diff --git a/.github/workflows/server-tests-mariadb.yml b/.github/workflows/server-tests-mariadb.yml index ccdfc8c109..1e5125eaf7 100644 --- a/.github/workflows/server-tests-mariadb.yml +++ b/.github/workflows/server-tests-mariadb.yml @@ -31,6 +31,9 @@ jobs: test: runs-on: ubuntu-latest timeout-minutes: 60 + env: + NODE_ENV: "production" + WITH_COVERAGE: ${{ github.event_name != 'pull_request' }} strategy: fail-fast: false @@ -117,11 +120,11 @@ jobs: FRAPPE_BRANCH: ${{ github.event.inputs.branch }} - name: Run Tests - run: 'cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --app erpnext --with-coverage --total-builds 4 --build-number ${{ matrix.container }}' + run: 'cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --app erpnext --total-builds 4 --build-number ${{ matrix.container }}' env: TYPE: server - CI_BUILD_ID: ${{ github.run_id }} - ORCHESTRATOR_URL: http://test-orchestrator.frappe.io + CAPTURE_COVERAGE: ${{ github.event_name != 'pull_request' }} + - name: Show bench output if: ${{ always() }} @@ -129,6 +132,7 @@ jobs: - name: Upload coverage data uses: actions/upload-artifact@v3 + if: github.event_name != 'pull_request' with: name: coverage-${{ matrix.container }} path: /home/runner/frappe-bench/sites/coverage.xml @@ -137,6 +141,7 @@ jobs: name: Coverage Wrap Up needs: test runs-on: ubuntu-latest + if: ${{ github.event_name != 'pull_request' }} steps: - name: Clone uses: actions/checkout@v2 @@ -148,5 +153,6 @@ jobs: uses: codecov/codecov-action@v2 with: name: MariaDB + token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: true verbose: true diff --git a/README.md b/README.md index 710187ad2f..4f65ceb70b 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,7 @@

ERP made simple

-[![CI](https://github.com/frappe/erpnext/actions/workflows/server-tests.yml/badge.svg?branch=develop)](https://github.com/frappe/erpnext/actions/workflows/server-tests.yml) -[![UI](https://github.com/erpnext/erpnext_ui_tests/actions/workflows/ui-tests.yml/badge.svg?branch=develop&event=schedule)](https://github.com/erpnext/erpnext_ui_tests/actions/workflows/ui-tests.yml) +[![CI](https://github.com/frappe/erpnext/actions/workflows/server-tests-mariadb.yml/badge.svg?event=schedule)](https://github.com/frappe/erpnext/actions/workflows/server-tests-mariadb.yml) [![Open Source Helpers](https://www.codetriage.com/frappe/erpnext/badges/users.svg)](https://www.codetriage.com/frappe/erpnext) [![codecov](https://codecov.io/gh/frappe/erpnext/branch/develop/graph/badge.svg?token=0TwvyUg3I5)](https://codecov.io/gh/frappe/erpnext) [![docker pulls](https://img.shields.io/docker/pulls/frappe/erpnext-worker.svg)](https://hub.docker.com/r/frappe/erpnext-worker) diff --git a/erpnext/accounts/doctype/account/account.py b/erpnext/accounts/doctype/account/account.py index 651599dafd..3f11798c2a 100644 --- a/erpnext/accounts/doctype/account/account.py +++ b/erpnext/accounts/doctype/account/account.py @@ -118,6 +118,7 @@ class Account(NestedSet): self.validate_balance_must_be_debit_or_credit() self.validate_account_currency() self.validate_root_company_and_sync_account_to_children() + self.validate_receivable_payable_account_type() def validate_parent_child_account_type(self): if self.parent_account: @@ -188,6 +189,24 @@ class Account(NestedSet): "Balance Sheet" if self.root_type in ("Asset", "Liability", "Equity") else "Profit and Loss" ) + def validate_receivable_payable_account_type(self): + doc_before_save = self.get_doc_before_save() + receivable_payable_types = ["Receivable", "Payable"] + if ( + doc_before_save + and doc_before_save.account_type in receivable_payable_types + and doc_before_save.account_type != self.account_type + ): + # check for ledger entries + if frappe.db.get_all("GL Entry", filters={"account": self.name, "is_cancelled": 0}, limit=1): + msg = _( + "There are ledger entries against this account. Changing {0} to non-{1} in live system will cause incorrect output in 'Accounts {2}' report" + ).format( + frappe.bold("Account Type"), doc_before_save.account_type, doc_before_save.account_type + ) + frappe.msgprint(msg) + self.add_comment("Comment", msg) + def validate_root_details(self): doc_before_save = self.get_doc_before_save() diff --git a/erpnext/accounts/doctype/account/test_account.py b/erpnext/accounts/doctype/account/test_account.py index eb3e00b388..7d0869b600 100644 --- a/erpnext/accounts/doctype/account/test_account.py +++ b/erpnext/accounts/doctype/account/test_account.py @@ -6,6 +6,7 @@ import unittest import frappe from frappe.test_runner import make_test_records +from frappe.utils import nowdate from erpnext.accounts.doctype.account.account import ( InvalidAccountMergeError, @@ -324,6 +325,19 @@ class TestAccount(unittest.TestCase): acc.account_currency = "USD" self.assertRaises(frappe.ValidationError, acc.save) + def test_account_balance(self): + from erpnext.accounts.utils import get_balance_on + + if not frappe.db.exists("Account", "Test Percent Account %5 - _TC"): + acc = frappe.new_doc("Account") + acc.account_name = "Test Percent Account %5" + acc.parent_account = "Tax Assets - _TC" + acc.company = "_Test Company" + acc.insert() + + balance = get_balance_on(account="Test Percent Account %5 - _TC", date=nowdate()) + self.assertEqual(balance, 0) + def _make_test_records(verbose=None): from frappe.test_runner import make_test_objects diff --git a/erpnext/accounts/doctype/bank_account/bank_account.py b/erpnext/accounts/doctype/bank_account/bank_account.py index ace4bb193d..df4bd5655a 100644 --- a/erpnext/accounts/doctype/bank_account/bank_account.py +++ b/erpnext/accounts/doctype/bank_account/bank_account.py @@ -9,6 +9,7 @@ from frappe.contacts.address_and_contact import ( load_address_and_contact, ) from frappe.model.document import Document +from frappe.utils import comma_and, get_link_to_form class BankAccount(Document): @@ -52,6 +53,17 @@ class BankAccount(Document): def validate(self): self.validate_company() self.validate_iban() + self.validate_account() + + def validate_account(self): + if self.account: + if accounts := frappe.db.get_all("Bank Account", filters={"account": self.account}, as_list=1): + frappe.throw( + _("'{0}' account is already used by {1}. Use another account.").format( + frappe.bold(self.account), + frappe.bold(comma_and([get_link_to_form(self.doctype, x[0]) for x in accounts])), + ) + ) def validate_company(self): if self.is_company_account and not self.company: diff --git a/erpnext/accounts/doctype/bank_clearance/bank_clearance.py b/erpnext/accounts/doctype/bank_clearance/bank_clearance.py index 4b97619f29..8a505a8dee 100644 --- a/erpnext/accounts/doctype/bank_clearance/bank_clearance.py +++ b/erpnext/accounts/doctype/bank_clearance/bank_clearance.py @@ -5,7 +5,9 @@ import frappe from frappe import _, msgprint from frappe.model.document import Document +from frappe.query_builder.custom import ConstantColumn from frappe.utils import flt, fmt_money, getdate +from pypika import Order import erpnext @@ -179,39 +181,62 @@ def get_payment_entries_for_bank_clearance( pos_sales_invoices, pos_purchase_invoices = [], [] if include_pos_transactions: - pos_sales_invoices = frappe.db.sql( - """ - select - "Sales Invoice Payment" as payment_document, sip.name as payment_entry, sip.amount as debit, - si.posting_date, si.customer as against_account, sip.clearance_date, - account.account_currency, 0 as credit - from `tabSales Invoice Payment` sip, `tabSales Invoice` si, `tabAccount` account - where - sip.account=%(account)s and si.docstatus=1 and sip.parent = si.name - and account.name = sip.account and si.posting_date >= %(from)s and si.posting_date <= %(to)s - order by - si.posting_date ASC, si.name DESC - """, - {"account": account, "from": from_date, "to": to_date}, - as_dict=1, - ) + si_payment = frappe.qb.DocType("Sales Invoice Payment") + si = frappe.qb.DocType("Sales Invoice") + acc = frappe.qb.DocType("Account") - pos_purchase_invoices = frappe.db.sql( - """ - select - "Purchase Invoice" as payment_document, pi.name as payment_entry, pi.paid_amount as credit, - pi.posting_date, pi.supplier as against_account, pi.clearance_date, - account.account_currency, 0 as debit - from `tabPurchase Invoice` pi, `tabAccount` account - where - pi.cash_bank_account=%(account)s and pi.docstatus=1 and account.name = pi.cash_bank_account - and pi.posting_date >= %(from)s and pi.posting_date <= %(to)s - order by - pi.posting_date ASC, pi.name DESC - """, - {"account": account, "from": from_date, "to": to_date}, - as_dict=1, - ) + pos_sales_invoices = ( + frappe.qb.from_(si_payment) + .inner_join(si) + .on(si_payment.parent == si.name) + .inner_join(acc) + .on(si_payment.account == acc.name) + .select( + ConstantColumn("Sales Invoice").as_("payment_document"), + si.name.as_("payment_entry"), + si_payment.reference_no.as_("cheque_number"), + si_payment.amount.as_("debit"), + si.posting_date, + si.customer.as_("against_account"), + si_payment.clearance_date, + acc.account_currency, + ConstantColumn(0).as_("credit"), + ) + .where( + (si.docstatus == 1) + & (si_payment.account == account) + & (si.posting_date >= from_date) + & (si.posting_date <= to_date) + ) + .orderby(si.posting_date) + .orderby(si.name, order=Order.desc) + ).run(as_dict=True) + + pi = frappe.qb.DocType("Purchase Invoice") + + pos_purchase_invoices = ( + frappe.qb.from_(pi) + .inner_join(acc) + .on(pi.cash_bank_account == acc.name) + .select( + ConstantColumn("Purchase Invoice").as_("payment_document"), + pi.name.as_("payment_entry"), + pi.paid_amount.as_("credit"), + pi.posting_date, + pi.supplier.as_("against_account"), + pi.clearance_date, + acc.account_currency, + ConstantColumn(0).as_("debit"), + ) + .where( + (pi.docstatus == 1) + & (pi.cash_bank_account == account) + & (pi.posting_date >= from_date) + & (pi.posting_date <= to_date) + ) + .orderby(pi.posting_date) + .orderby(pi.name, order=Order.desc) + ).run(as_dict=True) entries = ( list(payment_entries) diff --git a/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py index 1a4747c55b..30e564c803 100644 --- a/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py +++ b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py @@ -80,7 +80,8 @@ class BankStatementImport(DataImport): from frappe.utils.background_jobs import is_job_enqueued from frappe.utils.scheduler import is_scheduler_inactive - if is_scheduler_inactive() and not frappe.flags.in_test: + run_now = frappe.flags.in_test or frappe.conf.developer_mode + if is_scheduler_inactive() and not run_now: frappe.throw(_("Scheduler is inactive. Cannot import data."), title=_("Scheduler Inactive")) job_id = f"bank_statement_import::{self.name}" @@ -97,7 +98,7 @@ class BankStatementImport(DataImport): google_sheets_url=self.google_sheets_url, bank=self.bank, template_options=self.template_options, - now=frappe.conf.developer_mode or frappe.flags.in_test, + now=run_now, ) return True diff --git a/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py b/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py index 7bb3f4183b..1fe3608f56 100644 --- a/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py +++ b/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py @@ -32,8 +32,16 @@ class TestBankTransaction(FrappeTestCase): frappe.db.delete(dt) clear_loan_transactions() make_pos_profile() - add_transactions() - add_vouchers() + + # generate and use a uniq hash identifier for 'Bank Account' and it's linked GL 'Account' to avoid validation error + uniq_identifier = frappe.generate_hash(length=10) + gl_account = create_gl_account("_Test Bank " + uniq_identifier) + bank_account = create_bank_account( + gl_account=gl_account, bank_account_name="Checking Account " + uniq_identifier + ) + + add_transactions(bank_account=bank_account) + add_vouchers(gl_account=gl_account) # This test checks if ERPNext is able to provide a linked payment for a bank transaction based on the amount of the bank transaction. def test_linked_payments(self): @@ -219,7 +227,9 @@ def clear_loan_transactions(): frappe.db.delete("Loan Repayment") -def create_bank_account(bank_name="Citi Bank", account_name="_Test Bank - _TC"): +def create_bank_account( + bank_name="Citi Bank", gl_account="_Test Bank - _TC", bank_account_name="Checking Account" +): try: frappe.get_doc( { @@ -231,21 +241,35 @@ def create_bank_account(bank_name="Citi Bank", account_name="_Test Bank - _TC"): pass try: - frappe.get_doc( + bank_account = frappe.get_doc( { "doctype": "Bank Account", - "account_name": "Checking Account", + "account_name": bank_account_name, "bank": bank_name, - "account": account_name, + "account": gl_account, } ).insert(ignore_if_duplicate=True) except frappe.DuplicateEntryError: pass + return bank_account.name -def add_transactions(): - create_bank_account() +def create_gl_account(gl_account_name="_Test Bank - _TC"): + gl_account = frappe.get_doc( + { + "doctype": "Account", + "company": "_Test Company", + "parent_account": "Current Assets - _TC", + "account_type": "Bank", + "is_group": 0, + "account_name": gl_account_name, + } + ).insert() + return gl_account.name + + +def add_transactions(bank_account="_Test Bank - _TC"): doc = frappe.get_doc( { "doctype": "Bank Transaction", @@ -253,7 +277,7 @@ def add_transactions(): "date": "2018-10-23", "deposit": 1200, "currency": "INR", - "bank_account": "Checking Account - Citi Bank", + "bank_account": bank_account, } ).insert() doc.submit() @@ -265,7 +289,7 @@ def add_transactions(): "date": "2018-10-23", "deposit": 1700, "currency": "INR", - "bank_account": "Checking Account - Citi Bank", + "bank_account": bank_account, } ).insert() doc.submit() @@ -277,7 +301,7 @@ def add_transactions(): "date": "2018-10-26", "withdrawal": 690, "currency": "INR", - "bank_account": "Checking Account - Citi Bank", + "bank_account": bank_account, } ).insert() doc.submit() @@ -289,7 +313,7 @@ def add_transactions(): "date": "2018-10-27", "deposit": 3900, "currency": "INR", - "bank_account": "Checking Account - Citi Bank", + "bank_account": bank_account, } ).insert() doc.submit() @@ -301,13 +325,13 @@ def add_transactions(): "date": "2018-10-27", "withdrawal": 109080, "currency": "INR", - "bank_account": "Checking Account - Citi Bank", + "bank_account": bank_account, } ).insert() doc.submit() -def add_vouchers(): +def add_vouchers(gl_account="_Test Bank - _TC"): try: frappe.get_doc( { @@ -323,7 +347,7 @@ def add_vouchers(): pi = make_purchase_invoice(supplier="Conrad Electronic", qty=1, rate=690) - pe = get_payment_entry("Purchase Invoice", pi.name, bank_account="_Test Bank - _TC") + pe = get_payment_entry("Purchase Invoice", pi.name, bank_account=gl_account) pe.reference_no = "Conrad Oct 18" pe.reference_date = "2018-10-24" pe.insert() @@ -342,14 +366,14 @@ def add_vouchers(): pass pi = make_purchase_invoice(supplier="Mr G", qty=1, rate=1200) - pe = get_payment_entry("Purchase Invoice", pi.name, bank_account="_Test Bank - _TC") + pe = get_payment_entry("Purchase Invoice", pi.name, bank_account=gl_account) pe.reference_no = "Herr G Oct 18" pe.reference_date = "2018-10-24" pe.insert() pe.submit() pi = make_purchase_invoice(supplier="Mr G", qty=1, rate=1700) - pe = get_payment_entry("Purchase Invoice", pi.name, bank_account="_Test Bank - _TC") + pe = get_payment_entry("Purchase Invoice", pi.name, bank_account=gl_account) pe.reference_no = "Herr G Nov 18" pe.reference_date = "2018-11-01" pe.insert() @@ -380,10 +404,10 @@ def add_vouchers(): pass pi = make_purchase_invoice(supplier="Poore Simon's", qty=1, rate=3900, is_paid=1, do_not_save=1) - pi.cash_bank_account = "_Test Bank - _TC" + pi.cash_bank_account = gl_account pi.insert() pi.submit() - pe = get_payment_entry("Purchase Invoice", pi.name, bank_account="_Test Bank - _TC") + pe = get_payment_entry("Purchase Invoice", pi.name, bank_account=gl_account) pe.reference_no = "Poore Simon's Oct 18" pe.reference_date = "2018-10-28" pe.paid_amount = 690 @@ -392,7 +416,7 @@ def add_vouchers(): pe.submit() si = create_sales_invoice(customer="Poore Simon's", qty=1, rate=3900) - pe = get_payment_entry("Sales Invoice", si.name, bank_account="_Test Bank - _TC") + pe = get_payment_entry("Sales Invoice", si.name, bank_account=gl_account) pe.reference_no = "Poore Simon's Oct 18" pe.reference_date = "2018-10-28" pe.insert() @@ -415,16 +439,12 @@ def add_vouchers(): if not frappe.db.get_value( "Mode of Payment Account", {"company": "_Test Company", "parent": "Cash"} ): - mode_of_payment.append( - "accounts", {"company": "_Test Company", "default_account": "_Test Bank - _TC"} - ) + mode_of_payment.append("accounts", {"company": "_Test Company", "default_account": gl_account}) mode_of_payment.save() si = create_sales_invoice(customer="Fayva", qty=1, rate=109080, do_not_save=1) si.is_pos = 1 - si.append( - "payments", {"mode_of_payment": "Cash", "account": "_Test Bank - _TC", "amount": 109080} - ) + si.append("payments", {"mode_of_payment": "Cash", "account": gl_account, "amount": 109080}) si.insert() si.submit() diff --git a/erpnext/accounts/doctype/gl_entry/gl_entry.py b/erpnext/accounts/doctype/gl_entry/gl_entry.py index 777a5bb91c..def2838b75 100644 --- a/erpnext/accounts/doctype/gl_entry/gl_entry.py +++ b/erpnext/accounts/doctype/gl_entry/gl_entry.py @@ -13,16 +13,9 @@ import erpnext from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( get_checks_for_pl_and_bs_accounts, ) -from erpnext.accounts.doctype.accounting_dimension_filter.accounting_dimension_filter import ( - get_dimension_filter_map, -) from erpnext.accounts.party import validate_party_frozen_disabled, validate_party_gle_currency from erpnext.accounts.utils import get_account_currency, get_fiscal_year -from erpnext.exceptions import ( - InvalidAccountCurrency, - InvalidAccountDimensionError, - MandatoryAccountDimensionError, -) +from erpnext.exceptions import InvalidAccountCurrency exclude_from_linked_with = True @@ -98,7 +91,6 @@ class GLEntry(Document): if not self.flags.from_repost and self.voucher_type != "Period Closing Voucher": self.validate_account_details(adv_adj) self.validate_dimensions_for_pl_and_bs() - self.validate_allowed_dimensions() validate_balance_type(self.account, adv_adj) validate_frozen_account(self.account, adv_adj) @@ -208,42 +200,6 @@ class GLEntry(Document): ) ) - def validate_allowed_dimensions(self): - dimension_filter_map = get_dimension_filter_map() - for key, value in dimension_filter_map.items(): - dimension = key[0] - account = key[1] - - if self.account == account: - if value["is_mandatory"] and not self.get(dimension): - frappe.throw( - _("{0} is mandatory for account {1}").format( - frappe.bold(frappe.unscrub(dimension)), frappe.bold(self.account) - ), - MandatoryAccountDimensionError, - ) - - if value["allow_or_restrict"] == "Allow": - if self.get(dimension) and self.get(dimension) not in value["allowed_dimensions"]: - frappe.throw( - _("Invalid value {0} for {1} against account {2}").format( - frappe.bold(self.get(dimension)), - frappe.bold(frappe.unscrub(dimension)), - frappe.bold(self.account), - ), - InvalidAccountDimensionError, - ) - else: - if self.get(dimension) and self.get(dimension) in value["allowed_dimensions"]: - frappe.throw( - _("Invalid value {0} for {1} against account {2}").format( - frappe.bold(self.get(dimension)), - frappe.bold(frappe.unscrub(dimension)), - frappe.bold(self.account), - ), - InvalidAccountDimensionError, - ) - def check_pl_account(self): if ( self.is_opening == "Yes" diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index c0dfaffc59..df67faf924 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -1167,7 +1167,9 @@ class JournalEntry(AccountsController): @frappe.whitelist() -def get_default_bank_cash_account(company, account_type=None, mode_of_payment=None, account=None): +def get_default_bank_cash_account( + company, account_type=None, mode_of_payment=None, account=None, ignore_permissions=False +): from erpnext.accounts.doctype.sales_invoice.sales_invoice import get_bank_cash_account if mode_of_payment: @@ -1205,7 +1207,7 @@ def get_default_bank_cash_account(company, account_type=None, mode_of_payment=No return frappe._dict( { "account": account, - "balance": get_balance_on(account), + "balance": get_balance_on(account, ignore_account_permission=ignore_permissions), "account_currency": account_details.account_currency, "account_type": account_details.account_type, } diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index f23d2c9137..c55c820152 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -2220,6 +2220,7 @@ def get_payment_entry( party_type=None, payment_type=None, reference_date=None, + ignore_permissions=False, ): doc = frappe.get_doc(dt, dn) over_billing_allowance = frappe.db.get_single_value("Accounts Settings", "over_billing_allowance") @@ -2242,14 +2243,14 @@ def get_payment_entry( ) # bank or cash - bank = get_bank_cash_account(doc, bank_account) + bank = get_bank_cash_account(doc, bank_account, ignore_permissions=ignore_permissions) # if default bank or cash account is not set in company master and party has default company bank account, fetch it if party_type in ["Customer", "Supplier"] and not bank: party_bank_account = get_party_bank_account(party_type, doc.get(scrub(party_type))) if party_bank_account: account = frappe.db.get_value("Bank Account", party_bank_account, "account") - bank = get_bank_cash_account(doc, account) + bank = get_bank_cash_account(doc, account, ignore_permissions=ignore_permissions) paid_amount, received_amount = set_paid_amount_and_received_amount( dt, party_account_currency, bank, outstanding_amount, payment_type, bank_amount, doc @@ -2389,9 +2390,13 @@ def update_accounting_dimensions(pe, doc): pe.set(dimension, doc.get(dimension)) -def get_bank_cash_account(doc, bank_account): +def get_bank_cash_account(doc, bank_account, ignore_permissions=False): bank = get_default_bank_cash_account( - doc.company, "Bank", mode_of_payment=doc.get("mode_of_payment"), account=bank_account + doc.company, + "Bank", + mode_of_payment=doc.get("mode_of_payment"), + account=bank_account, + ignore_permissions=ignore_permissions, ) if not bank: diff --git a/erpnext/accounts/doctype/payment_order/test_payment_order.py b/erpnext/accounts/doctype/payment_order/test_payment_order.py index 0dcb1794b9..60f288e1f0 100644 --- a/erpnext/accounts/doctype/payment_order/test_payment_order.py +++ b/erpnext/accounts/doctype/payment_order/test_payment_order.py @@ -4,9 +4,13 @@ import unittest import frappe +from frappe.tests.utils import FrappeTestCase from frappe.utils import getdate -from erpnext.accounts.doctype.bank_transaction.test_bank_transaction import create_bank_account +from erpnext.accounts.doctype.bank_transaction.test_bank_transaction import ( + create_bank_account, + create_gl_account, +) from erpnext.accounts.doctype.payment_entry.payment_entry import ( get_payment_entry, make_payment_order, @@ -14,28 +18,32 @@ from erpnext.accounts.doctype.payment_entry.payment_entry import ( from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice -class TestPaymentOrder(unittest.TestCase): +class TestPaymentOrder(FrappeTestCase): def setUp(self): - create_bank_account() + # generate and use a uniq hash identifier for 'Bank Account' and it's linked GL 'Account' to avoid validation error + uniq_identifier = frappe.generate_hash(length=10) + self.gl_account = create_gl_account("_Test Bank " + uniq_identifier) + self.bank_account = create_bank_account( + gl_account=self.gl_account, bank_account_name="Checking Account " + uniq_identifier + ) def tearDown(self): - for bt in frappe.get_all("Payment Order"): - doc = frappe.get_doc("Payment Order", bt.name) - doc.cancel() - doc.delete() + frappe.db.rollback() def test_payment_order_creation_against_payment_entry(self): purchase_invoice = make_purchase_invoice() payment_entry = get_payment_entry( - "Purchase Invoice", purchase_invoice.name, bank_account="_Test Bank - _TC" + "Purchase Invoice", purchase_invoice.name, bank_account=self.gl_account ) payment_entry.reference_no = "_Test_Payment_Order" payment_entry.reference_date = getdate() - payment_entry.party_bank_account = "Checking Account - Citi Bank" + payment_entry.party_bank_account = self.bank_account payment_entry.insert() payment_entry.submit() - doc = create_payment_order_against_payment_entry(payment_entry, "Payment Entry") + doc = create_payment_order_against_payment_entry( + payment_entry, "Payment Entry", self.bank_account + ) reference_doc = doc.get("references")[0] self.assertEqual(reference_doc.reference_name, payment_entry.name) self.assertEqual(reference_doc.reference_doctype, "Payment Entry") @@ -43,13 +51,13 @@ class TestPaymentOrder(unittest.TestCase): self.assertEqual(reference_doc.amount, 250) -def create_payment_order_against_payment_entry(ref_doc, order_type): +def create_payment_order_against_payment_entry(ref_doc, order_type, bank_account): payment_order = frappe.get_doc( dict( doctype="Payment Order", company="_Test Company", payment_order_type=order_type, - company_bank_account="Checking Account - Citi Bank", + company_bank_account=bank_account, ) ) doc = make_payment_order(ref_doc.name, payment_order) diff --git a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py index d7a73f0ce7..fb75a0f7ca 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py @@ -591,6 +591,70 @@ class TestPaymentReconciliation(FrappeTestCase): self.assertEqual(si.status, "Paid") self.assertEqual(si.outstanding_amount, 0) + def test_invoice_status_after_cr_note_cancellation(self): + # This test case is made after the 'always standalone Credit/Debit notes' feature is introduced + transaction_date = nowdate() + amount = 100 + + si = self.create_sales_invoice(qty=1, rate=amount, posting_date=transaction_date) + + cr_note = self.create_sales_invoice( + qty=-1, rate=amount, posting_date=transaction_date, do_not_save=True, do_not_submit=True + ) + cr_note.is_return = 1 + cr_note.return_against = si.name + cr_note = cr_note.save().submit() + + pr = self.create_payment_reconciliation() + + pr.get_unreconciled_entries() + invoices = [x.as_dict() for x in pr.get("invoices")] + payments = [x.as_dict() for x in pr.get("payments")] + pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + pr.reconcile() + + pr.get_unreconciled_entries() + self.assertEqual(pr.get("invoices"), []) + self.assertEqual(pr.get("payments"), []) + + journals = frappe.db.get_all( + "Journal Entry", + filters={ + "is_system_generated": 1, + "docstatus": 1, + "voucher_type": "Credit Note", + "reference_type": si.doctype, + "reference_name": si.name, + }, + pluck="name", + ) + self.assertEqual(len(journals), 1) + + # assert status and outstanding + si.reload() + self.assertEqual(si.status, "Credit Note Issued") + self.assertEqual(si.outstanding_amount, 0) + + cr_note.reload() + cr_note.cancel() + # 'Credit Note' Journal should be auto cancelled + journals = frappe.db.get_all( + "Journal Entry", + filters={ + "is_system_generated": 1, + "docstatus": 1, + "voucher_type": "Credit Note", + "reference_type": si.doctype, + "reference_name": si.name, + }, + pluck="name", + ) + self.assertEqual(len(journals), 0) + # assert status and outstanding + si.reload() + self.assertEqual(si.status, "Unpaid") + self.assertEqual(si.outstanding_amount, 100) + def test_cr_note_partial_against_invoice(self): transaction_date = nowdate() amount = 100 diff --git a/erpnext/accounts/doctype/pos_invoice_item/pos_invoice_item.json b/erpnext/accounts/doctype/pos_invoice_item/pos_invoice_item.json index 5a281aaa4f..ad2889d0a0 100644 --- a/erpnext/accounts/doctype/pos_invoice_item/pos_invoice_item.json +++ b/erpnext/accounts/doctype/pos_invoice_item/pos_invoice_item.json @@ -80,13 +80,16 @@ "target_warehouse", "quality_inspection", "serial_and_batch_bundle", - "batch_no", + "use_serial_batch_fields", "col_break5", "allow_zero_valuation_rate", - "serial_no", "item_tax_rate", "actual_batch_qty", "actual_qty", + "section_break_tlhi", + "serial_no", + "column_break_ciit", + "batch_no", "edit_references", "sales_order", "so_detail", @@ -628,13 +631,13 @@ "options": "Quality Inspection" }, { + "depends_on": "eval:doc.use_serial_batch_fields === 1", "fieldname": "batch_no", "fieldtype": "Link", "hidden": 1, "label": "Batch No", "options": "Batch", - "print_hide": 1, - "read_only": 1 + "print_hide": 1 }, { "fieldname": "col_break5", @@ -649,14 +652,14 @@ "print_hide": 1 }, { + "depends_on": "eval:doc.use_serial_batch_fields === 1", "fieldname": "serial_no", "fieldtype": "Small Text", "hidden": 1, "in_list_view": 1, "label": "Serial No", "oldfieldname": "serial_no", - "oldfieldtype": "Small Text", - "read_only": 1 + "oldfieldtype": "Small Text" }, { "fieldname": "item_tax_rate", @@ -824,17 +827,33 @@ "read_only": 1 }, { + "depends_on": "eval:doc.use_serial_batch_fields === 1", "fieldname": "serial_and_batch_bundle", "fieldtype": "Link", "label": "Serial and Batch Bundle", "no_copy": 1, "options": "Serial and Batch Bundle", "print_hide": 1 + }, + { + "default": "0", + "fieldname": "use_serial_batch_fields", + "fieldtype": "Check", + "label": "Use Serial No / Batch Fields" + }, + { + "depends_on": "eval:doc.use_serial_batch_fields === 1", + "fieldname": "section_break_tlhi", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_ciit", + "fieldtype": "Column Break" } ], "istable": 1, "links": [], - "modified": "2023-11-14 18:33:22.585715", + "modified": "2024-02-04 16:36:25.665743", "modified_by": "Administrator", "module": "Accounts", "name": "POS Invoice Item", diff --git a/erpnext/accounts/doctype/pos_invoice_item/pos_invoice_item.py b/erpnext/accounts/doctype/pos_invoice_item/pos_invoice_item.py index e2a62f1336..55a577b0c5 100644 --- a/erpnext/accounts/doctype/pos_invoice_item/pos_invoice_item.py +++ b/erpnext/accounts/doctype/pos_invoice_item/pos_invoice_item.py @@ -82,6 +82,7 @@ class POSInvoiceItem(Document): target_warehouse: DF.Link | None total_weight: DF.Float uom: DF.Link + use_serial_batch_fields: DF.Check warehouse: DF.Link | None weight_per_unit: DF.Float weight_uom: DF.Link | None diff --git a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py index c03b18a871..083c8fce18 100644 --- a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py +++ b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py @@ -120,18 +120,6 @@ def get_statement_dict(doc, get_statement_dict=False): statement_dict = {} ageing = "" - err_journals = None - if doc.report == "General Ledger" and doc.ignore_exchange_rate_revaluation_journals: - err_journals = frappe.db.get_all( - "Journal Entry", - filters={ - "company": doc.company, - "docstatus": 1, - "voucher_type": ("in", ["Exchange Rate Revaluation", "Exchange Gain Or Loss"]), - }, - as_list=True, - ) - for entry in doc.customers: if doc.include_ageing: ageing = set_ageing(doc, entry) @@ -144,8 +132,8 @@ def get_statement_dict(doc, get_statement_dict=False): ) filters = get_common_filters(doc) - if err_journals: - filters.update({"voucher_no_not_in": [x[0] for x in err_journals]}) + if doc.ignore_exchange_rate_revaluation_journals: + filters.update({"ignore_err": True}) if doc.report == "General Ledger": filters.update(get_gl_filters(doc, entry, tax_id, presentation_currency)) diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index 01821fc08e..3312f2e5dd 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -696,6 +696,7 @@ class PurchaseInvoice(BuyingController): # Updating stock ledger should always be called after updating prevdoc status, # because updating ordered qty in bin depends upon updated ordered qty in PO if self.update_stock == 1: + self.make_bundle_using_old_serial_batch_fields() self.update_stock_ledger() if self.is_old_subcontracting_flow: diff --git a/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json b/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json index 26984d96ef..3ee4214ae7 100644 --- a/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json +++ b/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json @@ -62,16 +62,19 @@ "rm_supp_cost", "warehouse_section", "warehouse", - "from_warehouse", - "quality_inspection", "add_serial_batch_bundle", "serial_and_batch_bundle", - "serial_no", + "use_serial_batch_fields", "col_br_wh", + "from_warehouse", + "quality_inspection", "rejected_warehouse", "rejected_serial_and_batch_bundle", - "batch_no", + "section_break_rqbe", + "serial_no", "rejected_serial_no", + "column_break_vbbb", + "batch_no", "manufacture_details", "manufacturer", "column_break_13", @@ -440,13 +443,11 @@ "print_hide": 1 }, { - "depends_on": "eval:!doc.is_fixed_asset", + "depends_on": "eval:!doc.is_fixed_asset && doc.use_serial_batch_fields === 1 && parent.update_stock === 1", "fieldname": "batch_no", "fieldtype": "Link", - "hidden": 1, "label": "Batch No", "options": "Batch", - "read_only": 1, "search_index": 1 }, { @@ -454,21 +455,18 @@ "fieldtype": "Column Break" }, { - "depends_on": "eval:!doc.is_fixed_asset", + "depends_on": "eval:!doc.is_fixed_asset && doc.use_serial_batch_fields === 1 && parent.update_stock === 1", "fieldname": "serial_no", "fieldtype": "Text", - "hidden": 1, - "label": "Serial No", - "read_only": 1 + "label": "Serial No" }, { - "depends_on": "eval:!doc.is_fixed_asset", + "depends_on": "eval:!doc.is_fixed_asset && doc.use_serial_batch_fields === 1 && parent.update_stock === 1", "fieldname": "rejected_serial_no", "fieldtype": "Text", "label": "Rejected Serial No", "no_copy": 1, - "print_hide": 1, - "read_only": 1 + "print_hide": 1 }, { "fieldname": "accounting", @@ -891,7 +889,7 @@ "label": "Apply TDS" }, { - "depends_on": "eval:parent.update_stock == 1", + "depends_on": "eval:parent.update_stock == 1 && (doc.use_serial_batch_fields === 0 || doc.docstatus === 1)", "fieldname": "serial_and_batch_bundle", "fieldtype": "Link", "label": "Serial and Batch Bundle", @@ -901,7 +899,7 @@ "search_index": 1 }, { - "depends_on": "eval:parent.update_stock == 1", + "depends_on": "eval:parent.update_stock == 1 && (doc.use_serial_batch_fields === 0 || doc.docstatus === 1)", "fieldname": "rejected_serial_and_batch_bundle", "fieldtype": "Link", "label": "Rejected Serial and Batch Bundle", @@ -916,16 +914,31 @@ "options": "Asset" }, { - "depends_on": "eval:parent.update_stock === 1", + "depends_on": "eval:parent.update_stock === 1 && (doc.use_serial_batch_fields === 0 || doc.docstatus === 1)", "fieldname": "add_serial_batch_bundle", "fieldtype": "Button", "label": "Add Serial / Batch No" + }, + { + "default": "0", + "fieldname": "use_serial_batch_fields", + "fieldtype": "Check", + "label": "Use Serial No / Batch Fields" + }, + { + "depends_on": "eval:!doc.is_fixed_asset && doc.use_serial_batch_fields === 1 && parent.update_stock === 1", + "fieldname": "section_break_rqbe", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_vbbb", + "fieldtype": "Column Break" } ], "idx": 1, "istable": 1, "links": [], - "modified": "2024-01-21 19:46:25.537861", + "modified": "2024-02-04 14:11:52.742228", "modified_by": "Administrator", "module": "Accounts", "name": "Purchase Invoice Item", diff --git a/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.py b/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.py index e48d22379a..ccbc34749d 100644 --- a/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.py +++ b/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.py @@ -88,6 +88,7 @@ class PurchaseInvoiceItem(Document): stock_uom_rate: DF.Currency total_weight: DF.Float uom: DF.Link + use_serial_batch_fields: DF.Check valuation_rate: DF.Currency warehouse: DF.Link | None weight_per_unit: DF.Float diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 7ce6fd0ff4..76ec4a4cf7 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -447,6 +447,7 @@ class SalesInvoice(SellingController): # Updating stock ledger should always be called after updating prevdoc status, # because updating reserved qty in bin depends upon updated delivered qty in SO if self.update_stock == 1: + self.make_bundle_using_old_serial_batch_fields() self.update_stock_ledger() # this sequence because outstanding may get -ve diff --git a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json index ec9e792d7d..d06c7861da 100644 --- a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json +++ b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json @@ -83,14 +83,17 @@ "quality_inspection", "pick_serial_and_batch", "serial_and_batch_bundle", - "batch_no", - "incoming_rate", + "use_serial_batch_fields", "col_break5", "allow_zero_valuation_rate", - "serial_no", + "incoming_rate", "item_tax_rate", "actual_batch_qty", "actual_qty", + "section_break_eoec", + "serial_no", + "column_break_ytgd", + "batch_no", "edit_references", "sales_order", "so_detail", @@ -600,12 +603,11 @@ "options": "Quality Inspection" }, { + "depends_on": "eval: doc.use_serial_batch_fields === 1 && parent.update_stock === 1", "fieldname": "batch_no", "fieldtype": "Link", - "hidden": 1, "label": "Batch No", "options": "Batch", - "read_only": 1, "search_index": 1 }, { @@ -621,13 +623,12 @@ "print_hide": 1 }, { + "depends_on": "eval: doc.use_serial_batch_fields === 1 && parent.update_stock === 1", "fieldname": "serial_no", "fieldtype": "Small Text", - "hidden": 1, "label": "Serial No", "oldfieldname": "serial_no", - "oldfieldtype": "Small Text", - "read_only": 1 + "oldfieldtype": "Small Text" }, { "fieldname": "item_group", @@ -891,6 +892,7 @@ "read_only": 1 }, { + "depends_on": "eval:parent.update_stock == 1 && (doc.use_serial_batch_fields === 0 || doc.docstatus === 1)", "fieldname": "serial_and_batch_bundle", "fieldtype": "Link", "label": "Serial and Batch Bundle", @@ -904,12 +906,27 @@ "fieldname": "pick_serial_and_batch", "fieldtype": "Button", "label": "Pick Serial / Batch No" + }, + { + "default": "0", + "fieldname": "use_serial_batch_fields", + "fieldtype": "Check", + "label": "Use Serial No / Batch Fields" + }, + { + "depends_on": "eval:doc.use_serial_batch_fields === 1 && parent.update_stock === 1", + "fieldname": "section_break_eoec", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_ytgd", + "fieldtype": "Column Break" } ], "idx": 1, "istable": 1, "links": [], - "modified": "2023-12-29 13:03:14.121298", + "modified": "2024-02-04 11:52:16.106541", "modified_by": "Administrator", "module": "Accounts", "name": "Sales Invoice Item", diff --git a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.py b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.py index 80f67748f4..c71d08e7f7 100644 --- a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.py +++ b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.py @@ -86,6 +86,7 @@ class SalesInvoiceItem(Document): target_warehouse: DF.Link | None total_weight: DF.Float uom: DF.Link + use_serial_batch_fields: DF.Check warehouse: DF.Link | None weight_per_unit: DF.Float weight_uom: DF.Link | None diff --git a/erpnext/accounts/doctype/sales_invoice_payment/sales_invoice_payment.json b/erpnext/accounts/doctype/sales_invoice_payment/sales_invoice_payment.json index 5ab46b7fd5..bd59f65dd4 100644 --- a/erpnext/accounts/doctype/sales_invoice_payment/sales_invoice_payment.json +++ b/erpnext/accounts/doctype/sales_invoice_payment/sales_invoice_payment.json @@ -8,6 +8,7 @@ "default", "mode_of_payment", "amount", + "reference_no", "column_break_3", "account", "type", @@ -75,11 +76,16 @@ "hidden": 1, "label": "Default", "read_only": 1 + }, + { + "fieldname": "reference_no", + "fieldtype": "Data", + "label": "Reference No" } ], "istable": 1, "links": [], - "modified": "2020-08-03 12:45:39.986598", + "modified": "2024-01-23 16:20:06.436979", "modified_by": "Administrator", "module": "Accounts", "name": "Sales Invoice Payment", @@ -87,5 +93,6 @@ "permissions": [], "quick_entry": 1, "sort_field": "modified", - "sort_order": "DESC" + "sort_order": "DESC", + "states": [] } \ No newline at end of file diff --git a/erpnext/accounts/doctype/sales_invoice_payment/sales_invoice_payment.py b/erpnext/accounts/doctype/sales_invoice_payment/sales_invoice_payment.py index 57d0142406..e460a01155 100644 --- a/erpnext/accounts/doctype/sales_invoice_payment/sales_invoice_payment.py +++ b/erpnext/accounts/doctype/sales_invoice_payment/sales_invoice_payment.py @@ -23,6 +23,7 @@ class SalesInvoicePayment(Document): parent: DF.Data parentfield: DF.Data parenttype: DF.Data + reference_no: DF.Data | None type: DF.ReadOnly | None # end: auto-generated types diff --git a/erpnext/accounts/general_ledger.py b/erpnext/accounts/general_ledger.py index 1c8ac2f164..2e82886755 100644 --- a/erpnext/accounts/general_ledger.py +++ b/erpnext/accounts/general_ledger.py @@ -13,9 +13,13 @@ import erpnext from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( get_accounting_dimensions, ) +from erpnext.accounts.doctype.accounting_dimension_filter.accounting_dimension_filter import ( + get_dimension_filter_map, +) from erpnext.accounts.doctype.accounting_period.accounting_period import ClosedAccountingPeriod from erpnext.accounts.doctype.budget.budget import validate_expense_against_budget from erpnext.accounts.utils import create_payment_ledger_entry +from erpnext.exceptions import InvalidAccountDimensionError, MandatoryAccountDimensionError def make_gl_entries( @@ -355,6 +359,7 @@ def save_entries(gl_map, adv_adj, update_outstanding, from_repost=False): process_debit_credit_difference(gl_map) + dimension_filter_map = get_dimension_filter_map() if gl_map: check_freezing_date(gl_map[0]["posting_date"], adv_adj) is_opening = any(d.get("is_opening") == "Yes" for d in gl_map) @@ -362,6 +367,7 @@ def save_entries(gl_map, adv_adj, update_outstanding, from_repost=False): validate_against_pcv(is_opening, gl_map[0]["posting_date"], gl_map[0]["company"]) for entry in gl_map: + validate_allowed_dimensions(entry, dimension_filter_map) make_entry(entry, adv_adj, update_outstanding, from_repost) @@ -700,3 +706,39 @@ def set_as_cancel(voucher_type, voucher_no): where voucher_type=%s and voucher_no=%s and is_cancelled = 0""", (now(), frappe.session.user, voucher_type, voucher_no), ) + + +def validate_allowed_dimensions(gl_entry, dimension_filter_map): + for key, value in dimension_filter_map.items(): + dimension = key[0] + account = key[1] + + if gl_entry.account == account: + if value["is_mandatory"] and not gl_entry.get(dimension): + frappe.throw( + _("{0} is mandatory for account {1}").format( + frappe.bold(frappe.unscrub(dimension)), frappe.bold(gl_entry.account) + ), + MandatoryAccountDimensionError, + ) + + if value["allow_or_restrict"] == "Allow": + if gl_entry.get(dimension) and gl_entry.get(dimension) not in value["allowed_dimensions"]: + frappe.throw( + _("Invalid value {0} for {1} against account {2}").format( + frappe.bold(gl_entry.get(dimension)), + frappe.bold(frappe.unscrub(dimension)), + frappe.bold(gl_entry.account), + ), + InvalidAccountDimensionError, + ) + else: + if gl_entry.get(dimension) and gl_entry.get(dimension) in value["allowed_dimensions"]: + frappe.throw( + _("Invalid value {0} for {1} against account {2}").format( + frappe.bold(gl_entry.get(dimension)), + frappe.bold(frappe.unscrub(dimension)), + frappe.bold(gl_entry.account), + ), + InvalidAccountDimensionError, + ) diff --git a/erpnext/accounts/report/general_ledger/general_ledger.js b/erpnext/accounts/report/general_ledger/general_ledger.js index 79b5e4d9ec..b7b9d34e00 100644 --- a/erpnext/accounts/report/general_ledger/general_ledger.js +++ b/erpnext/accounts/report/general_ledger/general_ledger.js @@ -203,8 +203,14 @@ frappe.query_reports["General Ledger"] = { "fieldname": "show_remarks", "label": __("Show Remarks"), "fieldtype": "Check" + }, + { + "fieldname": "ignore_err", + "label": __("Ignore Exchange Rate Revaluation Journals"), + "fieldtype": "Check" } + ] } diff --git a/erpnext/accounts/report/general_ledger/general_ledger.py b/erpnext/accounts/report/general_ledger/general_ledger.py index 110ec758fc..cea3a7b57e 100644 --- a/erpnext/accounts/report/general_ledger/general_ledger.py +++ b/erpnext/accounts/report/general_ledger/general_ledger.py @@ -241,6 +241,19 @@ def get_conditions(filters): if filters.get("against_voucher_no"): conditions.append("against_voucher=%(against_voucher_no)s") + if filters.get("ignore_err"): + err_journals = frappe.db.get_all( + "Journal Entry", + filters={ + "company": filters.get("company"), + "docstatus": 1, + "voucher_type": ("in", ["Exchange Rate Revaluation", "Exchange Gain Or Loss"]), + }, + as_list=True, + ) + if err_journals: + filters.update({"voucher_no_not_in": [x[0] for x in err_journals]}) + if filters.get("voucher_no_not_in"): conditions.append("voucher_no not in %(voucher_no_not_in)s") diff --git a/erpnext/accounts/report/general_ledger/test_general_ledger.py b/erpnext/accounts/report/general_ledger/test_general_ledger.py index a8c362e78c..75f94309bc 100644 --- a/erpnext/accounts/report/general_ledger/test_general_ledger.py +++ b/erpnext/accounts/report/general_ledger/test_general_ledger.py @@ -3,7 +3,7 @@ import frappe from frappe.tests.utils import FrappeTestCase -from frappe.utils import today +from frappe.utils import flt, today from erpnext.accounts.report.general_ledger.general_ledger import execute @@ -148,3 +148,105 @@ class TestGeneralLedger(FrappeTestCase): self.assertEqual(data[2]["credit"], 900) self.assertEqual(data[3]["debit"], 100) self.assertEqual(data[3]["credit"], 100) + + def test_ignore_exchange_rate_journals_filter(self): + # create a new account with USD currency + account_name = "Test Debtors USD" + company = "_Test Company" + account = frappe.get_doc( + { + "account_name": account_name, + "is_group": 0, + "company": company, + "root_type": "Asset", + "report_type": "Balance Sheet", + "account_currency": "USD", + "parent_account": "Accounts Receivable - _TC", + "account_type": "Receivable", + "doctype": "Account", + } + ) + account.insert(ignore_if_duplicate=True) + # create a JV to debit 1000 USD at 75 exchange rate + jv = frappe.new_doc("Journal Entry") + jv.posting_date = today() + jv.company = company + jv.multi_currency = 1 + jv.cost_center = "_Test Cost Center - _TC" + jv.set( + "accounts", + [ + { + "account": account.name, + "party_type": "Customer", + "party": "_Test Customer USD", + "debit_in_account_currency": 1000, + "credit_in_account_currency": 0, + "exchange_rate": 75, + "cost_center": "_Test Cost Center - _TC", + }, + { + "account": "Cash - _TC", + "debit_in_account_currency": 0, + "credit_in_account_currency": 75000, + "cost_center": "_Test Cost Center - _TC", + }, + ], + ) + jv.save() + jv.submit() + + revaluation = frappe.new_doc("Exchange Rate Revaluation") + revaluation.posting_date = today() + revaluation.company = company + accounts = revaluation.get_accounts_data() + revaluation.extend("accounts", accounts) + row = revaluation.accounts[0] + row.new_exchange_rate = 83 + row.new_balance_in_base_currency = flt( + row.new_exchange_rate * flt(row.balance_in_account_currency) + ) + row.gain_loss = row.new_balance_in_base_currency - flt(row.balance_in_base_currency) + revaluation.set_total_gain_loss() + revaluation = revaluation.save().submit() + + # post journal entry for Revaluation doc + frappe.db.set_value( + "Company", company, "unrealized_exchange_gain_loss_account", "_Test Exchange Gain/Loss - _TC" + ) + revaluation_jv = revaluation.make_jv_for_revaluation() + revaluation_jv.cost_center = "_Test Cost Center - _TC" + for acc in revaluation_jv.get("accounts"): + acc.cost_center = "_Test Cost Center - _TC" + revaluation_jv.save() + revaluation_jv.submit() + + # With ignore_err enabled + columns, data = execute( + frappe._dict( + { + "company": company, + "from_date": today(), + "to_date": today(), + "account": [account.name], + "group_by": "Group by Voucher (Consolidated)", + "ignore_err": True, + } + ) + ) + self.assertNotIn(revaluation_jv.name, set([x.voucher_no for x in data])) + + # Without ignore_err enabled + columns, data = execute( + frappe._dict( + { + "company": company, + "from_date": today(), + "to_date": today(), + "account": [account.name], + "group_by": "Group by Voucher (Consolidated)", + "ignore_err": False, + } + ) + ) + self.assertIn(revaluation_jv.name, set([x.voucher_no for x in data])) diff --git a/erpnext/accounts/report/tax_withholding_details/tax_withholding_details.py b/erpnext/accounts/report/tax_withholding_details/tax_withholding_details.py index 4a80dd0339..0e3acd7b24 100644 --- a/erpnext/accounts/report/tax_withholding_details/tax_withholding_details.py +++ b/erpnext/accounts/report/tax_withholding_details/tax_withholding_details.py @@ -63,16 +63,14 @@ def get_result( tax_amount += entry.credit - entry.debit # infer tax withholding category from the account if it's the single account for this category tax_withholding_category = tds_accounts.get(entry.account) - rate = tax_rate_map.get(tax_withholding_category) # or else the consolidated value from the voucher document if not tax_withholding_category: - # or else from the party default tax_withholding_category = tax_category_map.get(name) - rate = tax_rate_map.get(tax_withholding_category) + # or else from the party default if not tax_withholding_category: tax_withholding_category = party_map.get(party, {}).get("tax_withholding_category") - rate = tax_rate_map.get(tax_withholding_category) + rate = tax_rate_map.get(tax_withholding_category) if net_total_map.get(name): if voucher_type == "Journal Entry" and tax_amount and rate: # back calcalute total amount from rate and tax_amount @@ -295,7 +293,7 @@ def get_tds_docs(filters): tds_accounts = {} for tds_acc in _tds_accounts: # if it turns out not to be the only tax withholding category, then don't include in the map - if tds_accounts.get(tds_acc["account"]): + if tds_acc["account"] in tds_accounts: tds_accounts[tds_acc["account"]] = None else: tds_accounts[tds_acc["account"]] = tds_acc["parent"] @@ -354,9 +352,6 @@ def get_tds_docs_query(filters, bank_accounts, tds_accounts): if filters.get("to_date"): query = query.where(gle.posting_date <= filters.get("to_date")) - if bank_accounts: - query = query.where(gle.against.notin(bank_accounts)) - if filters.get("party"): party = [filters.get("party")] jv_condition = gle.against.isin(party) | ( @@ -368,7 +363,14 @@ def get_tds_docs_query(filters, bank_accounts, tds_accounts): (gle.voucher_type == "Journal Entry") & ((gle.party_type == filters.get("party_type")) | (gle.party_type == "")) ) - query = query.where((gle.account.isin(tds_accounts) & jv_condition) | gle.party.isin(party)) + + query.where((gle.account.isin(tds_accounts) & jv_condition) | gle.party.isin(party)) + if bank_accounts: + query = query.where( + gle.against.notin(bank_accounts) & (gle.account.isin(tds_accounts) & jv_condition) + | gle.party.isin(party) + ) + return query @@ -408,7 +410,7 @@ def get_doc_info(vouchers, doctype, tax_category_map, net_total_map=None): "paid_amount_after_tax", "base_paid_amount", ], - "Journal Entry": ["tax_withholding_category", "total_amount"], + "Journal Entry": ["total_amount"], } entries = frappe.get_all( diff --git a/erpnext/accounts/report/tax_withholding_details/test_tax_withholding_details.py b/erpnext/accounts/report/tax_withholding_details/test_tax_withholding_details.py index b3f67378a9..7515616b0b 100644 --- a/erpnext/accounts/report/tax_withholding_details/test_tax_withholding_details.py +++ b/erpnext/accounts/report/tax_withholding_details/test_tax_withholding_details.py @@ -5,7 +5,6 @@ import frappe from frappe.tests.utils import FrappeTestCase from frappe.utils import today -from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice @@ -17,36 +16,63 @@ from erpnext.accounts.test.accounts_mixin import AccountsTestMixin from erpnext.accounts.utils import get_fiscal_year -class TestTdsPayableMonthly(AccountsTestMixin, FrappeTestCase): +class TestTaxWithholdingDetails(AccountsTestMixin, FrappeTestCase): def setUp(self): self.create_company() self.clear_old_entries() create_tax_accounts() - create_tcs_category() def test_tax_withholding_for_customers(self): + create_tax_category(cumulative_threshold=300) + frappe.db.set_value("Customer", "_Test Customer", "tax_withholding_category", "TCS") si = create_sales_invoice(rate=1000) pe = create_tcs_payment_entry() + jv = create_tcs_journal_entry() + filters = frappe._dict( company="_Test Company", party_type="Customer", from_date=today(), to_date=today() ) result = execute(filters)[1] expected_values = [ + # Check for JV totals using back calculation logic + [jv.name, "TCS", 0.075, -10000.0, -7.5, -10000.0], [pe.name, "TCS", 0.075, 2550, 0.53, 2550.53], [si.name, "TCS", 0.075, 1000, 0.52, 1000.52], ] self.check_expected_values(result, expected_values) + def test_single_account_for_multiple_categories(self): + create_tax_category("TDS - 1", rate=10, account="TDS - _TC") + inv_1 = make_purchase_invoice(rate=1000, do_not_submit=True) + inv_1.tax_withholding_category = "TDS - 1" + inv_1.submit() + + create_tax_category("TDS - 2", rate=20, account="TDS - _TC") + inv_2 = make_purchase_invoice(rate=1000, do_not_submit=True) + inv_2.tax_withholding_category = "TDS - 2" + inv_2.submit() + result = execute( + frappe._dict(company="_Test Company", party_type="Supplier", from_date=today(), to_date=today()) + )[1] + expected_values = [ + [inv_1.name, "TDS - 1", 10, 5000, 500, 5500], + [inv_2.name, "TDS - 2", 20, 5000, 1000, 6000], + ] + self.check_expected_values(result, expected_values) + def check_expected_values(self, result, expected_values): for i in range(len(result)): voucher = frappe._dict(result[i]) voucher_expected_values = expected_values[i] - self.assertEqual(voucher.ref_no, voucher_expected_values[0]) - self.assertEqual(voucher.section_code, voucher_expected_values[1]) - self.assertEqual(voucher.rate, voucher_expected_values[2]) - self.assertEqual(voucher.base_total, voucher_expected_values[3]) - self.assertAlmostEqual(voucher.tax_amount, voucher_expected_values[4]) - self.assertAlmostEqual(voucher.grand_total, voucher_expected_values[5]) + voucher_actual_values = ( + voucher.ref_no, + voucher.section_code, + voucher.rate, + voucher.base_total, + voucher.tax_amount, + voucher.grand_total, + ) + self.assertSequenceEqual(voucher_actual_values, voucher_expected_values) def tearDown(self): self.clear_old_entries() @@ -67,24 +93,20 @@ def create_tax_accounts(): ).insert(ignore_if_duplicate=True) -def create_tcs_category(): +def create_tax_category(category="TCS", rate=0.075, account="TCS - _TC", cumulative_threshold=0): fiscal_year = get_fiscal_year(today(), company="_Test Company") from_date = fiscal_year[1] to_date = fiscal_year[2] - tax_category = create_tax_withholding_category( - category_name="TCS", - rate=0.075, + create_tax_withholding_category( + category_name=category, + rate=rate, from_date=from_date, to_date=to_date, - account="TCS - _TC", - cumulative_threshold=300, + account=account, + cumulative_threshold=cumulative_threshold, ) - customer = frappe.get_doc("Customer", "_Test Customer") - customer.tax_withholding_category = "TCS" - customer.save() - def create_tcs_payment_entry(): payment_entry = create_payment_entry( @@ -109,3 +131,32 @@ def create_tcs_payment_entry(): ) payment_entry.submit() return payment_entry + + +def create_tcs_journal_entry(): + jv = frappe.new_doc("Journal Entry") + jv.posting_date = today() + jv.company = "_Test Company" + jv.set( + "accounts", + [ + { + "account": "Debtors - _TC", + "party_type": "Customer", + "party": "_Test Customer", + "credit_in_account_currency": 10000, + }, + { + "account": "Debtors - _TC", + "party_type": "Customer", + "party": "_Test Customer", + "debit_in_account_currency": 9992.5, + }, + { + "account": "TCS - _TC", + "debit_in_account_currency": 7.5, + }, + ], + ) + jv.insert() + return jv.submit() diff --git a/erpnext/accounts/report/trial_balance/trial_balance.js b/erpnext/accounts/report/trial_balance/trial_balance.js index 2c4c762073..5374ac16d1 100644 --- a/erpnext/accounts/report/trial_balance/trial_balance.js +++ b/erpnext/accounts/report/trial_balance/trial_balance.js @@ -78,8 +78,14 @@ frappe.query_reports["Trial Balance"] = { "options": erpnext.get_presentation_currency_list() }, { - "fieldname": "with_period_closing_entry", - "label": __("Period Closing Entry"), + "fieldname": "with_period_closing_entry_for_opening", + "label": __("With Period Closing Entry For Opening Balances"), + "fieldtype": "Check", + "default": 1 + }, + { + "fieldname": "with_period_closing_entry_for_current_period", + "label": __("Period Closing Entry For Current Period"), "fieldtype": "Check", "default": 1 }, diff --git a/erpnext/accounts/report/trial_balance/trial_balance.py b/erpnext/accounts/report/trial_balance/trial_balance.py index 8b7f0bbc00..2ff0eff662 100644 --- a/erpnext/accounts/report/trial_balance/trial_balance.py +++ b/erpnext/accounts/report/trial_balance/trial_balance.py @@ -116,7 +116,7 @@ def get_data(filters): max_rgt, filters, gl_entries_by_account, - ignore_closing_entries=not flt(filters.with_period_closing_entry), + ignore_closing_entries=not flt(filters.with_period_closing_entry_for_current_period), ignore_opening_entries=True, ) @@ -249,7 +249,7 @@ def get_opening_balance( ): opening_balance = opening_balance.where(closing_balance.posting_date >= filters.year_start_date) - if not flt(filters.with_period_closing_entry): + if not flt(filters.with_period_closing_entry_for_opening): if doctype == "Account Closing Balance": opening_balance = opening_balance.where(closing_balance.is_period_closing_voucher_entry == 0) else: diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index 30700d09b8..64bc39a77b 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -237,7 +237,7 @@ def get_balance_on( ) else: - cond.append("""gle.cost_center = %s """ % (frappe.db.escape(cost_center, percent=False),)) + cond.append("""gle.cost_center = %s """ % (frappe.db.escape(cost_center),)) if account: if not (frappe.flags.ignore_account_permission or ignore_account_permission): @@ -258,7 +258,7 @@ def get_balance_on( if acc.account_currency == frappe.get_cached_value("Company", acc.company, "default_currency"): in_account_currency = False else: - cond.append("""gle.account = %s """ % (frappe.db.escape(account, percent=False),)) + cond.append("""gle.account = %s """ % (frappe.db.escape(account),)) if account_type: accounts = frappe.db.get_all( @@ -278,11 +278,11 @@ def get_balance_on( if party_type and party: cond.append( """gle.party_type = %s and gle.party = %s """ - % (frappe.db.escape(party_type), frappe.db.escape(party, percent=False)) + % (frappe.db.escape(party_type), frappe.db.escape(party)) ) if company: - cond.append("""gle.company = %s """ % (frappe.db.escape(company, percent=False))) + cond.append("""gle.company = %s """ % (frappe.db.escape(company))) if account or (party_type and party) or account_type: precision = get_currency_precision() @@ -348,7 +348,7 @@ def get_count_on(account, fieldname, date): % (acc.lft, acc.rgt) ) else: - cond.append("""gle.account = %s """ % (frappe.db.escape(account, percent=False),)) + cond.append("""gle.account = %s """ % (frappe.db.escape(account),)) entries = frappe.db.sql( """ diff --git a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py index 5e251a5658..c9ed806fe4 100644 --- a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py +++ b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py @@ -126,6 +126,7 @@ class AssetCapitalization(StockController): self.create_target_asset() def on_submit(self): + self.make_bundle_using_old_serial_batch_fields() self.update_stock_ledger() self.make_gl_entries() self.update_target_asset() diff --git a/erpnext/assets/doctype/asset_capitalization_stock_item/asset_capitalization_stock_item.json b/erpnext/assets/doctype/asset_capitalization_stock_item/asset_capitalization_stock_item.json index 26e1c3c270..8eda441781 100644 --- a/erpnext/assets/doctype/asset_capitalization_stock_item/asset_capitalization_stock_item.json +++ b/erpnext/assets/doctype/asset_capitalization_stock_item/asset_capitalization_stock_item.json @@ -18,9 +18,12 @@ "amount", "batch_and_serial_no_section", "serial_and_batch_bundle", + "use_serial_batch_fields", "column_break_13", - "batch_no", + "section_break_bfqc", "serial_no", + "column_break_mbuv", + "batch_no", "accounting_dimensions_section", "cost_center", "dimension_col_break" @@ -39,13 +42,13 @@ "reqd": 1 }, { + "depends_on": "eval:doc.use_serial_batch_fields === 1", "fieldname": "batch_no", "fieldtype": "Link", "label": "Batch No", "no_copy": 1, "options": "Batch", - "print_hide": 1, - "read_only": 1 + "print_hide": 1 }, { "fieldname": "section_break_6", @@ -102,12 +105,12 @@ "fieldtype": "Column Break" }, { + "depends_on": "eval:doc.use_serial_batch_fields === 1", "fieldname": "serial_no", "fieldtype": "Small Text", "hidden": 1, "label": "Serial No", - "print_hide": 1, - "read_only": 1 + "print_hide": 1 }, { "fieldname": "item_code", @@ -148,18 +151,34 @@ "fieldtype": "Column Break" }, { + "depends_on": "eval:doc.use_serial_batch_fields === 0 || doc.docstatus === 1", "fieldname": "serial_and_batch_bundle", "fieldtype": "Link", "label": "Serial and Batch Bundle", "no_copy": 1, "options": "Serial and Batch Bundle", "print_hide": 1 + }, + { + "default": "0", + "fieldname": "use_serial_batch_fields", + "fieldtype": "Check", + "label": "Use Serial No / Batch Fields" + }, + { + "depends_on": "eval:doc.use_serial_batch_fields === 1", + "fieldname": "section_break_bfqc", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_mbuv", + "fieldtype": "Column Break" } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2023-04-06 01:10:17.947952", + "modified": "2024-02-04 16:41:09.239762", "modified_by": "Administrator", "module": "Assets", "name": "Asset Capitalization Stock Item", diff --git a/erpnext/assets/doctype/asset_capitalization_stock_item/asset_capitalization_stock_item.py b/erpnext/assets/doctype/asset_capitalization_stock_item/asset_capitalization_stock_item.py index 122cbb600d..d2b075c3e6 100644 --- a/erpnext/assets/doctype/asset_capitalization_stock_item/asset_capitalization_stock_item.py +++ b/erpnext/assets/doctype/asset_capitalization_stock_item/asset_capitalization_stock_item.py @@ -27,6 +27,7 @@ class AssetCapitalizationStockItem(Document): serial_no: DF.SmallText | None stock_qty: DF.Float stock_uom: DF.Link + use_serial_batch_fields: DF.Check valuation_rate: DF.Currency warehouse: DF.Link # end: auto-generated types diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index 4efbb270e9..4d948689f6 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -457,6 +457,7 @@ class PurchaseOrder(BuyingController): self.update_ordered_qty() self.update_reserved_qty_for_subcontract() self.update_subcontracting_order_status() + self.update_blanket_order() self.notify_update() clear_doctype_notifications(self) @@ -644,6 +645,7 @@ class PurchaseOrder(BuyingController): update_sco_status(sco, "Closed" if self.status == "Closed" else None) +@frappe.request_cache def item_last_purchase_rate(name, conversion_rate, item_code, conversion_factor=1.0): """get last purchase rate for an item""" diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index 5405799b4e..a30de68a00 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -822,6 +822,30 @@ class TestPurchaseOrder(FrappeTestCase): # To test if the PO does NOT have a Blanket Order self.assertEqual(po_doc.items[0].blanket_order, None) + def test_blanket_order_on_po_close_and_open(self): + # Step - 1: Create Blanket Order + bo = make_blanket_order(blanket_order_type="Purchasing", quantity=10, rate=10) + + # Step - 2: Create Purchase Order + po = create_purchase_order( + item_code="_Test Item", qty=5, against_blanket_order=1, against_blanket=bo.name + ) + + bo.load_from_db() + self.assertEqual(bo.items[0].ordered_qty, 5) + + # Step - 3: Close Purchase Order + po.update_status("Closed") + + bo.load_from_db() + self.assertEqual(bo.items[0].ordered_qty, 0) + + # Step - 4: Re-Open Purchase Order + po.update_status("Re-open") + + bo.load_from_db() + self.assertEqual(bo.items[0].ordered_qty, 5) + def test_payment_terms_are_fetched_when_creating_purchase_invoice(self): from erpnext.accounts.doctype.payment_entry.test_payment_entry import ( create_payment_terms_template, @@ -1148,6 +1172,7 @@ def create_purchase_order(**args): "schedule_date": add_days(nowdate(), 1), "include_exploded_items": args.get("include_exploded_items", 1), "against_blanket_order": args.against_blanket_order, + "against_blanket": args.against_blanket, "material_request": args.material_request, "material_request_item": args.material_request_item, }, diff --git a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json index 5a24cc2e92..e3e8def7ff 100644 --- a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json +++ b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json @@ -545,7 +545,6 @@ "fieldname": "blanket_order", "fieldtype": "Link", "label": "Blanket Order", - "no_copy": 1, "options": "Blanket Order" }, { @@ -553,7 +552,6 @@ "fieldname": "blanket_order_rate", "fieldtype": "Currency", "label": "Blanket Order Rate", - "no_copy": 1, "print_hide": 1, "read_only": 1 }, @@ -917,7 +915,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2023-11-24 13:24:41.298416", + "modified": "2024-02-05 11:23:24.859435", "modified_by": "Administrator", "module": "Buying", "name": "Purchase Order Item", diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 678b293c5f..f11db1a672 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -693,7 +693,7 @@ class AccountsController(TransactionBase): if self.get("is_subcontracted"): args["is_subcontracted"] = self.is_subcontracted - ret = get_item_details(args, self, for_validate=True, overwrite_warehouse=False) + ret = get_item_details(args, self, for_validate=for_validate, overwrite_warehouse=False) for fieldname, value in ret.items(): if item.meta.get_field(fieldname) and value is not None: @@ -1476,6 +1476,24 @@ class AccountsController(TransactionBase): x.update({dim.fieldname: self.get(dim.fieldname)}) reconcile_against_document(lst, active_dimensions=active_dimensions) + def cancel_system_generated_credit_debit_notes(self): + # Cancel 'Credit/Debit' Note Journal Entries, if found. + if self.doctype in ["Sales Invoice", "Purchase Invoice"]: + voucher_type = "Credit Note" if self.doctype == "Sales Invoice" else "Debit Note" + journals = frappe.db.get_all( + "Journal Entry", + filters={ + "is_system_generated": 1, + "reference_type": self.doctype, + "reference_name": self.name, + "voucher_type": voucher_type, + "docstatus": 1, + }, + pluck="name", + ) + for x in journals: + frappe.get_doc("Journal Entry", x).cancel() + def on_cancel(self): from erpnext.accounts.doctype.bank_transaction.bank_transaction import ( remove_from_bank_transaction, @@ -1488,6 +1506,8 @@ class AccountsController(TransactionBase): remove_from_bank_transaction(self.doctype, self.name) if self.doctype in ["Sales Invoice", "Purchase Invoice", "Payment Entry", "Journal Entry"]: + self.cancel_system_generated_credit_debit_notes() + # Cancel Exchange Gain/Loss Journal before unlinking cancel_exchange_gain_loss_journal(self) diff --git a/erpnext/controllers/queries.py b/erpnext/controllers/queries.py index e234eec1a6..c46ef50f58 100644 --- a/erpnext/controllers/queries.py +++ b/erpnext/controllers/queries.py @@ -729,17 +729,24 @@ def warehouse_query(doctype, txt, searchfield, start, page_len, filters): conditions, bin_conditions = [], [] filter_dict = get_doctype_wise_filters(filters) - query = """select `tabWarehouse`.name, + warehouse_field = "name" + meta = frappe.get_meta("Warehouse") + if meta.get("show_title_field_in_link") and meta.get("title_field"): + searchfield = meta.get("title_field") + warehouse_field = meta.get("title_field") + + query = """select `tabWarehouse`.`{warehouse_field}`, CONCAT_WS(' : ', 'Actual Qty', ifnull(round(`tabBin`.actual_qty, 2), 0 )) actual_qty from `tabWarehouse` left join `tabBin` on `tabBin`.warehouse = `tabWarehouse`.name {bin_conditions} where `tabWarehouse`.`{key}` like {txt} {fcond} {mcond} - order by ifnull(`tabBin`.actual_qty, 0) desc + order by ifnull(`tabBin`.actual_qty, 0) desc, `tabWarehouse`.`{warehouse_field}` asc limit {page_len} offset {start} """.format( + warehouse_field=warehouse_field, bin_conditions=get_filters_cond( doctype, filter_dict.get("Bin"), bin_conditions, ignore_permissions=True ), diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py index 8c438420ad..dc49023149 100644 --- a/erpnext/controllers/selling_controller.py +++ b/erpnext/controllers/selling_controller.py @@ -599,7 +599,7 @@ class SellingController(StockController): if self.doctype in ["Sales Order", "Quotation"]: for item in self.items: item.gross_profit = flt( - ((item.base_rate - item.valuation_rate) * item.stock_qty), self.precision("amount", item) + ((item.base_rate - flt(item.valuation_rate)) * item.stock_qty), self.precision("amount", item) ) def set_customer_address(self): diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 11e9f9f441..74c835c745 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -21,6 +21,9 @@ from erpnext.stock import get_warehouse_account_map from erpnext.stock.doctype.inventory_dimension.inventory_dimension import ( get_evaluated_inventory_dimension, ) +from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( + get_type_of_transaction, +) from erpnext.stock.stock_ledger import get_items_to_be_repost @@ -126,6 +129,81 @@ class StockController(AccountsController): # remove extra whitespace and store one serial no on each line row.serial_no = clean_serial_no_string(row.serial_no) + def make_bundle_using_old_serial_batch_fields(self): + from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos + from erpnext.stock.serial_batch_bundle import SerialBatchCreation + + # To handle test cases + if frappe.flags.in_test and frappe.flags.use_serial_and_batch_fields: + return + + table_name = "items" + if self.doctype == "Asset Capitalization": + table_name = "stock_items" + + for row in self.get(table_name): + if not row.serial_no and not row.batch_no and not row.get("rejected_serial_no"): + continue + + if not row.use_serial_batch_fields and ( + row.serial_no or row.batch_no or row.get("rejected_serial_no") + ): + frappe.throw(_("Please enable Use Old Serial / Batch Fields to make_bundle")) + + if row.use_serial_batch_fields and ( + not row.serial_and_batch_bundle and not row.get("rejected_serial_and_batch_bundle") + ): + if self.doctype == "Stock Reconciliation": + qty = row.qty + type_of_transaction = "Inward" + else: + qty = row.stock_qty + type_of_transaction = get_type_of_transaction(self, row) + + sn_doc = SerialBatchCreation( + { + "item_code": row.item_code, + "warehouse": row.warehouse, + "posting_date": self.posting_date, + "posting_time": self.posting_time, + "voucher_type": self.doctype, + "voucher_no": self.name, + "voucher_detail_no": row.name, + "qty": qty, + "type_of_transaction": type_of_transaction, + "company": self.company, + "is_rejected": 1 if row.get("rejected_warehouse") else 0, + "serial_nos": get_serial_nos(row.serial_no) if row.serial_no else None, + "batches": frappe._dict({row.batch_no: qty}) if row.batch_no else None, + "batch_no": row.batch_no, + "use_serial_batch_fields": row.use_serial_batch_fields, + "do_not_submit": True, + } + ).make_serial_and_batch_bundle() + + if sn_doc.is_rejected: + row.rejected_serial_and_batch_bundle = sn_doc.name + row.db_set( + { + "rejected_serial_and_batch_bundle": sn_doc.name, + "rejected_serial_no": "", + } + ) + else: + row.serial_and_batch_bundle = sn_doc.name + row.db_set( + { + "serial_and_batch_bundle": sn_doc.name, + "serial_no": "", + "batch_no": "", + } + ) + + def set_use_serial_batch_fields(self): + if frappe.db.get_single_value("Stock Settings", "use_serial_batch_fields"): + for row in self.items: + row.use_serial_batch_fields = 1 + def get_gl_entries( self, warehouse_account=None, default_expense_account=None, default_cost_center=None ): diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index 8cb1a0e861..3d7a94778b 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -98,6 +98,7 @@ class calculate_taxes_and_totals(object): item_doc = frappe.get_cached_doc("Item", item.item_code) args = { "net_rate": item.net_rate or item.rate, + "base_net_rate": item.base_net_rate or item.base_rate, "tax_category": self.doc.get("tax_category"), "posting_date": self.doc.get("posting_date"), "bill_date": self.doc.get("bill_date"), diff --git a/erpnext/controllers/tests/test_accounts_controller.py b/erpnext/controllers/tests/test_accounts_controller.py index fad216d5a4..d2a3574f28 100644 --- a/erpnext/controllers/tests/test_accounts_controller.py +++ b/erpnext/controllers/tests/test_accounts_controller.py @@ -1108,18 +1108,18 @@ class TestAccountsController(FrappeTestCase): cr_note.reload() cr_note.cancel() - # Exchange Gain/Loss Journal should've been created. + # with the introduction of 'cancel_system_generated_credit_debit_notes' in accounts controller + # JE(Credit Note) will be cancelled once the parent is cancelled exc_je_for_si = self.get_journals_for(si.doctype, si.name) exc_je_for_cr = self.get_journals_for(cr_note.doctype, cr_note.name) - self.assertNotEqual(exc_je_for_si, []) - self.assertEqual(len(exc_je_for_si), 1) + self.assertEqual(exc_je_for_si, []) + self.assertEqual(len(exc_je_for_si), 0) self.assertEqual(len(exc_je_for_cr), 0) - # The Credit Note JE is still active and is referencing the sales invoice - # So, outstanding stays the same + # No references, full outstanding si.reload() - self.assertEqual(si.outstanding_amount, 1) - self.assert_ledger_outstanding(si.doctype, si.name, 80.0, 1.0) + self.assertEqual(si.outstanding_amount, 2) + self.assert_ledger_outstanding(si.doctype, si.name, 160.0, 2.0) def test_40_cost_center_from_payment_entry(self): """ diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 14b7656491..308e6ca011 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -42,7 +42,6 @@ setup_wizard_test = "erpnext.setup.setup_wizard.test_setup_wizard.run_setup_wiza before_install = [ "erpnext.setup.install.check_setup_wizard_not_completed", - "erpnext.setup.install.check_frappe_version", ] after_install = "erpnext.setup.install.after_install" diff --git a/erpnext/manufacturing/doctype/blanket_order/blanket_order.py b/erpnext/manufacturing/doctype/blanket_order/blanket_order.py index 6a72c4fdaf..dcf122c4c3 100644 --- a/erpnext/manufacturing/doctype/blanket_order/blanket_order.py +++ b/erpnext/manufacturing/doctype/blanket_order/blanket_order.py @@ -90,6 +90,7 @@ def make_order(source_name): def update_item(source, target, source_parent): target_qty = source.get("qty") - source.get("ordered_qty") target.qty = target_qty if flt(target_qty) >= 0 else 0 + target.rate = source.get("rate") item = get_item_defaults(target.item_code, source_parent.company) if item: target.item_name = item.get("item_name") @@ -111,6 +112,10 @@ def make_order(source_name): }, }, ) + + if target_doc.doctype == "Purchase Order": + target_doc.set_missing_values() + return target_doc diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index 5dc5c38376..6e9d1fcfd8 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -312,9 +312,10 @@ class ProductionPlan(Document): so_item.parent, so_item.item_code, so_item.warehouse, - ( - (so_item.qty - so_item.work_order_qty - so_item.delivered_qty) * so_item.conversion_factor - ).as_("pending_qty"), + so_item.qty, + so_item.work_order_qty, + so_item.delivered_qty, + so_item.conversion_factor, so_item.description, so_item.name, so_item.bom_no, @@ -337,6 +338,11 @@ class ProductionPlan(Document): items = items_query.run(as_dict=True) + for item in items: + item.pending_qty = ( + flt(item.qty) - max(item.work_order_qty, item.delivered_qty, 0) * item.conversion_factor + ) + pi = frappe.qb.DocType("Packed Item") packed_items_query = ( @@ -1334,10 +1340,10 @@ def get_sales_orders(self): ) date_field_mapper = { - "from_date": self.from_date >= so.transaction_date, - "to_date": self.to_date <= so.transaction_date, - "from_delivery_date": self.from_delivery_date >= so_item.delivery_date, - "to_delivery_date": self.to_delivery_date <= so_item.delivery_date, + "from_date": so.transaction_date >= self.from_date, + "to_date": so.transaction_date <= self.to_date, + "from_delivery_date": so_item.delivery_date >= self.from_delivery_date, + "to_delivery_date": so_item.delivery_date <= self.to_delivery_date, } for field, value in date_field_mapper.items(): diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index aa7bc5bf76..39beb361de 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -1511,14 +1511,14 @@ def get_serial_nos_for_work_order(work_order, production_item): def validate_operation_data(row): - if row.get("qty") <= 0: + if flt(row.get("qty")) <= 0: frappe.throw( _("Quantity to Manufacture can not be zero for the operation {0}").format( frappe.bold(row.get("operation")) ) ) - if row.get("qty") > row.get("pending_qty"): + if flt(row.get("qty")) > flt(row.get("pending_qty")): frappe.throw( _("For operation {0}: Quantity ({1}) can not be greater than pending quantity({2})").format( frappe.bold(row.get("operation")), diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index 4ea834bcd6..ba53cf86f4 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -7,6 +7,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe super.setup(); let me = this; + this.set_fields_onload_for_line_item(); this.frm.ignore_doctypes_on_cancel_all = ['Serial and Batch Bundle']; frappe.flags.hide_serial_batch_dialog = true; @@ -105,6 +106,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe frappe.ui.form.on(this.frm.doctype + " Item", { items_add: function(frm, cdt, cdn) { + debugger var item = frappe.get_doc(cdt, cdn); if (!item.warehouse && frm.doc.set_warehouse) { item.warehouse = frm.doc.set_warehouse; @@ -118,6 +120,13 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe item.from_warehouse = frm.doc.set_from_warehouse; } + if (item.docstatus === 0 + && frappe.meta.has_field(item.doctype, "use_serial_batch_fields") + && cint(frappe.user_defaults?.use_serial_batch_fields) === 1 + ) { + frappe.model.set_value(item.doctype, item.name, "use_serial_batch_fields", 1); + } + erpnext.accounts.dimensions.copy_dimension_from_first_row(frm, cdt, cdn, 'items'); } }); @@ -222,7 +231,19 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe }; }); } + } + set_fields_onload_for_line_item() { + if (this.frm.is_new && this.frm.doc?.items) { + this.frm.doc.items.forEach(item => { + if (item.docstatus === 0 + && frappe.meta.has_field(item.doctype, "use_serial_batch_fields") + && cint(frappe.user_defaults?.use_serial_batch_fields) === 1 + ) { + frappe.model.set_value(item.doctype, item.name, "use_serial_batch_fields", 1); + } + }) + } } toggle_enable_for_stock_uom(field) { @@ -462,6 +483,11 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe this.frm.doc.doctype === 'Delivery Note') { show_batch_dialog = 1; } + + if (show_batch_dialog && item.use_serial_batch_fields === 1) { + show_batch_dialog = 0; + } + item.barcode = null; @@ -502,6 +528,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe project: item.project || me.frm.doc.project, qty: item.qty || 1, net_rate: item.rate, + base_net_rate: item.base_net_rate, stock_qty: item.stock_qty, conversion_factor: item.conversion_factor, weight_per_unit: item.weight_per_unit, @@ -705,10 +732,10 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe item.serial_no = item.serial_no.replace(/,/g, '\n'); item.conversion_factor = item.conversion_factor || 1; refresh_field("serial_no", item.name, item.parentfield); - if (!doc.is_return && cint(frappe.user_defaults.set_qty_in_transactions_based_on_serial_no_input)) { + if (!doc.is_return) { setTimeout(() => { me.update_qty(cdt, cdn); - }, 10000); + }, 3000); } } } @@ -1241,20 +1268,6 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe } } - sync_bundle_data() { - let doctypes = ["Sales Invoice", "Purchase Invoice", "Delivery Note", "Purchase Receipt"]; - - if (this.frm.is_new() && doctypes.includes(this.frm.doc.doctype)) { - const barcode_scanner = new erpnext.utils.BarcodeScanner({frm:this.frm}); - barcode_scanner.sync_bundle_data(); - barcode_scanner.remove_item_from_localstorage(); - } - } - - before_save(doc) { - this.sync_bundle_data(); - } - service_start_date(frm, cdt, cdn) { var child = locals[cdt][cdn]; @@ -1902,7 +1915,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe if (item.item_code) { // Use combination of name and item code in case same item is added multiple times item_codes.push([item.item_code, item.name]); - item_rates[item.name] = item.net_rate; + item_rates[item.name] = item.base_net_rate; item_tax_templates[item.name] = item.item_tax_template; } }); diff --git a/erpnext/public/js/utils/barcode_scanner.js b/erpnext/public/js/utils/barcode_scanner.js index aacab0fe6c..4d1c0c1ad3 100644 --- a/erpnext/public/js/utils/barcode_scanner.js +++ b/erpnext/public/js/utils/barcode_scanner.js @@ -1,12 +1,15 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner { constructor(opts) { this.frm = opts.frm; + // frappe.flags.trigger_from_barcode_scanner is used for custom scripts // field from which to capture input of scanned data this.scan_field_name = opts.scan_field_name || "scan_barcode"; this.scan_barcode_field = this.frm.fields_dict[this.scan_field_name]; this.barcode_field = opts.barcode_field || "barcode"; + this.serial_no_field = opts.serial_no_field || "serial_no"; + this.batch_no_field = opts.batch_no_field || "batch_no"; this.uom_field = opts.uom_field || "uom"; this.qty_field = opts.qty_field || "qty"; // field name on row which defines max quantity to be scanned e.g. picklist @@ -105,53 +108,52 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner { this.frm.has_items = false; } - if (serial_no) { - this.is_duplicate_serial_no(row, item_code, serial_no) - .then((is_duplicate) => { - if (!is_duplicate) { - this.run_serially_tasks(row, data, resolve); - } else { - this.clean_up(); - reject(); - return; - } - }); - } else { - this.run_serially_tasks(row, data, resolve); + if (this.is_duplicate_serial_no(row, serial_no)) { + this.clean_up(); + reject(); + return; } - + frappe.run_serially([ + () => this.set_selector_trigger_flag(data), + () => this.set_item(row, item_code, barcode, batch_no, serial_no).then(qty => { + this.show_scan_message(row.idx, row.item_code, qty); + }), + () => this.set_barcode_uom(row, uom), + () => this.set_serial_no(row, serial_no), + () => this.set_batch_no(row, batch_no), + () => this.set_barcode(row, barcode), + () => this.clean_up(), + () => this.revert_selector_flag(), + () => resolve(row) + ]); }); } - run_serially_tasks(row, data, resolve) { - const {item_code, barcode, batch_no, serial_no, uom} = data; + // batch and serial selector is reduandant when all info can be added by scan + // this flag on item row is used by transaction.js to avoid triggering selector + set_selector_trigger_flag(data) { + const {batch_no, serial_no, has_batch_no, has_serial_no} = data; - frappe.run_serially([ - () => this.set_serial_and_batch(row, item_code, serial_no, batch_no), - () => this.set_barcode(row, barcode), - () => this.set_item(row, item_code, barcode, batch_no, serial_no).then(qty => { - this.show_scan_message(row.idx, row.item_code, qty); - }), - () => this.set_barcode_uom(row, uom), - () => this.clean_up(), - () => { - if (row.serial_and_batch_bundle && !this.frm.is_new()) { - this.frm.save(); - } + const require_selecting_batch = has_batch_no && !batch_no; + const require_selecting_serial = has_serial_no && !serial_no; - frappe.flags.trigger_from_barcode_scanner = false; - }, - () => resolve(row), - ]); + if (!(require_selecting_batch || require_selecting_serial)) { + frappe.flags.hide_serial_batch_dialog = true; + } + } + + revert_selector_flag() { + frappe.flags.hide_serial_batch_dialog = false; + frappe.flags.trigger_from_barcode_scanner = false; } set_item(row, item_code, barcode, batch_no, serial_no) { return new Promise(resolve => { const increment = async (value = 1) => { - const item_data = {item_code: item_code}; - item_data[this.qty_field] = Number((row[this.qty_field] || 0)) + Number(value); + const item_data = {item_code: item_code, use_serial_batch_fields: 1.0}; frappe.flags.trigger_from_barcode_scanner = true; + item_data[this.qty_field] = Number((row[this.qty_field] || 0)) + Number(value); await frappe.model.set_value(row.doctype, row.name, item_data); return value; }; @@ -160,6 +162,8 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner { frappe.prompt(__("Please enter quantity for item {0}", [item_code]), ({value}) => { increment(value).then((value) => resolve(value)); }); + } else if (this.frm.has_items) { + this.prepare_item_for_scan(row, item_code, barcode, batch_no, serial_no); } else { increment().then((value) => resolve(value)); } @@ -182,8 +186,9 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner { frappe.model.set_value(row.doctype, row.name, item_data); frappe.run_serially([ + () => this.set_batch_no(row, this.dialog.get_value("batch_no")), () => this.set_barcode(row, this.dialog.get_value("barcode")), - () => this.set_serial_and_batch(row, item_code, this.dialog.get_value("serial_no"), this.dialog.get_value("batch_no")), + () => this.set_serial_no(row, this.dialog.get_value("serial_no")), () => this.add_child_for_remaining_qty(row), () => this.clean_up() ]); @@ -337,144 +342,32 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner { } } - async set_serial_and_batch(row, item_code, serial_no, batch_no) { - if (this.frm.is_new() || !row.serial_and_batch_bundle) { - this.set_bundle_in_localstorage(row, item_code, serial_no, batch_no); - } else if(row.serial_and_batch_bundle) { - frappe.call({ - method: "erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle.update_serial_or_batch", - args: { - bundle_id: row.serial_and_batch_bundle, - serial_no: serial_no, - batch_no: batch_no, - }, - }) - } - } + async set_serial_no(row, serial_no) { + if (serial_no && frappe.meta.has_field(row.doctype, this.serial_no_field)) { + const existing_serial_nos = row[this.serial_no_field]; + let new_serial_nos = ""; - get_key_for_localstorage() { - let parts = this.frm.doc.name.split("-"); - return parts[parts.length - 1] + this.frm.doc.doctype; - } - - update_localstorage_scanned_data() { - let docname = this.frm.doc.name - if (localStorage[docname]) { - let items = JSON.parse(localStorage[docname]); - let existing_items = this.frm.doc.items.map(d => d.item_code); - if (!existing_items.length) { - localStorage.removeItem(docname); - return; + if (!!existing_serial_nos) { + new_serial_nos = existing_serial_nos + "\n" + serial_no; + } else { + new_serial_nos = serial_no; } - - for (let item_code in items) { - if (!existing_items.includes(item_code)) { - delete items[item_code]; - } - } - - localStorage[docname] = JSON.stringify(items); + await frappe.model.set_value(row.doctype, row.name, this.serial_no_field, new_serial_nos); } } - async set_bundle_in_localstorage(row, item_code, serial_no, batch_no) { - let docname = this.frm.doc.name - - let entries = JSON.parse(localStorage.getItem(docname)); - if (!entries) { - entries = {}; - } - - let key = item_code; - if (!entries[key]) { - entries[key] = []; - } - - let existing_row = []; - if (!serial_no && batch_no) { - existing_row = entries[key].filter((e) => e.batch_no === batch_no); - if (existing_row.length) { - existing_row[0].qty += 1; - } - } else if (serial_no) { - existing_row = entries[key].filter((e) => e.serial_no === serial_no); - if (existing_row.length) { - frappe.throw(__("Serial No {0} has already scanned.", [serial_no])); - } - } - - if (!existing_row.length) { - entries[key].push({ - "serial_no": serial_no, - "batch_no": batch_no, - "qty": 1 - }); - } - - localStorage.setItem(docname, JSON.stringify(entries)); - - // Auto remove from localstorage after 1 hour - setTimeout(() => { - localStorage.removeItem(docname); - }, 3600000) - } - - remove_item_from_localstorage() { - let docname = this.frm.doc.name; - if (localStorage[docname]) { - localStorage.removeItem(docname); - } - } - - async sync_bundle_data() { - let docname = this.frm.doc.name; - - if (localStorage[docname]) { - let entries = JSON.parse(localStorage[docname]); - if (entries) { - for (let entry in entries) { - let row = this.frm.doc.items.filter((item) => { - if (item.item_code === entry) { - return true; - } - })[0]; - - if (row) { - this.create_serial_and_batch_bundle(row, entries, entry) - .then(() => { - if (!entries) { - localStorage.removeItem(docname); - } - }); - } - } - } - } - } - - async create_serial_and_batch_bundle(row, entries, key) { - frappe.call({ - method: "erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle.add_serial_batch_ledgers", - args: { - entries: entries[key], - child_row: row, - doc: this.frm.doc, - warehouse: row.warehouse, - do_not_save: 1 - }, - callback: function(r) { - row.serial_and_batch_bundle = r.message.name; - delete entries[key]; - } - }) - } - async set_barcode_uom(row, uom) { if (uom && frappe.meta.has_field(row.doctype, this.uom_field)) { await frappe.model.set_value(row.doctype, row.name, this.uom_field, uom); } } + async set_batch_no(row, batch_no) { + if (batch_no && frappe.meta.has_field(row.doctype, this.batch_no_field)) { + await frappe.model.set_value(row.doctype, row.name, this.batch_no_field, batch_no); + } + } + async set_barcode(row, barcode) { if (barcode && frappe.meta.has_field(row.doctype, this.barcode_field)) { await frappe.model.set_value(row.doctype, row.name, this.barcode_field, barcode); @@ -490,58 +383,13 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner { } } - async is_duplicate_serial_no(row, item_code, serial_no) { - let is_duplicate = false; - const promise = new Promise((resolve, reject) => { - if (this.frm.is_new() || !row.serial_and_batch_bundle) { - is_duplicate = this.check_duplicate_serial_no_in_localstorage(item_code, serial_no); - if (is_duplicate) { - this.show_alert(__("Serial No {0} is already added", [serial_no]), "orange"); - } + is_duplicate_serial_no(row, serial_no) { + const is_duplicate = row[this.serial_no_field]?.includes(serial_no); - resolve(is_duplicate); - } else if (row.serial_and_batch_bundle) { - this.check_duplicate_serial_no_in_db(row, serial_no, (r) => { - if (r.message) { - this.show_alert(__("Serial No {0} is already added", [serial_no]), "orange"); - } - - is_duplicate = r.message; - resolve(is_duplicate); - }) - } - }); - - return await promise; - } - - check_duplicate_serial_no_in_db(row, serial_no, response) { - frappe.call({ - method: "erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle.is_duplicate_serial_no", - args: { - serial_no: serial_no, - bundle_id: row.serial_and_batch_bundle - }, - callback(r) { - response(r); - } - }); - } - - check_duplicate_serial_no_in_localstorage(item_code, serial_no) { - let docname = this.frm.doc.name - let entries = JSON.parse(localStorage.getItem(docname)); - - if (!entries) { - return false; + if (is_duplicate) { + this.show_alert(__("Serial No {0} is already added", [serial_no]), "orange"); } - - let existing_row = []; - if (entries[item_code]) { - existing_row = entries[item_code].filter((e) => e.serial_no === serial_no); - } - - return existing_row.length; + return is_duplicate; } get_row_to_modify_on_scan(item_code, batch_no, uom, barcode) { @@ -587,4 +435,4 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner { show_alert(msg, indicator, duration=3) { frappe.show_alert({message: msg, indicator: indicator}, duration); } -}; +}; \ No newline at end of file diff --git a/erpnext/selling/doctype/customer/customer.py b/erpnext/selling/doctype/customer/customer.py index 2f6775f0cf..3744922a1a 100644 --- a/erpnext/selling/doctype/customer/customer.py +++ b/erpnext/selling/doctype/customer/customer.py @@ -230,6 +230,7 @@ class Customer(TransactionBase): if self.flags.is_new_doc: self.link_lead_address_and_contact() + self.copy_communication() self.update_customer_groups() @@ -291,6 +292,17 @@ class Customer(TransactionBase): linked_doc.append("links", dict(link_doctype="Customer", link_name=self.name)) linked_doc.save(ignore_permissions=self.flags.ignore_permissions) + def copy_communication(self): + if not self.lead_name or not frappe.db.get_single_value( + "CRM Settings", "carry_forward_communication_and_comments" + ): + return + + from erpnext.crm.utils import copy_comments, link_communications + + copy_comments("Lead", self.lead_name, self) + link_communications("Lead", self.lead_name, self) + def validate_name_with_customer_group(self): if frappe.db.exists("Customer Group", self.name): frappe.throw( @@ -560,15 +572,14 @@ def check_credit_limit(customer, company, ignore_outstanding_sales_order=False, @frappe.whitelist() -def send_emails(args): - args = json.loads(args) - subject = _("Credit limit reached for customer {0}").format(args.get("customer")) +def send_emails(customer, customer_outstanding, credit_limit, credit_controller_users_list): + if isinstance(credit_controller_users_list, str): + credit_controller_users_list = json.loads(credit_controller_users_list) + subject = _("Credit limit reached for customer {0}").format(customer) message = _("Credit limit has been crossed for customer {0} ({1}/{2})").format( - args.get("customer"), args.get("customer_outstanding"), args.get("credit_limit") - ) - frappe.sendmail( - recipients=args.get("credit_controller_users_list"), subject=subject, message=message + customer, customer_outstanding, credit_limit ) + frappe.sendmail(recipients=credit_controller_users_list, subject=subject, message=message) def get_customer_outstanding( diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 79f24d1160..9661bac8ad 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -906,6 +906,7 @@ def make_delivery_note(source_name, target_doc=None, kwargs=None): target.run_method("set_missing_values") target.run_method("set_po_nos") target.run_method("calculate_taxes_and_totals") + target.run_method("set_use_serial_batch_fields") if source.company_address: target.update({"company_address": source.company_address}) @@ -1026,6 +1027,7 @@ def make_sales_invoice(source_name, target_doc=None, ignore_permissions=False): target.run_method("set_missing_values") target.run_method("set_po_nos") target.run_method("calculate_taxes_and_totals") + target.run_method("set_use_serial_batch_fields") if source.company_address: target.update({"company_address": source.company_address}) @@ -1608,7 +1610,11 @@ def create_pick_list(source_name, target_doc=None): "Sales Order", source_name, { - "Sales Order": {"doctype": "Pick List", "validation": {"docstatus": ["=", 1]}}, + "Sales Order": { + "doctype": "Pick List", + "field_map": {"set_warehouse": "parent_warehouse"}, + "validation": {"docstatus": ["=", 1]}, + }, "Sales Order Item": { "doctype": "Pick List Item", "field_map": {"parent": "sales_order", "name": "sales_order_item"}, diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 5ae48ee561..b5189b8f06 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -20,6 +20,7 @@ from erpnext.manufacturing.doctype.blanket_order.test_blanket_order import make_ from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle from erpnext.selling.doctype.sales_order.sales_order import ( WarehouseRequired, + create_pick_list, make_delivery_note, make_material_request, make_raw_material_request, @@ -2023,6 +2024,83 @@ class TestSalesOrder(FrappeTestCase): frappe.db.get_value(so.doctype, so.name, "advance_payment_status"), "Not Requested" ) + def test_pick_list_without_rejected_materials(self): + serial_and_batch_item = make_item( + "_Test Serial and Batch Item for Rejected Materials", + properties={ + "has_serial_no": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "BAT-TSBIFRM-.#####", + "serial_no_series": "SN-TSBIFRM-.#####", + }, + ).name + + serial_item = make_item( + "_Test Serial Item for Rejected Materials", + properties={ + "has_serial_no": 1, + "serial_no_series": "SN-TSIFRM-.#####", + }, + ).name + + batch_item = make_item( + "_Test Batch Item for Rejected Materials", + properties={ + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "BAT-TBIFRM-.#####", + }, + ).name + + normal_item = make_item("_Test Normal Item for Rejected Materials").name + + warehouse = "_Test Warehouse - _TC" + rejected_warehouse = "_Test Dummy Rejected Warehouse - _TC" + + if not frappe.db.exists("Warehouse", rejected_warehouse): + frappe.get_doc( + { + "doctype": "Warehouse", + "warehouse_name": rejected_warehouse, + "company": "_Test Company", + "warehouse_group": "_Test Warehouse Group", + "is_rejected_warehouse": 1, + } + ).insert() + + se = make_stock_entry(item_code=normal_item, qty=1, to_warehouse=warehouse, do_not_submit=True) + for item in [serial_and_batch_item, serial_item, batch_item]: + se.append("items", {"item_code": item, "qty": 1, "t_warehouse": warehouse}) + + se.save() + se.submit() + + se = make_stock_entry( + item_code=normal_item, qty=1, to_warehouse=rejected_warehouse, do_not_submit=True + ) + for item in [serial_and_batch_item, serial_item, batch_item]: + se.append("items", {"item_code": item, "qty": 1, "t_warehouse": rejected_warehouse}) + + se.save() + se.submit() + + so = make_sales_order(item_code=normal_item, qty=2, do_not_submit=True) + + for item in [serial_and_batch_item, serial_item, batch_item]: + so.append("items", {"item_code": item, "qty": 2, "warehouse": warehouse}) + + so.save() + so.submit() + + pick_list = create_pick_list(so.name) + + pick_list.save() + for row in pick_list.locations: + self.assertEqual(row.qty, 1.0) + self.assertFalse(row.warehouse == rejected_warehouse) + self.assertTrue(row.warehouse == warehouse) + def automatically_fetch_payment_terms(enable=1): accounts_settings = frappe.get_doc("Accounts Settings") diff --git a/erpnext/setup/install.py b/erpnext/setup/install.py index 6239864c23..527f742841 100644 --- a/erpnext/setup/install.py +++ b/erpnext/setup/install.py @@ -2,14 +2,12 @@ # License: GNU General Public License v3. See license.txt -import click import frappe from frappe import _ from frappe.custom.doctype.custom_field.custom_field import create_custom_fields from frappe.desk.page.setup_wizard.setup_wizard import add_all_roles_to from frappe.utils import cint -import erpnext from erpnext.setup.default_energy_point_rules import get_default_energy_point_rules from erpnext.setup.doctype.incoterm.incoterm import create_incoterms @@ -42,25 +40,6 @@ You can reinstall this site (after saving your data) using: bench --site [sitena frappe.throw(message) # nosemgrep -def check_frappe_version(): - def major_version(v: str) -> str: - return v.split(".")[0] - - frappe_version = major_version(frappe.__version__) - erpnext_version = major_version(erpnext.__version__) - - if frappe_version == erpnext_version: - return - - click.secho( - f"You're attempting to install ERPNext version {erpnext_version} with Frappe version {frappe_version}. " - "This is not supported and will result in broken install. Switch to correct branch before installing.", - fg="red", - ) - - raise SystemExit(1) - - def set_single_defaults(): for dt in ( "Accounts Settings", diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index 58990d4838..4eacbc1541 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -398,6 +398,8 @@ class DeliveryNote(SellingController): self.check_credit_limit() elif self.issue_credit_note: self.make_return_invoice() + + self.make_bundle_using_old_serial_batch_fields() # Updating stock ledger should always be called after updating prevdoc status, # because updating reserved qty in bin depends upon updated delivered qty in SO self.update_stock_ledger() diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py index 0f12f38195..459e7e7c4f 100644 --- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py @@ -200,7 +200,6 @@ class TestDeliveryNote(FrappeTestCase): }, ) - frappe.flags.ignore_serial_batch_bundle_validation = True serial_nos = [ "OSN-1", "OSN-2", @@ -239,6 +238,8 @@ class TestDeliveryNote(FrappeTestCase): ) se_doc.items[0].serial_no = "\n".join(serial_nos) + + frappe.flags.use_serial_and_batch_fields = True se_doc.submit() self.assertEqual(sorted(get_serial_nos(se_doc.items[0].serial_no)), sorted(serial_nos)) @@ -294,6 +295,8 @@ class TestDeliveryNote(FrappeTestCase): self.assertTrue(serial_no in serial_nos) self.assertFalse(serial_no in returned_serial_nos1) + frappe.flags.use_serial_and_batch_fields = False + def test_sales_return_for_non_bundled_items_partial(self): company = frappe.db.get_value("Warehouse", "Stores - TCP1", "company") @@ -1563,7 +1566,7 @@ def create_delivery_note(**args): dn.return_against = args.return_against bundle_id = None - if args.get("batch_no") or args.get("serial_no"): + if not args.use_serial_batch_fields and (args.get("batch_no") or args.get("serial_no")): type_of_transaction = args.type_of_transaction or "Outward" if dn.is_return: @@ -1605,6 +1608,9 @@ def create_delivery_note(**args): "expense_account": args.expense_account or "Cost of Goods Sold - _TC", "cost_center": args.cost_center or "_Test Cost Center - _TC", "target_warehouse": args.target_warehouse, + "use_serial_batch_fields": args.use_serial_batch_fields, + "serial_no": args.serial_no if args.use_serial_batch_fields else None, + "batch_no": args.batch_no if args.use_serial_batch_fields else None, }, ) diff --git a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json index a44b9ac44b..247672fe12 100644 --- a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json +++ b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json @@ -80,8 +80,11 @@ "section_break_40", "pick_serial_and_batch", "serial_and_batch_bundle", + "use_serial_batch_fields", "column_break_eaoe", + "section_break_qyjv", "serial_no", + "column_break_rxvc", "batch_no", "available_qty_section", "actual_batch_qty", @@ -850,6 +853,7 @@ "read_only": 1 }, { + "depends_on": "eval:doc.use_serial_batch_fields === 0 || doc.docstatus === 1", "fieldname": "serial_and_batch_bundle", "fieldtype": "Link", "label": "Serial and Batch Bundle", @@ -859,6 +863,7 @@ "search_index": 1 }, { + "depends_on": "eval:doc.use_serial_batch_fields === 0 || doc.docstatus === 1", "fieldname": "pick_serial_and_batch", "fieldtype": "Button", "label": "Pick Serial / Batch No" @@ -874,27 +879,40 @@ "fieldtype": "Column Break" }, { + "depends_on": "eval:doc.use_serial_batch_fields === 1", "fieldname": "serial_no", "fieldtype": "Text", - "hidden": 1, - "label": "Serial No", - "read_only": 1 + "label": "Serial No" }, { + "depends_on": "eval:doc.use_serial_batch_fields === 1", "fieldname": "batch_no", "fieldtype": "Link", - "hidden": 1, "label": "Batch No", "options": "Batch", - "read_only": 1, "search_index": 1 + }, + { + "default": "0", + "fieldname": "use_serial_batch_fields", + "fieldtype": "Check", + "label": "Use Serial No / Batch Fields" + }, + { + "depends_on": "eval:doc.use_serial_batch_fields === 1", + "fieldname": "section_break_qyjv", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_rxvc", + "fieldtype": "Column Break" } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2023-11-14 18:37:38.638144", + "modified": "2024-02-04 14:10:31.750340", "modified_by": "Administrator", "module": "Stock", "name": "Delivery Note Item", diff --git a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.py b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.py index c11c4103e5..b76f742972 100644 --- a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.py +++ b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.py @@ -82,6 +82,7 @@ class DeliveryNoteItem(Document): target_warehouse: DF.Link | None total_weight: DF.Float uom: DF.Link + use_serial_batch_fields: DF.Check warehouse: DF.Link | None weight_per_unit: DF.Float weight_uom: DF.Link | None diff --git a/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py b/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py index b4f7708e0b..dec75066ec 100644 --- a/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py +++ b/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py @@ -149,6 +149,13 @@ class LandedCostVoucher(Document): self.get("items")[item_count - 1].applicable_charges += diff def validate_applicable_charges_for_item(self): + if self.distribute_charges_based_on == "Distribute Manually" and len(self.taxes) > 1: + frappe.throw( + _( + "Please keep one Applicable Charges, when 'Distribute Charges Based On' is 'Distribute Manually'. For more charges, please create another Landed Cost Voucher." + ) + ) + based_on = self.distribute_charges_based_on.lower() if based_on != "distribute manually": diff --git a/erpnext/stock/doctype/packed_item/packed_item.json b/erpnext/stock/doctype/packed_item/packed_item.json index 5dd8934d43..1daf6791d4 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.json +++ b/erpnext/stock/doctype/packed_item/packed_item.json @@ -20,9 +20,12 @@ "uom", "section_break_9", "pick_serial_and_batch", - "serial_and_batch_bundle", - "serial_no", + "use_serial_batch_fields", "column_break_11", + "serial_and_batch_bundle", + "section_break_bgys", + "serial_no", + "column_break_qlha", "batch_no", "actual_batch_qty", "section_break_13", @@ -118,10 +121,10 @@ "fieldtype": "Section Break" }, { + "depends_on": "eval:doc.use_serial_batch_fields === 1", "fieldname": "serial_no", "fieldtype": "Text", - "label": "Serial No", - "read_only": 1 + "label": "Serial No" }, { "fieldname": "column_break_11", @@ -131,8 +134,7 @@ "fieldname": "batch_no", "fieldtype": "Link", "label": "Batch No", - "options": "Batch", - "read_only": 1 + "options": "Batch" }, { "fieldname": "section_break_13", @@ -259,6 +261,7 @@ "read_only": 1 }, { + "depends_on": "eval:doc.use_serial_batch_fields === 0 || doc.docstatus === 1", "fieldname": "serial_and_batch_bundle", "fieldtype": "Link", "label": "Serial and Batch Bundle", @@ -267,16 +270,32 @@ "print_hide": 1 }, { + "depends_on": "eval:doc.use_serial_batch_fields === 0 || doc.docstatus === 1", "fieldname": "pick_serial_and_batch", "fieldtype": "Button", "label": "Pick Serial / Batch No" + }, + { + "default": "0", + "fieldname": "use_serial_batch_fields", + "fieldtype": "Check", + "label": "Use Serial No / Batch Fields" + }, + { + "depends_on": "eval:doc.use_serial_batch_fields === 1", + "fieldname": "section_break_bgys", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_qlha", + "fieldtype": "Column Break" } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2023-04-28 13:16:38.460806", + "modified": "2024-02-04 16:30:44.263964", "modified_by": "Administrator", "module": "Stock", "name": "Packed Item", diff --git a/erpnext/stock/doctype/packed_item/packed_item.py b/erpnext/stock/doctype/packed_item/packed_item.py index ed667c2b99..c115e33e17 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.py +++ b/erpnext/stock/doctype/packed_item/packed_item.py @@ -47,6 +47,7 @@ class PackedItem(Document): serial_no: DF.Text | None target_warehouse: DF.Link | None uom: DF.Link | None + use_serial_batch_fields: DF.Check warehouse: DF.Link | None # end: auto-generated types diff --git a/erpnext/stock/doctype/pick_list/pick_list.js b/erpnext/stock/doctype/pick_list/pick_list.js index afd6ce8138..aa0e125496 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.js +++ b/erpnext/stock/doctype/pick_list/pick_list.js @@ -16,7 +16,6 @@ frappe.ui.form.on('Pick List', { frm.set_query('parent_warehouse', () => { return { filters: { - 'is_group': 1, 'company': frm.doc.company } }; diff --git a/erpnext/stock/doctype/pick_list/pick_list.json b/erpnext/stock/doctype/pick_list/pick_list.json index 7259dc00a8..0c474342a9 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.json +++ b/erpnext/stock/doctype/pick_list/pick_list.json @@ -16,6 +16,7 @@ "for_qty", "column_break_4", "parent_warehouse", + "consider_rejected_warehouses", "get_item_locations", "section_break_6", "scan_barcode", @@ -51,7 +52,7 @@ "description": "Items under this warehouse will be suggested", "fieldname": "parent_warehouse", "fieldtype": "Link", - "label": "Parent Warehouse", + "label": "Warehouse", "options": "Warehouse" }, { @@ -184,11 +185,18 @@ "report_hide": 1, "reqd": 1, "search_index": 1 + }, + { + "default": "0", + "description": "Enable it if users want to consider rejected materials to dispatch.", + "fieldname": "consider_rejected_warehouses", + "fieldtype": "Check", + "label": "Consider Rejected Warehouses" } ], "is_submittable": 1, "links": [], - "modified": "2023-01-24 10:33:43.244476", + "modified": "2024-02-02 16:17:44.877426", "modified_by": "Administrator", "module": "Stock", "name": "Pick List", @@ -260,4 +268,4 @@ "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index 758448af79..98ed569af1 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -13,7 +13,7 @@ from frappe.model.mapper import map_child_doc from frappe.query_builder import Case from frappe.query_builder.custom import GROUP_CONCAT from frappe.query_builder.functions import Coalesce, Locate, Replace, Sum -from frappe.utils import cint, floor, flt +from frappe.utils import ceil, cint, floor, flt from frappe.utils.nestedset import get_descendants_of from erpnext.selling.doctype.sales_order.sales_order import ( @@ -122,11 +122,42 @@ class PickList(Document): def on_submit(self): self.validate_serial_and_batch_bundle() + self.make_bundle_using_old_serial_batch_fields() self.update_status() self.update_bundle_picked_qty() self.update_reference_qty() self.update_sales_order_picking_status() + def make_bundle_using_old_serial_batch_fields(self): + from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos + + for row in self.locations: + if not row.serial_no and not row.batch_no: + continue + + if not row.use_serial_batch_fields and (row.serial_no or row.batch_no): + frappe.throw(_("Please enable Use Old Serial / Batch Fields to make_bundle")) + + if row.use_serial_batch_fields and (not row.serial_and_batch_bundle): + sn_doc = SerialBatchCreation( + { + "item_code": row.item_code, + "warehouse": row.warehouse, + "voucher_type": self.doctype, + "voucher_no": self.name, + "voucher_detail_no": row.name, + "qty": row.stock_qty, + "type_of_transaction": "Outward", + "company": self.company, + "serial_nos": get_serial_nos(row.serial_no) if row.serial_no else None, + "batches": frappe._dict({row.batch_no: row.stock_qty}) if row.batch_no else None, + "batch_no": row.batch_no, + } + ).make_serial_and_batch_bundle() + + row.serial_and_batch_bundle = sn_doc.name + row.db_set("serial_and_batch_bundle", sn_doc.name) + def on_update_after_submit(self) -> None: if self.has_reserved_stock(): msg = _( @@ -156,6 +187,7 @@ class PickList(Document): {"is_cancelled": 1, "voucher_no": ""}, ) + frappe.get_doc("Serial and Batch Bundle", row.serial_and_batch_bundle).cancel() row.db_set("serial_and_batch_bundle", None) def on_update(self): @@ -324,7 +356,6 @@ class PickList(Document): locations_replica = self.get("locations") # reset - self.remove_serial_and_batch_bundle() self.delete_key("locations") updated_locations = frappe._dict() for item_doc in items: @@ -338,6 +369,7 @@ class PickList(Document): self.item_count_map.get(item_code), self.company, picked_item_details=picked_items_details.get(item_code), + consider_rejected_warehouses=self.consider_rejected_warehouses, ), ) @@ -639,13 +671,19 @@ def get_items_with_location_and_quantity(item_doc, item_location_map, docstatus) if not stock_qty: break + serial_nos = None + if item_location.serial_nos: + serial_nos = "\n".join(item_location.serial_nos[0 : cint(stock_qty)]) + locations.append( frappe._dict( { "qty": qty, "stock_qty": stock_qty, "warehouse": item_location.warehouse, - "serial_and_batch_bundle": item_location.serial_and_batch_bundle, + "serial_no": serial_nos, + "batch_no": item_location.batch_no, + "use_serial_batch_fields": 1, } ) ) @@ -673,6 +711,7 @@ def get_available_item_locations( company, ignore_validation=False, picked_item_details=None, + consider_rejected_warehouses=False, ): locations = [] total_picked_qty = ( @@ -681,17 +720,41 @@ def get_available_item_locations( has_serial_no = frappe.get_cached_value("Item", item_code, "has_serial_no") has_batch_no = frappe.get_cached_value("Item", item_code, "has_batch_no") - if has_serial_no: + if has_batch_no and has_serial_no: + locations = get_available_item_locations_for_serial_and_batched_item( + item_code, + from_warehouses, + required_qty, + company, + total_picked_qty, + consider_rejected_warehouses=consider_rejected_warehouses, + ) + elif has_serial_no: locations = get_available_item_locations_for_serialized_item( - item_code, from_warehouses, required_qty, company, total_picked_qty + item_code, + from_warehouses, + required_qty, + company, + total_picked_qty, + consider_rejected_warehouses=consider_rejected_warehouses, ) elif has_batch_no: locations = get_available_item_locations_for_batched_item( - item_code, from_warehouses, required_qty, company, total_picked_qty + item_code, + from_warehouses, + required_qty, + company, + total_picked_qty, + consider_rejected_warehouses=consider_rejected_warehouses, ) else: locations = get_available_item_locations_for_other_item( - item_code, from_warehouses, required_qty, company, total_picked_qty + item_code, + from_warehouses, + required_qty, + company, + total_picked_qty, + consider_rejected_warehouses=consider_rejected_warehouses, ) total_qty_available = sum(location.get("qty") for location in locations) @@ -724,8 +787,56 @@ def get_available_item_locations( return locations +def get_available_item_locations_for_serial_and_batched_item( + item_code, + from_warehouses, + required_qty, + company, + total_picked_qty=0, + consider_rejected_warehouses=False, +): + # Get batch nos by FIFO + locations = get_available_item_locations_for_batched_item( + item_code, + from_warehouses, + required_qty, + company, + consider_rejected_warehouses=consider_rejected_warehouses, + ) + + if locations: + sn = frappe.qb.DocType("Serial No") + conditions = (sn.item_code == item_code) & (sn.company == company) + + for location in locations: + location.qty = ( + required_qty if location.qty > required_qty else location.qty + ) # if extra qty in batch + + serial_nos = ( + frappe.qb.from_(sn) + .select(sn.name) + .where( + (conditions) & (sn.batch_no == location.batch_no) & (sn.warehouse == location.warehouse) + ) + .orderby(sn.creation) + .limit(ceil(location.qty + total_picked_qty)) + ).run(as_dict=True) + + serial_nos = [sn.name for sn in serial_nos] + location.serial_nos = serial_nos + location.qty = len(serial_nos) + + return locations + + def get_available_item_locations_for_serialized_item( - item_code, from_warehouses, required_qty, company, total_picked_qty=0 + item_code, + from_warehouses, + required_qty, + company, + total_picked_qty=0, + consider_rejected_warehouses=False, ): picked_serial_nos = get_picked_serial_nos(item_code, from_warehouses) @@ -742,6 +853,10 @@ def get_available_item_locations_for_serialized_item( else: query = query.where(Coalesce(sn.warehouse, "") != "") + if not consider_rejected_warehouses: + if rejected_warehouses := get_rejected_warehouses(): + query = query.where(sn.warehouse.notin(rejected_warehouses)) + serial_nos = query.run(as_list=True) warehouse_serial_nos_map = frappe._dict() @@ -757,28 +872,16 @@ def get_available_item_locations_for_serialized_item( picked_qty -= 1 locations = [] + for warehouse, serial_nos in warehouse_serial_nos_map.items(): qty = len(serial_nos) - bundle_doc = SerialBatchCreation( - { - "item_code": item_code, - "warehouse": warehouse, - "voucher_type": "Pick List", - "total_qty": qty * -1, - "serial_nos": serial_nos, - "type_of_transaction": "Outward", - "company": company, - "do_not_submit": True, - } - ).make_serial_and_batch_bundle() - locations.append( { "qty": qty, "warehouse": warehouse, "item_code": item_code, - "serial_and_batch_bundle": bundle_doc.name, + "serial_nos": serial_nos, } ) @@ -786,7 +889,12 @@ def get_available_item_locations_for_serialized_item( def get_available_item_locations_for_batched_item( - item_code, from_warehouses, required_qty, company, total_picked_qty=0 + item_code, + from_warehouses, + required_qty, + company, + total_picked_qty=0, + consider_rejected_warehouses=False, ): locations = [] data = get_auto_batch_nos( @@ -801,42 +909,42 @@ def get_available_item_locations_for_batched_item( ) warehouse_wise_batches = frappe._dict() + rejected_warehouses = get_rejected_warehouses() + for d in data: + if ( + not consider_rejected_warehouses and rejected_warehouses and d.warehouse in rejected_warehouses + ): + continue + if d.warehouse not in warehouse_wise_batches: warehouse_wise_batches.setdefault(d.warehouse, defaultdict(float)) warehouse_wise_batches[d.warehouse][d.batch_no] += d.qty for warehouse, batches in warehouse_wise_batches.items(): - qty = sum(batches.values()) - - bundle_doc = SerialBatchCreation( - { - "item_code": item_code, - "warehouse": warehouse, - "voucher_type": "Pick List", - "total_qty": qty * -1, - "batches": batches, - "type_of_transaction": "Outward", - "company": company, - "do_not_submit": True, - } - ).make_serial_and_batch_bundle() - - locations.append( - { - "qty": qty, - "warehouse": warehouse, - "item_code": item_code, - "serial_and_batch_bundle": bundle_doc.name, - } - ) + for batch_no, qty in batches.items(): + locations.append( + frappe._dict( + { + "qty": qty, + "warehouse": warehouse, + "item_code": item_code, + "batch_no": batch_no, + } + ) + ) return locations def get_available_item_locations_for_other_item( - item_code, from_warehouses, required_qty, company, total_picked_qty=0 + item_code, + from_warehouses, + required_qty, + company, + total_picked_qty=0, + consider_rejected_warehouses=False, ): bin = frappe.qb.DocType("Bin") query = ( @@ -853,6 +961,10 @@ def get_available_item_locations_for_other_item( wh = frappe.qb.DocType("Warehouse") query = query.from_(wh).where((bin.warehouse == wh.name) & (wh.company == company)) + if not consider_rejected_warehouses: + if rejected_warehouses := get_rejected_warehouses(): + query = query.where(bin.warehouse.notin(rejected_warehouses)) + item_locations = query.run(as_dict=True) return item_locations @@ -1174,3 +1286,15 @@ def update_common_item_properties(item, location): item.serial_no = location.serial_no item.batch_no = location.batch_no item.material_request_item = location.material_request_item + + +def get_rejected_warehouses(): + if not hasattr(frappe.local, "rejected_warehouses"): + frappe.local.rejected_warehouses = [] + + if not frappe.local.rejected_warehouses: + frappe.local.rejected_warehouses = frappe.get_all( + "Warehouse", filters={"is_rejected_warehouse": 1}, pluck="name" + ) + + return frappe.local.rejected_warehouses diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py index 322b0b46ba..cffd0d2820 100644 --- a/erpnext/stock/doctype/pick_list/test_pick_list.py +++ b/erpnext/stock/doctype/pick_list/test_pick_list.py @@ -217,6 +217,8 @@ class TestPickList(FrappeTestCase): ) pick_list.save() + pick_list.submit() + self.assertEqual(pick_list.locations[0].item_code, "_Test Serialized Item") self.assertEqual(pick_list.locations[0].warehouse, "_Test Warehouse - _TC") self.assertEqual(pick_list.locations[0].qty, 5) @@ -239,7 +241,7 @@ class TestPickList(FrappeTestCase): pr1 = make_purchase_receipt(item_code="Batched Item", qty=1, rate=100.0) pr1.load_from_db() - oldest_batch_no = pr1.items[0].batch_no + oldest_batch_no = get_batch_from_bundle(pr1.items[0].serial_and_batch_bundle) pr2 = make_purchase_receipt(item_code="Batched Item", qty=2, rate=100.0) @@ -302,6 +304,8 @@ class TestPickList(FrappeTestCase): } ) pick_list.set_item_locations() + pick_list.submit() + pick_list.reload() self.assertEqual( get_batch_from_bundle(pick_list.locations[0].serial_and_batch_bundle), oldest_batch_no @@ -310,6 +314,7 @@ class TestPickList(FrappeTestCase): get_serial_nos_from_bundle(pick_list.locations[0].serial_and_batch_bundle), oldest_serial_nos ) + pick_list.cancel() pr1.cancel() pr2.cancel() @@ -671,29 +676,22 @@ class TestPickList(FrappeTestCase): so = make_sales_order(item_code=item, qty=25.0, rate=100) pl = create_pick_list(so.name) + pl.submit() # pick half the qty for loc in pl.locations: self.assertEqual(loc.qty, 25.0) self.assertTrue(loc.serial_and_batch_bundle) - data = frappe.get_all( - "Serial and Batch Entry", - fields=["qty", "batch_no"], - filters={"parent": loc.serial_and_batch_bundle}, - ) - - for d in data: - self.assertEqual(d.batch_no, "PICKLT-000001") - self.assertEqual(d.qty, 25.0 * -1) - pl.save() pl.submit() so1 = make_sales_order(item_code=item, qty=10.0, rate=100) - pl = create_pick_list(so1.name) + pl1 = create_pick_list(so1.name) + pl1.submit() + # pick half the qty - for loc in pl.locations: - self.assertEqual(loc.qty, 10.0) + for loc in pl1.locations: + self.assertEqual(loc.qty, 5.0) self.assertTrue(loc.serial_and_batch_bundle) data = frappe.get_all( @@ -709,8 +707,7 @@ class TestPickList(FrappeTestCase): elif d.batch_no == "PICKLT-000002": self.assertEqual(d.qty, 5.0 * -1) - pl.save() - pl.submit() + pl1.cancel() pl.cancel() def test_picklist_for_serial_item(self): @@ -723,6 +720,7 @@ class TestPickList(FrappeTestCase): so = make_sales_order(item_code=item, qty=25.0, rate=100) pl = create_pick_list(so.name) + pl.submit() picked_serial_nos = [] # pick half the qty for loc in pl.locations: @@ -736,13 +734,11 @@ class TestPickList(FrappeTestCase): picked_serial_nos = [d.serial_no for d in data] self.assertEqual(len(picked_serial_nos), 25) - pl.save() - pl.submit() - so1 = make_sales_order(item_code=item, qty=10.0, rate=100) - pl = create_pick_list(so1.name) + pl1 = create_pick_list(so1.name) + pl1.submit() # pick half the qty - for loc in pl.locations: + for loc in pl1.locations: self.assertEqual(loc.qty, 10.0) self.assertTrue(loc.serial_and_batch_bundle) @@ -756,8 +752,7 @@ class TestPickList(FrappeTestCase): for d in data: self.assertTrue(d.serial_no not in picked_serial_nos) - pl.save() - pl.submit() + pl1.cancel() pl.cancel() def test_picklist_with_bundles(self): diff --git a/erpnext/stock/doctype/pick_list_item/pick_list_item.json b/erpnext/stock/doctype/pick_list_item/pick_list_item.json index e8e4afc6e3..962fa9f09d 100644 --- a/erpnext/stock/doctype/pick_list_item/pick_list_item.json +++ b/erpnext/stock/doctype/pick_list_item/pick_list_item.json @@ -24,8 +24,11 @@ "serial_no_and_batch_section", "pick_serial_and_batch", "serial_and_batch_bundle", - "serial_no", + "use_serial_batch_fields", "column_break_20", + "section_break_ecxc", + "serial_no", + "column_break_belw", "batch_no", "column_break_15", "sales_order", @@ -72,19 +75,17 @@ "read_only": 1 }, { - "depends_on": "serial_no", + "depends_on": "eval:doc.use_serial_batch_fields === 1", "fieldname": "serial_no", "fieldtype": "Small Text", - "label": "Serial No", - "read_only": 1 + "label": "Serial No" }, { - "depends_on": "batch_no", + "depends_on": "eval:doc.use_serial_batch_fields === 1", "fieldname": "batch_no", "fieldtype": "Link", "label": "Batch No", "options": "Batch", - "read_only": 1, "search_index": 1 }, { @@ -195,6 +196,7 @@ "read_only": 1 }, { + "depends_on": "eval:doc.use_serial_batch_fields === 0 || doc.docstatus === 1", "fieldname": "serial_and_batch_bundle", "fieldtype": "Link", "label": "Serial and Batch Bundle", @@ -204,6 +206,7 @@ "search_index": 1 }, { + "depends_on": "eval:doc.use_serial_batch_fields === 0 || doc.docstatus === 1", "fieldname": "pick_serial_and_batch", "fieldtype": "Button", "label": "Pick Serial / Batch No" @@ -218,11 +221,26 @@ "print_hide": 1, "read_only": 1, "report_hide": 1 + }, + { + "default": "0", + "fieldname": "use_serial_batch_fields", + "fieldtype": "Check", + "label": "Use Serial No / Batch Fields" + }, + { + "depends_on": "eval:doc.use_serial_batch_fields === 1", + "fieldname": "section_break_ecxc", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_belw", + "fieldtype": "Column Break" } ], "istable": 1, "links": [], - "modified": "2023-07-26 12:54:15.785962", + "modified": "2024-02-04 16:12:16.257951", "modified_by": "Administrator", "module": "Stock", "name": "Pick List Item", diff --git a/erpnext/stock/doctype/pick_list_item/pick_list_item.py b/erpnext/stock/doctype/pick_list_item/pick_list_item.py index 6e5a94e446..f3f6298a30 100644 --- a/erpnext/stock/doctype/pick_list_item/pick_list_item.py +++ b/erpnext/stock/doctype/pick_list_item/pick_list_item.py @@ -37,6 +37,7 @@ class PickListItem(Document): stock_reserved_qty: DF.Float stock_uom: DF.Link | None uom: DF.Link | None + use_serial_batch_fields: DF.Check warehouse: DF.Link | None # end: auto-generated types diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index bf6080bf23..c9fe7d2751 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -369,6 +369,7 @@ class PurchaseReceipt(BuyingController): else: self.db_set("status", "Completed") + self.make_bundle_using_old_serial_batch_fields() # Updating stock ledger should always be called after updating prevdoc status, # because updating ordered qty, reserved_qty_for_subcontract in bin # depends upon updated ordered qty in PO @@ -1360,16 +1361,16 @@ def get_item_account_wise_additional_cost(purchase_document): for lcv in landed_cost_vouchers: landed_cost_voucher_doc = frappe.get_doc("Landed Cost Voucher", lcv.parent) + based_on_field = None # Use amount field for total item cost for manually cost distributed LCVs - if landed_cost_voucher_doc.distribute_charges_based_on == "Distribute Manually": - based_on_field = "amount" - else: + if landed_cost_voucher_doc.distribute_charges_based_on != "Distribute Manually": based_on_field = frappe.scrub(landed_cost_voucher_doc.distribute_charges_based_on) total_item_cost = 0 - for item in landed_cost_voucher_doc.items: - total_item_cost += item.get(based_on_field) + if based_on_field: + for item in landed_cost_voucher_doc.items: + total_item_cost += item.get(based_on_field) for item in landed_cost_voucher_doc.items: if item.receipt_document == purchase_document: diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index 65c08c164c..2d209220de 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -2230,6 +2230,93 @@ class TestPurchaseReceipt(FrappeTestCase): pr_doc.reload() self.assertFalse(pr_doc.items[0].from_warehouse) + def test_use_serial_batch_fields_for_serial_nos(self): + from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note + from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos + from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import ( + create_stock_reconciliation, + ) + + item_code = make_item( + "_Test Use Serial Fields Item Serial Item", + properties={"has_serial_no": 1, "serial_no_series": "SNU-TSFISI-.#####"}, + ).name + + serial_nos = [ + "SNU-TSFISI-000011", + "SNU-TSFISI-000012", + "SNU-TSFISI-000013", + "SNU-TSFISI-000014", + "SNU-TSFISI-000015", + ] + + pr = make_purchase_receipt( + item_code=item_code, + qty=5, + serial_no="\n".join(serial_nos), + use_serial_batch_fields=1, + rate=100, + ) + + self.assertEqual(pr.items[0].use_serial_batch_fields, 1) + self.assertFalse(pr.items[0].serial_no) + self.assertTrue(pr.items[0].serial_and_batch_bundle) + + sbb_doc = frappe.get_doc("Serial and Batch Bundle", pr.items[0].serial_and_batch_bundle) + + for row in sbb_doc.entries: + self.assertTrue(row.serial_no in serial_nos) + + serial_nos.remove("SNU-TSFISI-000015") + + sr = create_stock_reconciliation( + item_code=item_code, + serial_no="\n".join(serial_nos), + qty=4, + warehouse=pr.items[0].warehouse, + use_serial_batch_fields=1, + do_not_submit=True, + ) + sr.reload() + + serial_nos = get_serial_nos(sr.items[0].current_serial_no) + self.assertEqual(len(serial_nos), 5) + self.assertEqual(sr.items[0].current_qty, 5) + + new_serial_nos = get_serial_nos(sr.items[0].serial_no) + self.assertEqual(len(new_serial_nos), 4) + self.assertEqual(sr.items[0].qty, 4) + self.assertEqual(sr.items[0].use_serial_batch_fields, 1) + self.assertFalse(sr.items[0].current_serial_and_batch_bundle) + self.assertFalse(sr.items[0].serial_and_batch_bundle) + self.assertTrue(sr.items[0].current_serial_no) + sr.submit() + + sr.reload() + self.assertTrue(sr.items[0].current_serial_and_batch_bundle) + self.assertTrue(sr.items[0].serial_and_batch_bundle) + + serial_no_status = frappe.db.get_value("Serial No", "SNU-TSFISI-000015", "status") + + self.assertTrue(serial_no_status != "Active") + + dn = create_delivery_note( + item_code=item_code, + qty=4, + serial_no="\n".join(new_serial_nos), + use_serial_batch_fields=1, + ) + + self.assertTrue(dn.items[0].serial_and_batch_bundle) + self.assertEqual(dn.items[0].qty, 4) + doc = frappe.get_doc("Serial and Batch Bundle", dn.items[0].serial_and_batch_bundle) + for row in doc.entries: + self.assertTrue(row.serial_no in new_serial_nos) + + for sn in new_serial_nos: + serial_no_status = frappe.db.get_value("Serial No", sn, "status") + self.assertTrue(serial_no_status != "Active") + def prepare_data_for_internal_transfer(): from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier @@ -2399,7 +2486,7 @@ def make_purchase_receipt(**args): uom = args.uom or frappe.db.get_value("Item", item_code, "stock_uom") or "_Test UOM" bundle_id = None - if args.get("batch_no") or args.get("serial_no"): + if not args.use_serial_batch_fields and (args.get("batch_no") or args.get("serial_no")): batches = {} if args.get("batch_no"): batches = frappe._dict({args.batch_no: qty}) @@ -2441,6 +2528,9 @@ def make_purchase_receipt(**args): "cost_center": args.cost_center or frappe.get_cached_value("Company", pr.company, "cost_center"), "asset_location": args.location or "Test Location", + "use_serial_batch_fields": args.use_serial_batch_fields or 0, + "serial_no": args.serial_no if args.use_serial_batch_fields else "", + "batch_no": args.batch_no if args.use_serial_batch_fields else "", }, ) diff --git a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json index 9bd692ad61..6b01047f00 100644 --- a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json +++ b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json @@ -94,6 +94,7 @@ "section_break_45", "add_serial_batch_bundle", "serial_and_batch_bundle", + "use_serial_batch_fields", "col_break5", "add_serial_batch_for_rejected_qty", "rejected_serial_and_batch_bundle", @@ -1003,6 +1004,7 @@ "read_only": 1 }, { + "depends_on": "eval:doc.use_serial_batch_fields === 0 || doc.docstatus === 1", "fieldname": "serial_and_batch_bundle", "fieldtype": "Link", "label": "Serial and Batch Bundle", @@ -1020,24 +1022,22 @@ { "fieldname": "serial_no", "fieldtype": "Text", - "label": "Serial No", - "read_only": 1 + "label": "Serial No" }, { "fieldname": "rejected_serial_no", "fieldtype": "Text", - "label": "Rejected Serial No", - "read_only": 1 + "label": "Rejected Serial No" }, { "fieldname": "batch_no", "fieldtype": "Link", "label": "Batch No", "options": "Batch", - "read_only": 1, "search_index": 1 }, { + "depends_on": "eval:doc.use_serial_batch_fields === 0 || doc.docstatus === 1", "fieldname": "rejected_serial_and_batch_bundle", "fieldtype": "Link", "label": "Rejected Serial and Batch Bundle", @@ -1045,11 +1045,13 @@ "options": "Serial and Batch Bundle" }, { + "depends_on": "eval:doc.use_serial_batch_fields === 0", "fieldname": "add_serial_batch_for_rejected_qty", "fieldtype": "Button", "label": "Add Serial / Batch No (Rejected Qty)" }, { + "depends_on": "eval:doc.use_serial_batch_fields === 1", "fieldname": "section_break_3vxt", "fieldtype": "Section Break" }, @@ -1058,6 +1060,7 @@ "fieldtype": "Column Break" }, { + "depends_on": "eval:doc.use_serial_batch_fields === 0", "fieldname": "add_serial_batch_bundle", "fieldtype": "Button", "label": "Add Serial / Batch No" @@ -1098,12 +1101,18 @@ "read_only": 1, "report_hide": 1, "search_index": 1 + }, + { + "default": "0", + "fieldname": "use_serial_batch_fields", + "fieldtype": "Check", + "label": "Use Serial No / Batch Fields" } ], "idx": 1, "istable": 1, "links": [], - "modified": "2023-12-25 22:32:09.801965", + "modified": "2024-02-04 11:48:06.653771", "modified_by": "Administrator", "module": "Stock", "name": "Purchase Receipt Item", diff --git a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.py b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.py index aed8d21dae..3c6dcdca48 100644 --- a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.py +++ b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.py @@ -99,6 +99,7 @@ class PurchaseReceiptItem(Document): supplier_part_no: DF.Data | None total_weight: DF.Float uom: DF.Link + use_serial_batch_fields: DF.Check valuation_rate: DF.Currency warehouse: DF.Link | None weight_per_unit: DF.Float 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 4f45210fb9..31fc2cab6a 100644 --- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py @@ -281,6 +281,7 @@ def repost(doc): repost_gl_entries(doc) doc.set_status("Completed") + remove_attached_file(doc.name) except Exception as e: if frappe.flags.in_test: @@ -309,6 +310,13 @@ def repost(doc): frappe.db.commit() +def remove_attached_file(docname): + if file_name := frappe.db.get_value( + "File", {"attached_to_name": docname, "attached_to_doctype": "Repost Item Valuation"}, "name" + ): + frappe.delete_doc("File", file_name, delete_permanently=True) + + def repost_sl_entries(doc): if doc.based_on == "Transaction": repost_future_sle( 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 5b76e442f4..f96a612dcb 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 @@ -420,3 +420,38 @@ class TestRepostItemValuation(FrappeTestCase, StockTestMixin): self.assertRaises(frappe.ValidationError, riv.save) doc.cancel() + + def test_remove_attached_file(self): + item_code = make_item("_Test Remove Attached File Item", properties={"is_stock_item": 1}) + + make_purchase_receipt( + item_code=item_code, + qty=1, + rate=100, + ) + + pr1 = make_purchase_receipt( + item_code=item_code, + qty=1, + rate=100, + posting_date=add_days(today(), days=-1), + ) + + if docname := frappe.db.exists("Repost Item Valuation", {"voucher_no": pr1.name}): + self.assertFalse( + frappe.db.get_value( + "File", + {"attached_to_doctype": "Repost Item Valuation", "attached_to_name": docname}, + "name", + ) + ) + else: + repost_entries = create_item_wise_repost_entries(pr1.doctype, pr1.name) + for entry in repost_entries: + self.assertFalse( + frappe.db.get_value( + "File", + {"attached_to_doctype": "Repost Item Valuation", "attached_to_name": entry.name}, + "name", + ) + ) diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py index 9cad8f62b8..eb4df29db8 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py @@ -1117,7 +1117,7 @@ def parse_serial_nos(data): if isinstance(data, list): return data - return [s.strip() for s in cstr(data).strip().upper().replace(",", "\n").split("\n") if s.strip()] + return [s.strip() for s in cstr(data).strip().replace(",", "\n").split("\n") if s.strip()] @frappe.whitelist() @@ -1256,7 +1256,7 @@ def create_serial_batch_no_ledgers( def get_type_of_transaction(parent_doc, child_row): - type_of_transaction = child_row.type_of_transaction + type_of_transaction = child_row.get("type_of_transaction") if parent_doc.get("doctype") == "Stock Entry": type_of_transaction = "Outward" if child_row.s_warehouse else "Inward" @@ -1384,6 +1384,8 @@ def get_available_serial_nos(kwargs): filters = {"item_code": kwargs.item_code} + # ignore_warehouse is used for backdated stock transactions + # There might be chances that the serial no not exists in the warehouse during backdated stock transactions if not kwargs.get("ignore_warehouse"): filters["warehouse"] = ("is", "set") if kwargs.warehouse: @@ -1677,7 +1679,10 @@ def get_reserved_batches_for_sre(kwargs) -> dict: query = query.where(sb_entry.batch_no == kwargs.batch_no) if kwargs.warehouse: - query = query.where(sre.warehouse == kwargs.warehouse) + if isinstance(kwargs.warehouse, list): + query = query.where(sre.warehouse.isin(kwargs.warehouse)) + else: + query = query.where(sre.warehouse == kwargs.warehouse) if kwargs.ignore_voucher_nos: query = query.where(sre.name.notin(kwargs.ignore_voucher_nos)) diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py index 0d453fb841..f430943708 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py @@ -136,6 +136,7 @@ class TestSerialandBatchBundle(FrappeTestCase): def test_old_batch_valuation(self): frappe.flags.ignore_serial_batch_bundle_validation = True + frappe.flags.use_serial_and_batch_fields = True batch_item_code = "Old Batch Item Valuation 1" make_item( batch_item_code, @@ -240,6 +241,7 @@ class TestSerialandBatchBundle(FrappeTestCase): bundle_doc.submit() frappe.flags.ignore_serial_batch_bundle_validation = False + frappe.flags.use_serial_and_batch_fields = False def test_old_serial_no_valuation(self): from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt @@ -259,6 +261,7 @@ class TestSerialandBatchBundle(FrappeTestCase): ) frappe.flags.ignore_serial_batch_bundle_validation = True + frappe.flags.use_serial_and_batch_fields = True serial_no_id = "Old Serial No 1" if not frappe.db.exists("Serial No", serial_no_id): @@ -320,6 +323,9 @@ class TestSerialandBatchBundle(FrappeTestCase): for row in bundle_doc.entries: self.assertEqual(flt(row.stock_value_difference, 2), -100.00) + frappe.flags.ignore_serial_batch_bundle_validation = False + frappe.flags.use_serial_and_batch_fields = False + def test_batch_not_belong_to_serial_no(self): from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt diff --git a/erpnext/stock/doctype/serial_no/serial_no.py b/erpnext/stock/doctype/serial_no/serial_no.py index 122664c2dd..5f4f3931a7 100644 --- a/erpnext/stock/doctype/serial_no/serial_no.py +++ b/erpnext/stock/doctype/serial_no/serial_no.py @@ -151,9 +151,7 @@ def get_serial_nos(serial_no): if isinstance(serial_no, list): return serial_no - return [ - s.strip() for s in cstr(serial_no).strip().upper().replace(",", "\n").split("\n") if s.strip() - ] + return [s.strip() for s in cstr(serial_no).strip().replace(",", "\n").split("\n") if s.strip()] def clean_serial_no_string(serial_no: str) -> str: diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index faccfa3a3d..10e3522579 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -274,6 +274,7 @@ class StockEntry(StockController): def on_submit(self): self.validate_closed_subcontracting_order() + self.make_bundle_using_old_serial_batch_fields() self.update_stock_ledger() self.update_work_order() self.validate_subcontract_order() diff --git a/erpnext/stock/doctype/stock_entry/stock_entry_utils.py b/erpnext/stock/doctype/stock_entry/stock_entry_utils.py index 83bfaa0094..0f67e47ad9 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry_utils.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry_utils.py @@ -92,6 +92,9 @@ def make_stock_entry(**args): else: args.qty = cint(args.qty) + if args.serial_no or args.batch_no: + args.use_serial_batch_fields = True + # purpose if not args.purpose: if args.source and args.target: @@ -162,6 +165,7 @@ def make_stock_entry(**args): ) args.serial_no = serial_number + s.append( "items", { @@ -177,6 +181,7 @@ def make_stock_entry(**args): "batch_no": args.batch_no, "cost_center": args.cost_center, "expense_account": args.expense_account, + "use_serial_batch_fields": args.use_serial_batch_fields, }, ) diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index 23dacc8343..af91536acc 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -680,6 +680,7 @@ class TestStockEntry(FrappeTestCase): def test_serial_move(self): se = make_serialized_item() serial_no = get_serial_nos_from_bundle(se.get("items")[0].serial_and_batch_bundle)[0] + frappe.flags.use_serial_and_batch_fields = True se = frappe.copy_doc(test_records[0]) se.purpose = "Material Transfer" @@ -700,6 +701,7 @@ class TestStockEntry(FrappeTestCase): self.assertTrue( frappe.db.get_value("Serial No", serial_no, "warehouse"), "_Test Warehouse - _TC" ) + frappe.flags.use_serial_and_batch_fields = False def test_serial_cancel(self): se, serial_nos = self.test_serial_by_series() @@ -999,6 +1001,8 @@ class TestStockEntry(FrappeTestCase): do_not_save=True, ) + frappe.flags.use_serial_and_batch_fields = True + cls_obj = SerialBatchCreation( { "type_of_transaction": "Inward", @@ -1035,84 +1039,7 @@ class TestStockEntry(FrappeTestCase): s2.submit() s2.cancel() - - # def test_retain_sample(self): - # from erpnext.stock.doctype.batch.batch import get_batch_qty - # from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse - - # create_warehouse("Test Warehouse for Sample Retention") - # frappe.db.set_value( - # "Stock Settings", - # None, - # "sample_retention_warehouse", - # "Test Warehouse for Sample Retention - _TC", - # ) - - # test_item_code = "Retain Sample Item" - # if not frappe.db.exists("Item", test_item_code): - # item = frappe.new_doc("Item") - # item.item_code = test_item_code - # item.item_name = "Retain Sample Item" - # item.description = "Retain Sample Item" - # item.item_group = "All Item Groups" - # item.is_stock_item = 1 - # item.has_batch_no = 1 - # item.create_new_batch = 1 - # item.retain_sample = 1 - # item.sample_quantity = 4 - # item.save() - - # receipt_entry = frappe.new_doc("Stock Entry") - # receipt_entry.company = "_Test Company" - # receipt_entry.purpose = "Material Receipt" - # receipt_entry.append( - # "items", - # { - # "item_code": test_item_code, - # "t_warehouse": "_Test Warehouse - _TC", - # "qty": 40, - # "basic_rate": 12, - # "cost_center": "_Test Cost Center - _TC", - # "sample_quantity": 4, - # }, - # ) - # receipt_entry.set_stock_entry_type() - # receipt_entry.insert() - # receipt_entry.submit() - - # retention_data = move_sample_to_retention_warehouse( - # receipt_entry.company, receipt_entry.get("items") - # ) - # retention_entry = frappe.new_doc("Stock Entry") - # retention_entry.company = retention_data.company - # retention_entry.purpose = retention_data.purpose - # retention_entry.append( - # "items", - # { - # "item_code": test_item_code, - # "t_warehouse": "Test Warehouse for Sample Retention - _TC", - # "s_warehouse": "_Test Warehouse - _TC", - # "qty": 4, - # "basic_rate": 12, - # "cost_center": "_Test Cost Center - _TC", - # "batch_no": get_batch_from_bundle(receipt_entry.get("items")[0].serial_and_batch_bundle), - # }, - # ) - # retention_entry.set_stock_entry_type() - # retention_entry.insert() - # retention_entry.submit() - - # qty_in_usable_warehouse = get_batch_qty( - # get_batch_from_bundle(receipt_entry.get("items")[0].serial_and_batch_bundle), "_Test Warehouse - _TC", "_Test Item" - # ) - # qty_in_retention_warehouse = get_batch_qty( - # get_batch_from_bundle(receipt_entry.get("items")[0].serial_and_batch_bundle), - # "Test Warehouse for Sample Retention - _TC", - # "_Test Item", - # ) - - # self.assertEqual(qty_in_usable_warehouse, 36) - # self.assertEqual(qty_in_retention_warehouse, 4) + frappe.flags.use_serial_and_batch_fields = False def test_quality_check(self): item_code = "_Test Item For QC" @@ -1785,6 +1712,48 @@ class TestStockEntry(FrappeTestCase): self.assertRaises(frappe.ValidationError, se1.cancel) + def test_auto_reorder_level(self): + from erpnext.stock.reorder_item import reorder_item + + item_doc = make_item( + "Test Auto Reorder Item - 001", + properties={"stock_uom": "Kg", "purchase_uom": "Nos", "is_stock_item": 1}, + uoms=[{"uom": "Nos", "conversion_factor": 5}], + ) + + if not frappe.db.exists("Item Reorder", {"parent": item_doc.name}): + item_doc.append( + "reorder_levels", + { + "warehouse_reorder_level": 0, + "warehouse_reorder_qty": 10, + "warehouse": "_Test Warehouse - _TC", + "material_request_type": "Purchase", + }, + ) + + item_doc.save(ignore_permissions=True) + + frappe.db.set_single_value("Stock Settings", "auto_indent", 1) + + mr_list = reorder_item() + + frappe.db.set_single_value("Stock Settings", "auto_indent", 0) + mrs = frappe.get_all( + "Material Request Item", + fields=["qty", "stock_uom", "stock_qty"], + filters={"item_code": item_doc.name, "uom": "Nos"}, + ) + + for mri in mrs: + self.assertEqual(mri.stock_uom, "Kg") + self.assertEqual(mri.stock_qty, 10) + self.assertEqual(mri.qty, 2) + + for mr in mr_list: + mr.cancel() + mr.delete() + def make_serialized_item(**args): args = frappe._dict(args) diff --git a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json index bd84a2b0d9..c7b3daab82 100644 --- a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json +++ b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json @@ -47,9 +47,12 @@ "amount", "serial_no_batch", "add_serial_batch_bundle", - "serial_and_batch_bundle", + "use_serial_batch_fields", "col_break4", + "serial_and_batch_bundle", + "section_break_rdtg", "serial_no", + "column_break_prps", "batch_no", "accounting", "expense_account", @@ -289,27 +292,27 @@ "no_copy": 1 }, { + "depends_on": "eval:doc.use_serial_batch_fields === 1", "fieldname": "serial_no", "fieldtype": "Small Text", "label": "Serial No", "no_copy": 1, "oldfieldname": "serial_no", - "oldfieldtype": "Text", - "read_only": 1 + "oldfieldtype": "Text" }, { "fieldname": "col_break4", "fieldtype": "Column Break" }, { + "depends_on": "eval:doc.use_serial_batch_fields === 1", "fieldname": "batch_no", "fieldtype": "Link", "label": "Batch No", "no_copy": 1, "oldfieldname": "batch_no", "oldfieldtype": "Link", - "options": "Batch", - "read_only": 1 + "options": "Batch" }, { "depends_on": "eval:parent.inspection_required && doc.t_warehouse", @@ -573,24 +576,41 @@ "read_only": 1 }, { + "depends_on": "eval:doc.use_serial_batch_fields === 0", "fieldname": "add_serial_batch_bundle", "fieldtype": "Button", "label": "Add Serial / Batch No" }, { + "depends_on": "eval:doc.use_serial_batch_fields === 0 || doc.docstatus === 1", "fieldname": "serial_and_batch_bundle", "fieldtype": "Link", "label": "Serial and Batch Bundle", "no_copy": 1, "options": "Serial and Batch Bundle", "print_hide": 1 + }, + { + "default": "0", + "fieldname": "use_serial_batch_fields", + "fieldtype": "Check", + "label": "Use Serial No / Batch Fields" + }, + { + "depends_on": "eval:doc.use_serial_batch_fields === 1", + "fieldname": "section_break_rdtg", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_prps", + "fieldtype": "Column Break" } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2024-01-12 11:56:04.626103", + "modified": "2024-02-04 16:16:47.606270", "modified_by": "Administrator", "module": "Stock", "name": "Stock Entry Detail", diff --git a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.py b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.py index a6dd0faadf..47c443c519 100644 --- a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.py +++ b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.py @@ -63,6 +63,7 @@ class StockEntryDetail(Document): transfer_qty: DF.Float transferred_qty: DF.Float uom: DF.Link + use_serial_batch_fields: DF.Check valuation_rate: DF.Currency # end: auto-generated types diff --git a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py index 277ca01320..04441f0e8b 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py @@ -93,6 +93,9 @@ class StockLedgerEntry(Document): self.validate_inventory_dimension_negative_stock() def validate_inventory_dimension_negative_stock(self): + if self.is_cancelled: + return + extra_cond = "" kwargs = {} 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 d8a3f2e33c..c0999532d0 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 @@ -482,6 +482,8 @@ class TestStockLedgerEntry(FrappeTestCase, StockTestMixin): (item, warehouses[0], batches[1], 1, 200), (item, warehouses[0], batches[0], 1, 200), ] + + frappe.flags.use_serial_and_batch_fields = True dns = create_delivery_note_entries_for_batchwise_item_valuation_test(dn_entry_list) sle_details = fetch_sle_details_for_doc_list(dns, ["stock_value_difference"]) svd_list = [-1 * d["stock_value_difference"] for d in sle_details] @@ -494,6 +496,8 @@ class TestStockLedgerEntry(FrappeTestCase, StockTestMixin): "Incorrect 'Incoming Rate' values fetched for DN items", ) + frappe.flags.use_serial_and_batch_fields = False + 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/stock_reconciliation.js b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js index 8e9dcb0fc5..ba7f9c58a8 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js @@ -198,6 +198,7 @@ frappe.ui.form.on("Stock Reconciliation", { frappe.model.set_value(cdt, cdn, "current_amount", r.message.rate * r.message.qty); frappe.model.set_value(cdt, cdn, "amount", row.qty * row.valuation_rate); frappe.model.set_value(cdt, cdn, "current_serial_no", r.message.serial_nos); + frappe.model.set_value(cdt, cdn, "use_serial_batch_fields", r.message.use_serial_batch_fields); if (frm.doc.purpose == "Stock Reconciliation" && !frm.doc.scan_mode) { frappe.model.set_value(cdt, cdn, "serial_no", r.message.serial_nos); diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index 788ae0d3ab..ce08615ed5 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -99,6 +99,8 @@ class StockReconciliation(StockController): ) def on_submit(self): + self.make_bundle_for_current_qty() + self.make_bundle_using_old_serial_batch_fields() self.update_stock_ledger() self.make_gl_entries() self.repost_future_sle_and_gle() @@ -116,9 +118,52 @@ class StockReconciliation(StockController): self.repost_future_sle_and_gle() self.delete_auto_created_batches() + def make_bundle_for_current_qty(self): + from erpnext.stock.serial_batch_bundle import SerialBatchCreation + + for row in self.items: + if not row.use_serial_batch_fields: + continue + + if row.current_serial_and_batch_bundle: + continue + + if row.current_qty and (row.current_serial_no or row.batch_no): + sn_doc = SerialBatchCreation( + { + "item_code": row.item_code, + "warehouse": row.warehouse, + "posting_date": self.posting_date, + "posting_time": self.posting_time, + "voucher_type": self.doctype, + "voucher_no": self.name, + "voucher_detail_no": row.name, + "qty": row.qty, + "type_of_transaction": "Outward", + "company": self.company, + "is_rejected": 0, + "serial_nos": get_serial_nos(row.current_serial_no) if row.current_serial_no else None, + "batches": frappe._dict({row.batch_no: row.qty}) if row.batch_no else None, + "batch_no": row.batch_no, + "do_not_submit": True, + } + ).make_serial_and_batch_bundle() + + row.current_serial_and_batch_bundle = sn_doc.name + row.db_set( + { + "current_serial_and_batch_bundle": sn_doc.name, + "current_serial_no": "", + "batch_no": "", + } + ) + def set_current_serial_and_batch_bundle(self, voucher_detail_no=None, save=False) -> None: """Set Serial and Batch Bundle for each item""" for item in self.items: + if not save and item.use_serial_batch_fields: + continue + if voucher_detail_no and voucher_detail_no != item.name: continue @@ -229,6 +274,9 @@ class StockReconciliation(StockController): def set_new_serial_and_batch_bundle(self): for item in self.items: + if item.use_serial_batch_fields: + continue + if not item.qty: continue @@ -291,8 +339,10 @@ class StockReconciliation(StockController): inventory_dimensions_dict=inventory_dimensions_dict, ) - if (item.qty is None or item.qty == item_dict.get("qty")) and ( - item.valuation_rate is None or item.valuation_rate == item_dict.get("rate") + if ( + (item.qty is None or item.qty == item_dict.get("qty")) + and (item.valuation_rate is None or item.valuation_rate == item_dict.get("rate")) + and (not item.serial_no or (item.serial_no == item_dict.get("serial_nos"))) ): return False else: @@ -303,6 +353,11 @@ class StockReconciliation(StockController): if item.valuation_rate is None: item.valuation_rate = item_dict.get("rate") + if item_dict.get("serial_nos"): + item.current_serial_no = item_dict.get("serial_nos") + if self.purpose == "Stock Reconciliation" and not item.serial_no and item.qty: + item.serial_no = item.current_serial_no + item.current_qty = item_dict.get("qty") item.current_valuation_rate = item_dict.get("rate") self.calculate_difference_amount(item, item_dict) @@ -1135,9 +1190,16 @@ def get_stock_balance_for( has_serial_no = bool(item_dict.get("has_serial_no")) has_batch_no = bool(item_dict.get("has_batch_no")) + use_serial_batch_fields = frappe.db.get_single_value("Stock Settings", "use_serial_batch_fields") + if not batch_no and has_batch_no: # Not enough information to fetch data - return {"qty": 0, "rate": 0, "serial_nos": None} + return { + "qty": 0, + "rate": 0, + "serial_nos": None, + "use_serial_batch_fields": use_serial_batch_fields, + } # TODO: fetch only selected batch's values data = get_stock_balance( @@ -1160,7 +1222,12 @@ def get_stock_balance_for( get_batch_qty(batch_no, warehouse, posting_date=posting_date, posting_time=posting_time) or 0 ) - return {"qty": qty, "rate": rate, "serial_nos": serial_nos} + return { + "qty": qty, + "rate": rate, + "serial_nos": serial_nos, + "use_serial_batch_fields": use_serial_batch_fields, + } @frappe.whitelist() diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py index 0bbfed40d8..479a74af7a 100644 --- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py @@ -1094,7 +1094,7 @@ def create_stock_reconciliation(**args): ) bundle_id = None - if args.batch_no or args.serial_no: + if not args.use_serial_batch_fields and (args.batch_no or args.serial_no): batches = frappe._dict({}) if args.batch_no: batches[args.batch_no] = args.qty @@ -1125,7 +1125,10 @@ def create_stock_reconciliation(**args): "warehouse": args.warehouse or "_Test Warehouse - _TC", "qty": args.qty, "valuation_rate": args.rate, + "serial_no": args.serial_no if args.use_serial_batch_fields else None, + "batch_no": args.batch_no if args.use_serial_batch_fields else None, "serial_and_batch_bundle": bundle_id, + "use_serial_batch_fields": args.use_serial_batch_fields, }, ) diff --git a/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json b/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json index fc4ae6a5fa..734225972c 100644 --- a/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json +++ b/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json @@ -19,11 +19,14 @@ "allow_zero_valuation_rate", "serial_no_and_batch_section", "add_serial_batch_bundle", - "serial_and_batch_bundle", - "batch_no", + "use_serial_batch_fields", "column_break_11", + "serial_and_batch_bundle", "current_serial_and_batch_bundle", + "section_break_lypk", "serial_no", + "column_break_eefq", + "batch_no", "section_break_3", "current_qty", "current_amount", @@ -103,10 +106,10 @@ "label": "Serial No and Batch" }, { + "depends_on": "eval:doc.use_serial_batch_fields === 1", "fieldname": "serial_no", "fieldtype": "Long Text", - "label": "Serial No", - "read_only": 1 + "label": "Serial No" }, { "fieldname": "column_break_11", @@ -171,11 +174,11 @@ "read_only": 1 }, { + "depends_on": "eval:doc.use_serial_batch_fields === 1", "fieldname": "batch_no", "fieldtype": "Link", "label": "Batch No", "options": "Batch", - "read_only": 1, "search_index": 1 }, { @@ -195,6 +198,7 @@ "read_only": 1 }, { + "depends_on": "eval:doc.use_serial_batch_fields === 0 || doc.docstatus === 1", "fieldname": "serial_and_batch_bundle", "fieldtype": "Link", "label": "Serial / Batch Bundle", @@ -204,6 +208,7 @@ "search_index": 1 }, { + "depends_on": "eval:doc.use_serial_batch_fields === 0 || doc.docstatus === 1", "fieldname": "current_serial_and_batch_bundle", "fieldtype": "Link", "label": "Current Serial / Batch Bundle", @@ -212,6 +217,7 @@ "read_only": 1 }, { + "depends_on": "eval:doc.use_serial_batch_fields === 0 || doc.docstatus === 1", "fieldname": "add_serial_batch_bundle", "fieldtype": "Button", "label": "Add Serial / Batch No" @@ -222,11 +228,26 @@ "fieldtype": "Link", "label": "Item Group", "options": "Item Group" + }, + { + "default": "0", + "fieldname": "use_serial_batch_fields", + "fieldtype": "Check", + "label": "Use Serial No / Batch Fields" + }, + { + "depends_on": "eval:doc.use_serial_batch_fields === 1", + "fieldname": "section_break_lypk", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_eefq", + "fieldtype": "Column Break" } ], "istable": 1, "links": [], - "modified": "2024-01-14 10:04:23.599951", + "modified": "2024-02-04 16:19:44.576022", "modified_by": "Administrator", "module": "Stock", "name": "Stock Reconciliation Item", diff --git a/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.py b/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.py index c82cdf58de..1938fec32b 100644 --- a/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.py +++ b/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.py @@ -26,6 +26,7 @@ class StockReconciliationItem(Document): current_valuation_rate: DF.Currency has_item_scanned: DF.Data | None item_code: DF.Link + item_group: DF.Link | None item_name: DF.Data | None parent: DF.Data parentfield: DF.Data @@ -34,6 +35,7 @@ class StockReconciliationItem(Document): quantity_difference: DF.ReadOnly | None serial_and_batch_bundle: DF.Link | None serial_no: DF.LongText | None + use_serial_batch_fields: DF.Check valuation_rate: DF.Currency warehouse: DF.Link # end: auto-generated types diff --git a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json index 76cedd4b1e..bf5ea741e3 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json @@ -315,7 +315,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2023-10-19 16:41:16.545416", + "modified": "2024-02-07 16:05:17.772098", "modified_by": "Administrator", "module": "Stock", "name": "Stock Reservation Entry", @@ -335,6 +335,90 @@ "share": 1, "submit": 1, "write": 1 + }, + { + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales Manager", + "share": 1, + "submit": 1, + "write": 1 + }, + { + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Purchase Manager", + "share": 1, + "submit": 1, + "write": 1 + }, + { + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Stock Manager", + "share": 1, + "submit": 1, + "write": 1 + }, + { + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales User", + "share": 1, + "submit": 1, + "write": 1 + }, + { + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Purchase User", + "share": 1, + "submit": 1, + "write": 1 + }, + { + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Stock User", + "share": 1, + "submit": 1, + "write": 1 } ], "sort_field": "modified", diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json index dc2797457b..c6982831b7 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.json +++ b/erpnext/stock/doctype/stock_settings/stock_settings.json @@ -50,6 +50,7 @@ "disable_serial_no_and_batch_selector", "use_naming_series", "naming_series_prefix", + "use_serial_batch_fields", "stock_planning_tab", "auto_material_request", "auto_indent", @@ -420,6 +421,12 @@ "fieldname": "auto_reserve_stock_for_sales_order_on_purchase", "fieldtype": "Check", "label": "Auto Reserve Stock for Sales Order on Purchase" + }, + { + "default": "1", + "fieldname": "use_serial_batch_fields", + "fieldtype": "Check", + "label": "Use Serial / Batch Fields" } ], "icon": "icon-cog", @@ -427,7 +434,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2024-01-30 14:03:52.143457", + "modified": "2024-02-04 12:01:31.931864", "modified_by": "Administrator", "module": "Stock", "name": "Stock Settings", diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.py b/erpnext/stock/doctype/stock_settings/stock_settings.py index 088c7cdfe1..c4960aa67a 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.py +++ b/erpnext/stock/doctype/stock_settings/stock_settings.py @@ -57,6 +57,7 @@ class StockSettings(Document): stock_uom: DF.Link | None update_existing_price_list_rate: DF.Check use_naming_series: DF.Check + use_serial_batch_fields: DF.Check valuation_method: DF.Literal["FIFO", "Moving Average", "LIFO"] # end: auto-generated types @@ -68,6 +69,7 @@ class StockSettings(Document): "allow_negative_stock", "default_warehouse", "set_qty_in_transactions_based_on_serial_no_input", + "use_serial_batch_fields", ]: frappe.db.set_default(key, self.get(key, "")) diff --git a/erpnext/stock/doctype/warehouse/warehouse.json b/erpnext/stock/doctype/warehouse/warehouse.json index 43b2ad2a69..7b0cade3ca 100644 --- a/erpnext/stock/doctype/warehouse/warehouse.json +++ b/erpnext/stock/doctype/warehouse/warehouse.json @@ -13,6 +13,7 @@ "column_break_3", "is_group", "parent_warehouse", + "is_rejected_warehouse", "column_break_4", "account", "company", @@ -249,13 +250,20 @@ { "fieldname": "column_break_qajx", "fieldtype": "Column Break" + }, + { + "default": "0", + "description": "If yes, then this warehouse will be used to store rejected materials", + "fieldname": "is_rejected_warehouse", + "fieldtype": "Check", + "label": "Is Rejected Warehouse" } ], "icon": "fa fa-building", "idx": 1, "is_tree": 1, "links": [], - "modified": "2023-05-29 13:10:43.333160", + "modified": "2024-01-24 16:27:28.299520", "modified_by": "Administrator", "module": "Stock", "name": "Warehouse", diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index ebcdd11bf1..1cb10575cd 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -86,7 +86,8 @@ def get_item_details(args, doc=None, for_validate=False, overwrite_warehouse=Tru get_party_item_code(args, item, out) - set_valuation_rate(out, args) + if args.get("doctype") in ["Sales Order", "Quotation"]: + set_valuation_rate(out, args) update_party_blanket_order(args, out) @@ -269,7 +270,9 @@ def get_basic_details(args, item, overwrite_warehouse=True): if not item: item = frappe.get_doc("Item", args.get("item_code")) - if item.variant_of and not item.taxes: + if ( + item.variant_of and not item.taxes and frappe.db.exists("Item Tax", {"parent": item.variant_of}) + ): item.update_template_tables() item_defaults = get_item_defaults(item.name, args.company) @@ -543,7 +546,7 @@ def get_item_tax_info(company, tax_category, item_codes, item_rates=None, item_t args = { "company": company, "tax_category": tax_category, - "net_rate": item_rates.get(item_code[1]), + "base_net_rate": item_rates.get(item_code[1]), } if item_tax_templates: @@ -635,7 +638,7 @@ def is_within_valid_range(args, tax): if not flt(tax.maximum_net_rate): # No range specified, just ignore return True - elif flt(tax.minimum_net_rate) <= flt(args.get("net_rate")) <= flt(tax.maximum_net_rate): + elif flt(tax.minimum_net_rate) <= flt(args.get("base_net_rate")) <= flt(tax.maximum_net_rate): return True return False diff --git a/erpnext/stock/reorder_item.py b/erpnext/stock/reorder_item.py index 276531ab5c..59f8b20b41 100644 --- a/erpnext/stock/reorder_item.py +++ b/erpnext/stock/reorder_item.py @@ -34,73 +34,157 @@ def _reorder_item(): erpnext.get_default_company() or frappe.db.sql("""select name from tabCompany limit 1""")[0][0] ) - items_to_consider = frappe.db.sql_list( - """select name from `tabItem` item - where is_stock_item=1 and has_variants=0 - and disabled=0 - and (end_of_life is null or end_of_life='0000-00-00' or end_of_life > %(today)s) - and (exists (select name from `tabItem Reorder` ir where ir.parent=item.name) - or (variant_of is not null and variant_of != '' - and exists (select name from `tabItem Reorder` ir where ir.parent=item.variant_of)) - )""", - {"today": nowdate()}, - ) + items_to_consider = get_items_for_reorder() if not items_to_consider: return item_warehouse_projected_qty = get_item_warehouse_projected_qty(items_to_consider) - def add_to_material_request( - item_code, warehouse, reorder_level, reorder_qty, material_request_type, warehouse_group=None - ): - if warehouse not in warehouse_company: + def add_to_material_request(**kwargs): + if isinstance(kwargs, dict): + kwargs = frappe._dict(kwargs) + + if kwargs.warehouse not in warehouse_company: # a disabled warehouse return - reorder_level = flt(reorder_level) - reorder_qty = flt(reorder_qty) + reorder_level = flt(kwargs.reorder_level) + reorder_qty = flt(kwargs.reorder_qty) # projected_qty will be 0 if Bin does not exist - if warehouse_group: - projected_qty = flt(item_warehouse_projected_qty.get(item_code, {}).get(warehouse_group)) + if kwargs.warehouse_group: + projected_qty = flt( + item_warehouse_projected_qty.get(kwargs.item_code, {}).get(kwargs.warehouse_group) + ) else: - projected_qty = flt(item_warehouse_projected_qty.get(item_code, {}).get(warehouse)) + projected_qty = flt( + item_warehouse_projected_qty.get(kwargs.item_code, {}).get(kwargs.warehouse) + ) if (reorder_level or reorder_qty) and projected_qty <= reorder_level: deficiency = reorder_level - projected_qty if deficiency > reorder_qty: reorder_qty = deficiency - company = warehouse_company.get(warehouse) or default_company + company = warehouse_company.get(kwargs.warehouse) or default_company - material_requests[material_request_type].setdefault(company, []).append( - {"item_code": item_code, "warehouse": warehouse, "reorder_qty": reorder_qty} + material_requests[kwargs.material_request_type].setdefault(company, []).append( + { + "item_code": kwargs.item_code, + "warehouse": kwargs.warehouse, + "reorder_qty": reorder_qty, + "item_details": kwargs.item_details, + } ) - for item_code in items_to_consider: - item = frappe.get_doc("Item", item_code) + for item_code, reorder_levels in items_to_consider.items(): + for d in reorder_levels: + if d.has_variants: + continue - if item.variant_of and not item.get("reorder_levels"): - item.update_template_tables() - - if item.get("reorder_levels"): - for d in item.get("reorder_levels"): - add_to_material_request( - item_code, - d.warehouse, - d.warehouse_reorder_level, - d.warehouse_reorder_qty, - d.material_request_type, - warehouse_group=d.warehouse_group, - ) + add_to_material_request( + item_code=item_code, + warehouse=d.warehouse, + reorder_level=d.warehouse_reorder_level, + reorder_qty=d.warehouse_reorder_qty, + material_request_type=d.material_request_type, + warehouse_group=d.warehouse_group, + item_details=frappe._dict( + { + "item_code": item_code, + "name": item_code, + "item_name": d.item_name, + "item_group": d.item_group, + "brand": d.brand, + "description": d.description, + "stock_uom": d.stock_uom, + "purchase_uom": d.purchase_uom, + } + ), + ) if material_requests: return create_material_request(material_requests) +def get_items_for_reorder() -> dict[str, list]: + reorder_table = frappe.qb.DocType("Item Reorder") + item_table = frappe.qb.DocType("Item") + + query = ( + frappe.qb.from_(reorder_table) + .inner_join(item_table) + .on(reorder_table.parent == item_table.name) + .select( + reorder_table.warehouse, + reorder_table.warehouse_group, + reorder_table.material_request_type, + reorder_table.warehouse_reorder_level, + reorder_table.warehouse_reorder_qty, + item_table.name, + item_table.stock_uom, + item_table.purchase_uom, + item_table.description, + item_table.item_name, + item_table.item_group, + item_table.brand, + item_table.variant_of, + item_table.has_variants, + ) + .where( + (item_table.disabled == 0) + & (item_table.is_stock_item == 1) + & ( + (item_table.end_of_life.isnull()) + | (item_table.end_of_life > nowdate()) + | (item_table.end_of_life == "0000-00-00") + ) + ) + ) + + data = query.run(as_dict=True) + itemwise_reorder = frappe._dict({}) + for d in data: + itemwise_reorder.setdefault(d.name, []).append(d) + + itemwise_reorder = get_reorder_levels_for_variants(itemwise_reorder) + + return itemwise_reorder + + +def get_reorder_levels_for_variants(itemwise_reorder): + item_table = frappe.qb.DocType("Item") + + query = ( + frappe.qb.from_(item_table) + .select( + item_table.name, + item_table.variant_of, + ) + .where( + (item_table.disabled == 0) + & (item_table.is_stock_item == 1) + & ( + (item_table.end_of_life.isnull()) + | (item_table.end_of_life > nowdate()) + | (item_table.end_of_life == "0000-00-00") + ) + & (item_table.variant_of.notnull()) + ) + ) + + variants_item = query.run(as_dict=True) + for row in variants_item: + if not itemwise_reorder.get(row.name) and itemwise_reorder.get(row.variant_of): + itemwise_reorder.setdefault(row.name, []).extend(itemwise_reorder.get(row.variant_of, [])) + + return itemwise_reorder + + def get_item_warehouse_projected_qty(items_to_consider): item_warehouse_projected_qty = {} + items_to_consider = list(items_to_consider.keys()) for item_code, warehouse, projected_qty in frappe.db.sql( """select item_code, warehouse, projected_qty @@ -164,7 +248,7 @@ def create_material_request(material_requests): for d in items: d = frappe._dict(d) - item = frappe.get_doc("Item", d.item_code) + item = d.get("item_details") uom = item.stock_uom conversion_factor = 1.0 @@ -190,6 +274,7 @@ def create_material_request(material_requests): "item_code": d.item_code, "schedule_date": add_days(nowdate(), cint(item.lead_time_days)), "qty": qty, + "conversion_factor": conversion_factor, "uom": uom, "stock_uom": item.stock_uom, "warehouse": d.warehouse, diff --git a/erpnext/stock/report/stock_balance/stock_balance.py b/erpnext/stock/report/stock_balance/stock_balance.py index ed84a5c2d5..269323810b 100644 --- a/erpnext/stock/report/stock_balance/stock_balance.py +++ b/erpnext/stock/report/stock_balance/stock_balance.py @@ -90,8 +90,7 @@ class StockBalanceReport(object): self.opening_data.setdefault(group_by_key, entry) def prepare_new_data(self): - if not self.sle_entries: - return + self.item_warehouse_map = self.get_item_warehouse_map() if self.filters.get("show_stock_ageing_data"): self.filters["show_warehouse_wise_stock"] = True @@ -99,7 +98,8 @@ class StockBalanceReport(object): _func = itemgetter(1) - self.item_warehouse_map = self.get_item_warehouse_map() + del self.sle_entries + sre_details = self.get_sre_reserved_qty_details() variant_values = {} @@ -143,15 +143,22 @@ class StockBalanceReport(object): item_warehouse_map = {} self.opening_vouchers = self.get_opening_vouchers() - for entry in self.sle_entries: - group_by_key = self.get_group_by_key(entry) - if group_by_key not in item_warehouse_map: - self.initialize_data(item_warehouse_map, group_by_key, entry) + if self.filters.get("show_stock_ageing_data"): + self.sle_entries = self.sle_query.run(as_dict=True) - self.prepare_item_warehouse_map(item_warehouse_map, entry, group_by_key) + with frappe.db.unbuffered_cursor(): + if not self.filters.get("show_stock_ageing_data"): + self.sle_entries = self.sle_query.run(as_dict=True, as_iterator=True) - if self.opening_data.get(group_by_key): - del self.opening_data[group_by_key] + for entry in self.sle_entries: + group_by_key = self.get_group_by_key(entry) + if group_by_key not in item_warehouse_map: + self.initialize_data(item_warehouse_map, group_by_key, entry) + + self.prepare_item_warehouse_map(item_warehouse_map, entry, group_by_key) + + if self.opening_data.get(group_by_key): + del self.opening_data[group_by_key] for group_by_key, entry in self.opening_data.items(): if group_by_key not in item_warehouse_map: @@ -252,7 +259,8 @@ class StockBalanceReport(object): .where( (table.docstatus == 1) & (table.company == self.filters.company) - & ((table.to_date <= self.from_date)) + & (table.to_date <= self.from_date) + & (table.status == "Completed") ) .orderby(table.to_date, order=Order.desc) .limit(1) @@ -305,7 +313,7 @@ class StockBalanceReport(object): if self.filters.get("company"): query = query.where(sle.company == self.filters.get("company")) - self.sle_entries = query.run(as_dict=True) + self.sle_query = query def apply_inventory_dimensions_filters(self, query, sle) -> str: inventory_dimension_fields = self.get_inventory_dimension_fields() diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index 4cfe5d817e..d8b5b34d44 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -283,6 +283,7 @@ class SerialBatchBundle: if (sn_table.purchase_document_no != self.sle.voucher_no and self.sle.is_cancelled != 1) else "Inactive", ) + .set(sn_table.company, self.sle.company) .where(sn_table.name.isin(serial_nos)) ).run() @@ -793,6 +794,9 @@ class SerialBatchCreation: setattr(self, "actual_qty", qty) self.__dict__["actual_qty"] = self.actual_qty + if not hasattr(self, "use_serial_batch_fields"): + setattr(self, "use_serial_batch_fields", 0) + def duplicate_package(self): if not self.serial_and_batch_bundle: return @@ -901,9 +905,14 @@ class SerialBatchCreation: self.batches = get_available_batches(kwargs) def set_auto_serial_batch_entries_for_inward(self): + print(self.get("serial_nos")) + if (self.get("batches") and self.has_batch_no) or ( self.get("serial_nos") and self.has_serial_no ): + if self.use_serial_batch_fields and self.get("serial_nos"): + self.make_serial_no_if_not_exists() + return self.batch_no = None @@ -915,6 +924,59 @@ class SerialBatchCreation: else: self.batches = frappe._dict({self.batch_no: abs(self.actual_qty)}) + def make_serial_no_if_not_exists(self): + non_exists_serial_nos = [] + for row in self.serial_nos: + if not frappe.db.exists("Serial No", row): + non_exists_serial_nos.append(row) + + if non_exists_serial_nos: + self.make_serial_nos(non_exists_serial_nos) + + def make_serial_nos(self, serial_nos): + serial_nos_details = [] + batch_no = None + if self.batches: + batch_no = list(self.batches.keys())[0] + + for serial_no in serial_nos: + serial_nos_details.append( + ( + serial_no, + serial_no, + now(), + now(), + frappe.session.user, + frappe.session.user, + self.warehouse, + self.company, + self.item_code, + self.item_name, + self.description, + "Active", + batch_no, + ) + ) + + if serial_nos_details: + fields = [ + "name", + "serial_no", + "creation", + "modified", + "owner", + "modified_by", + "warehouse", + "company", + "item_code", + "item_name", + "description", + "status", + "batch_no", + ] + + frappe.db.bulk_insert("Serial No", fields=fields, values=set(serial_nos_details)) + def set_serial_batch_entries(self, doc): if self.get("serial_nos"): serial_no_wise_batch = frappe._dict({}) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 45764f3ec0..e88b1921fa 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -897,9 +897,12 @@ class update_entries_after(object): self.wh_data.stock_value = round_off_if_near_zero(self.wh_data.stock_value + doc.total_amount) - self.wh_data.qty_after_transaction += doc.total_qty + precision = doc.precision("total_qty") + self.wh_data.qty_after_transaction += flt(doc.total_qty, precision) if self.wh_data.qty_after_transaction: - self.wh_data.valuation_rate = self.wh_data.stock_value / self.wh_data.qty_after_transaction + self.wh_data.valuation_rate = flt(self.wh_data.stock_value, precision) / flt( + self.wh_data.qty_after_transaction, precision + ) def validate_negative_stock(self, sle): """ diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py index 76af5d7e3e..9eac172aa7 100644 --- a/erpnext/stock/utils.py +++ b/erpnext/stock/utils.py @@ -11,6 +11,9 @@ from frappe.query_builder.functions import CombineDatetime, IfNull, Sum from frappe.utils import cstr, flt, get_link_to_form, nowdate, nowtime import erpnext +from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( + get_available_serial_nos, +) from erpnext.stock.doctype.warehouse.warehouse import get_child_warehouses from erpnext.stock.serial_batch_bundle import BatchNoValuation, SerialNoValuation from erpnext.stock.valuation import FIFOValuation, LIFOValuation @@ -125,7 +128,21 @@ def get_stock_balance( if with_valuation_rate: if with_serial_no: - serial_nos = get_serial_nos_data_after_transactions(args) + serial_no_details = get_available_serial_nos( + frappe._dict( + { + "item_code": item_code, + "warehouse": warehouse, + "posting_date": posting_date, + "posting_time": posting_time, + "ignore_warehouse": 1, + } + ) + ) + + serial_nos = "" + if serial_no_details: + serial_nos = "\n".join(d.serial_no for d in serial_no_details) return ( (last_entry.qty_after_transaction, last_entry.valuation_rate, serial_nos) @@ -140,38 +157,6 @@ def get_stock_balance( return last_entry.qty_after_transaction if last_entry else 0.0 -def get_serial_nos_data_after_transactions(args): - - serial_nos = set() - args = frappe._dict(args) - sle = frappe.qb.DocType("Stock Ledger Entry") - - stock_ledger_entries = ( - frappe.qb.from_(sle) - .select("serial_no", "actual_qty") - .where( - (sle.item_code == args.item_code) - & (sle.warehouse == args.warehouse) - & ( - CombineDatetime(sle.posting_date, sle.posting_time) - < CombineDatetime(args.posting_date, args.posting_time) - ) - & (sle.is_cancelled == 0) - ) - .orderby(sle.posting_date, sle.posting_time, sle.creation) - .run(as_dict=1) - ) - - for stock_ledger_entry in stock_ledger_entries: - changed_serial_no = get_serial_nos_data(stock_ledger_entry.serial_no) - if stock_ledger_entry.actual_qty > 0: - serial_nos.update(changed_serial_no) - else: - serial_nos.difference_update(changed_serial_no) - - return "\n".join(serial_nos) - - def get_serial_nos_data(serial_nos): from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py index 7c2a1f12e2..3467b8283c 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py @@ -149,6 +149,7 @@ class SubcontractingReceipt(SubcontractingController): self.update_prevdoc_status() self.set_subcontracting_order_status() self.set_consumed_qty_in_subcontract_order() + self.make_bundle_using_old_serial_batch_fields() self.update_stock_ledger() self.make_gl_entries() self.repost_future_sle_and_gle() diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.json b/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.json index 9bfc2fdb7a..f9e0a0b591 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.json +++ b/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.json @@ -48,11 +48,14 @@ "reference_name", "section_break_45", "serial_and_batch_bundle", - "serial_no", + "use_serial_batch_fields", "col_break5", "rejected_serial_and_batch_bundle", - "batch_no", + "section_break_jshh", + "serial_no", "rejected_serial_no", + "column_break_henr", + "batch_no", "manufacture_details", "manufacturer", "column_break_16", @@ -311,22 +314,20 @@ "label": "Serial and Batch Details" }, { - "depends_on": "eval:!doc.is_fixed_asset", + "depends_on": "eval:!doc.is_fixed_asset && doc.use_serial_batch_fields === 1", "fieldname": "serial_no", "fieldtype": "Small Text", "label": "Serial No", - "no_copy": 1, - "read_only": 1 + "no_copy": 1 }, { - "depends_on": "eval:!doc.is_fixed_asset", + "depends_on": "eval:!doc.is_fixed_asset && doc.use_serial_batch_fields === 1", "fieldname": "batch_no", "fieldtype": "Link", "label": "Batch No", "no_copy": 1, "options": "Batch", - "print_hide": 1, - "read_only": 1 + "print_hide": 1 }, { "depends_on": "eval: !parent.is_return", @@ -478,6 +479,7 @@ "label": "Accounting Details" }, { + "depends_on": "eval:doc.use_serial_batch_fields === 0 || doc.docstatus === 1", "fieldname": "serial_and_batch_bundle", "fieldtype": "Link", "label": "Serial and Batch Bundle", @@ -486,6 +488,7 @@ "print_hide": 1 }, { + "depends_on": "eval:doc.use_serial_batch_fields === 0 || doc.docstatus === 1", "fieldname": "rejected_serial_and_batch_bundle", "fieldtype": "Link", "label": "Rejected Serial and Batch Bundle", @@ -546,12 +549,27 @@ "fieldtype": "Check", "label": "Include Exploded Items", "print_hide": 1 + }, + { + "default": "0", + "fieldname": "use_serial_batch_fields", + "fieldtype": "Check", + "label": "Use Serial No / Batch Fields" + }, + { + "depends_on": "eval:doc.use_serial_batch_fields === 1", + "fieldname": "section_break_jshh", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_henr", + "fieldtype": "Column Break" } ], "idx": 1, "istable": 1, "links": [], - "modified": "2023-11-30 12:05:51.920705", + "modified": "2024-02-04 16:23:30.374865", "modified_by": "Administrator", "module": "Subcontracting", "name": "Subcontracting Receipt Item", diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.py b/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.py index d02160ece4..1a4ce5b977 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.py @@ -58,6 +58,7 @@ class SubcontractingReceiptItem(Document): subcontracting_order: DF.Link | None subcontracting_order_item: DF.Data | None subcontracting_receipt_item: DF.Data | None + use_serial_batch_fields: DF.Check warehouse: DF.Link | None # end: auto-generated types diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt_supplied_item/subcontracting_receipt_supplied_item.json b/erpnext/subcontracting/doctype/subcontracting_receipt_supplied_item/subcontracting_receipt_supplied_item.json index 90bcf4e544..957b6a2a65 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt_supplied_item/subcontracting_receipt_supplied_item.json +++ b/erpnext/subcontracting/doctype/subcontracting_receipt_supplied_item/subcontracting_receipt_supplied_item.json @@ -26,10 +26,13 @@ "current_stock", "secbreak_3", "serial_and_batch_bundle", - "batch_no", + "use_serial_batch_fields", "col_break4", + "subcontracting_order", + "section_break_zwnh", "serial_no", - "subcontracting_order" + "column_break_qibi", + "batch_no" ], "fields": [ { @@ -60,19 +63,19 @@ "width": "300px" }, { + "depends_on": "eval:doc.use_serial_batch_fields === 1", "fieldname": "batch_no", "fieldtype": "Link", "label": "Batch No", "no_copy": 1, - "options": "Batch", - "read_only": 1 + "options": "Batch" }, { + "depends_on": "eval:doc.use_serial_batch_fields === 1", "fieldname": "serial_no", "fieldtype": "Text", "label": "Serial No", - "no_copy": 1, - "read_only": 1 + "no_copy": 1 }, { "fieldname": "col_break1", @@ -198,6 +201,7 @@ }, { "columns": 2, + "depends_on": "eval:doc.use_serial_batch_fields === 0 || doc.docstatus === 1", "fieldname": "serial_and_batch_bundle", "fieldtype": "Link", "in_list_view": 1, @@ -205,12 +209,27 @@ "no_copy": 1, "options": "Serial and Batch Bundle", "print_hide": 1 + }, + { + "default": "0", + "fieldname": "use_serial_batch_fields", + "fieldtype": "Check", + "label": "Use Serial No / Batch Fields" + }, + { + "depends_on": "eval:doc.use_serial_batch_fields === 1", + "fieldname": "section_break_zwnh", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_qibi", + "fieldtype": "Column Break" } ], "idx": 1, "istable": 1, "links": [], - "modified": "2023-03-15 13:55:08.132626", + "modified": "2024-02-04 16:32:17.534162", "modified_by": "Administrator", "module": "Subcontracting", "name": "Subcontracting Receipt Supplied Item", diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt_supplied_item/subcontracting_receipt_supplied_item.py b/erpnext/subcontracting/doctype/subcontracting_receipt_supplied_item/subcontracting_receipt_supplied_item.py index 2ee55518d5..8f09197aa8 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt_supplied_item/subcontracting_receipt_supplied_item.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt_supplied_item/subcontracting_receipt_supplied_item.py @@ -35,6 +35,7 @@ class SubcontractingReceiptSuppliedItem(Document): serial_no: DF.Text | None stock_uom: DF.Link | None subcontracting_order: DF.Link | None + use_serial_batch_fields: DF.Check # end: auto-generated types pass diff --git a/pyproject.toml b/pyproject.toml index 604aa44585..8a0f12c5f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,3 +39,6 @@ force_grid_wrap = 0 use_parentheses = true ensure_newline_before_comments = true indent = "\t" + +[tool.bench.frappe-dependencies] +frappe = ">=16.0.0-dev,<17.0.0"