diff --git a/.github/workflows/patch.yml b/.github/workflows/patch.yml index 8bb44555206..8d29057b487 100644 --- a/.github/workflows/patch.yml +++ b/.github/workflows/patch.yml @@ -5,9 +5,14 @@ on: paths-ignore: - '**.js' - '**.md' + types: [opened, unlabeled, synchronize, reopened] workflow_dispatch: +concurrency: + group: patch-mariadb-v13-${{ github.event.number }} + cancel-in-progress: true + jobs: test: runs-on: ubuntu-18.04 @@ -25,6 +30,11 @@ jobs: options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3 steps: + - name: Check for merge conficts label + if: ${{ contains(github.event.pull_request.labels.*.name, 'conflicts') }} + run: | + echo "Remove merge conflicts and remove conflict label to run CI" + exit 1 - name: Clone uses: actions/checkout@v2 diff --git a/.github/workflows/server-tests.yml b/.github/workflows/server-tests.yml index 6d7324d623b..1c9743c5700 100644 --- a/.github/workflows/server-tests.yml +++ b/.github/workflows/server-tests.yml @@ -5,6 +5,7 @@ on: paths-ignore: - '**.js' - '**.md' + types: [opened, unlabeled, synchronize, reopened] workflow_dispatch: push: branches: [ develop ] @@ -12,6 +13,10 @@ on: - '**.js' - '**.md' +concurrency: + group: server-mariadb-v13-${{ github.event.number }} + cancel-in-progress: true + jobs: test: runs-on: ubuntu-18.04 @@ -35,6 +40,12 @@ jobs: options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3 steps: + - name: Check for merge conficts label + if: ${{ contains(github.event.pull_request.labels.*.name, 'conflicts') }} + run: | + echo "Remove merge conflicts and remove conflict label to run CI" + exit 1 + - name: Clone uses: actions/checkout@v2 @@ -89,39 +100,8 @@ jobs: run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh - name: Run Tests - run: cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --app erpnext --use-orchestrator --with-coverage + run: cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --app erpnext --use-orchestrator env: TYPE: server CI_BUILD_ID: ${{ github.run_id }} ORCHESTRATOR_URL: http://test-orchestrator.frappe.io - - - name: Upload Coverage Data - run: | - cp ~/frappe-bench/sites/.coverage ${GITHUB_WORKSPACE} - cd ${GITHUB_WORKSPACE} - pip3 install coverage==5.5 - pip3 install coveralls==3.0.1 - coveralls - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - COVERALLS_FLAG_NAME: run-${{ matrix.container }} - COVERALLS_SERVICE_NAME: ${{ github.event_name == 'pull_request' && 'github' || 'github-actions' }} - COVERALLS_PARALLEL: true - - coveralls: - name: Coverage Wrap Up - needs: test - container: python:3-slim - runs-on: ubuntu-18.04 - steps: - - name: Clone - uses: actions/checkout@v2 - - - name: Coveralls Finished - run: | - cd ${GITHUB_WORKSPACE} - pip3 install coverage==5.5 - pip3 install coveralls==3.0.1 - coveralls --finish - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index 5459e86123d..9f142bd2c2f 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -6,6 +6,10 @@ on: - '**.md' workflow_dispatch: +concurrency: + group: ui-v13-${{ github.event.number }} + cancel-in-progress: true + jobs: test: runs-on: ubuntu-18.04 diff --git a/erpnext/accounts/deferred_revenue.py b/erpnext/accounts/deferred_revenue.py index 7e270601fb5..4b1535e1000 100644 --- a/erpnext/accounts/deferred_revenue.py +++ b/erpnext/accounts/deferred_revenue.py @@ -255,11 +255,13 @@ def book_deferred_income_or_expense(doc, deferred_process, posting_date=None): enable_check = "enable_deferred_revenue" \ if doc.doctype=="Sales Invoice" else "enable_deferred_expense" + accounts_frozen_upto = frappe.get_cached_value('Accounts Settings', 'None', 'acc_frozen_upto') + def _book_deferred_revenue_or_expense(item, via_journal_entry, submit_journal_entry, book_deferred_entries_based_on): start_date, end_date, last_gl_entry = get_booking_dates(doc, item, posting_date=posting_date) if not (start_date and end_date): return - account_currency = get_account_currency(item.expense_account) + account_currency = get_account_currency(item.expense_account or item.income_account) if doc.doctype == "Sales Invoice": against, project = doc.customer, doc.project credit_account, debit_account = item.income_account, item.deferred_revenue_account @@ -280,6 +282,10 @@ def book_deferred_income_or_expense(doc, deferred_process, posting_date=None): if not amount: return + # check if books nor frozen till endate: + if getdate(end_date) >= getdate(accounts_frozen_upto): + end_date = get_last_day(add_days(accounts_frozen_upto, 1)) + if via_journal_entry: book_revenue_via_journal_entry(doc, credit_account, debit_account, against, amount, base_amount, end_date, project, account_currency, item.cost_center, item, deferred_process, submit_journal_entry) @@ -407,8 +413,6 @@ def book_revenue_via_journal_entry(doc, credit_account, debit_account, against, 'account': credit_account, 'credit': base_amount, 'credit_in_account_currency': amount, - 'party_type': 'Customer' if doc.doctype == 'Sales Invoice' else 'Supplier', - 'party': against, 'account_currency': account_currency, 'reference_name': doc.name, 'reference_type': doc.doctype, @@ -421,8 +425,6 @@ def book_revenue_via_journal_entry(doc, credit_account, debit_account, against, 'account': debit_account, 'debit': base_amount, 'debit_in_account_currency': amount, - 'party_type': 'Customer' if doc.doctype == 'Sales Invoice' else 'Supplier', - 'party': against, 'account_currency': account_currency, 'reference_name': doc.name, 'reference_type': doc.doctype, 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 c57e862892c..57434bdd829 100644 --- a/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py +++ b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py @@ -17,6 +17,7 @@ from openpyxl.styles import Font from openpyxl.utils import get_column_letter from six import string_types +INVALID_VALUES = ("", None) class BankStatementImport(DataImport): def __init__(self, *args, **kwargs): @@ -96,6 +97,18 @@ def download_errored_template(data_import_name): data_import = frappe.get_doc("Bank Statement Import", data_import_name) data_import.export_errored_rows() +def parse_data_from_template(raw_data): + data = [] + + for i, row in enumerate(raw_data): + if all(v in INVALID_VALUES for v in row): + # empty row + continue + + data.append(row) + + return data + def start_import(data_import, bank_account, import_file_path, google_sheets_url, bank, template_options): """This method runs in background job""" @@ -105,7 +118,8 @@ def start_import(data_import, bank_account, import_file_path, google_sheets_url, file = import_file_path if import_file_path else google_sheets_url import_file = ImportFile("Bank Transaction", file = file, import_type="Insert New Records") - data = import_file.raw_data + + data = parse_data_from_template(import_file.raw_data) if import_file_path: add_bank_account(data, bank_account) diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index 6ec2d4bda03..9c1710217dd 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -397,13 +397,14 @@ class JournalEntry(AccountsController): debit_or_credit = 'Debit' if d.debit else 'Credit' party_account = get_deferred_booking_accounts(d.reference_type, d.reference_detail_no, debit_or_credit) + against_voucher = ['', against_voucher[1]] else: if d.reference_type == "Sales Invoice": party_account = get_party_account_based_on_invoice_discounting(d.reference_name) or against_voucher[1] else: party_account = against_voucher[1] - if (against_voucher[0] != d.party or party_account != d.account): + if (against_voucher[0] != cstr(d.party) or party_account != d.account): frappe.throw(_("Row {0}: Party / Account does not match with {1} / {2} in {3} {4}") .format(d.idx, field_dict.get(d.reference_type)[0], field_dict.get(d.reference_type)[1], d.reference_type, d.reference_name)) @@ -468,13 +469,22 @@ class JournalEntry(AccountsController): def set_against_account(self): accounts_debited, accounts_credited = [], [] - for d in self.get("accounts"): - if flt(d.debit > 0): accounts_debited.append(d.party or d.account) - if flt(d.credit) > 0: accounts_credited.append(d.party or d.account) + if self.voucher_type in ('Deferred Revenue', 'Deferred Expense'): + for d in self.get('accounts'): + if d.reference_type == 'Sales Invoice': + field = 'customer' + else: + field = 'supplier' - for d in self.get("accounts"): - if flt(d.debit > 0): d.against_account = ", ".join(list(set(accounts_credited))) - if flt(d.credit > 0): d.against_account = ", ".join(list(set(accounts_debited))) + d.against_account = frappe.db.get_value(d.reference_type, d.reference_name, field) + else: + for d in self.get("accounts"): + if flt(d.debit > 0): accounts_debited.append(d.party or d.account) + if flt(d.credit) > 0: accounts_credited.append(d.party or d.account) + + for d in self.get("accounts"): + if flt(d.debit > 0): d.against_account = ", ".join(list(set(accounts_credited))) + if flt(d.credit > 0): d.against_account = ", ".join(list(set(accounts_debited))) def validate_debit_credit_amount(self): for d in self.get('accounts'): diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 61fa194aefd..80f61d4f02e 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -3,6 +3,7 @@ import json +from functools import reduce import frappe from frappe import ValidationError, _, scrub, throw @@ -1524,6 +1525,10 @@ def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount= pe.received_amount = received_amount pe.letter_head = doc.get("letter_head") + if dt in ['Purchase Order', 'Sales Order', 'Sales Invoice', 'Purchase Invoice']: + pe.project = (doc.get('project') or + reduce(lambda prev,cur: prev or cur, [x.get('project') for x in doc.get('items')], None)) # get first non-empty project from items + if pe.party_type in ["Customer", "Supplier"]: bank_account = get_party_bank_account(pe.party_type, pe.party) pe.set("bank_account", bank_account) @@ -1709,7 +1714,10 @@ def set_paid_amount_and_received_amount(dt, party_account_currency, bank, outsta def apply_early_payment_discount(paid_amount, received_amount, doc): total_discount = 0 - if doc.doctype in ['Sales Invoice', 'Purchase Invoice'] and doc.payment_schedule: + eligible_for_payments = ['Sales Order', 'Sales Invoice', 'Purchase Order', 'Purchase Invoice'] + has_payment_schedule = hasattr(doc, 'payment_schedule') and doc.payment_schedule + + if doc.doctype in eligible_for_payments and has_payment_schedule: for term in doc.payment_schedule: if not term.discounted_amount and term.discount and getdate(nowdate()) <= term.discount_date: if term.discount_type == 'Percentage': diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index d5961223358..de31b5c4136 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -354,7 +354,6 @@ class POSInvoice(SalesInvoice): if not for_validate and not self.customer: self.customer = profile.customer - self.ignore_pricing_rule = profile.ignore_pricing_rule self.account_for_change_amount = profile.get('account_for_change_amount') or self.account_for_change_amount self.set_warehouse = profile.get('warehouse') or self.set_warehouse diff --git a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py index 7d31e0aa195..56479a0b77d 100644 --- a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py @@ -556,6 +556,37 @@ class TestPOSInvoice(unittest.TestCase): batch.cancel() batch.delete() + def test_ignore_pricing_rule(self): + from erpnext.accounts.doctype.pricing_rule.test_pricing_rule import make_pricing_rule + + item_price = frappe.get_doc({ + 'doctype': 'Item Price', + 'item_code': '_Test Item', + 'price_list': '_Test Price List', + 'price_list_rate': '450', + }) + item_price.insert() + pr = make_pricing_rule(selling=1, priority=5, discount_percentage=10) + pr.save() + pos_inv = create_pos_invoice(qty=1, do_not_submit=1) + pos_inv.items[0].rate = 300 + pos_inv.save() + self.assertEquals(pos_inv.items[0].discount_percentage, 10) + # rate shouldn't change + self.assertEquals(pos_inv.items[0].rate, 405) + + pos_inv.ignore_pricing_rule = 1 + pos_inv.items[0].rate = 300 + pos_inv.save() + self.assertEquals(pos_inv.ignore_pricing_rule, 1) + # rate should change since pricing rules are ignored + self.assertEquals(pos_inv.items[0].rate, 300) + + item_price.delete() + pos_inv.delete() + pr.delete() + + def create_pos_invoice(**args): args = frappe._dict(args) pos_profile = None diff --git a/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py index 74e188471de..e9a018ec46c 100644 --- a/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py +++ b/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py @@ -652,7 +652,7 @@ def make_pricing_rule(**args): "rate": args.rate or 0.0, "margin_rate_or_amount": args.margin_rate_or_amount or 0.0, "condition": args.condition or '', - "priority": 1, + "priority": args.priority or 1, "discount_amount": args.discount_amount or 0.0, "apply_multiple_pricing_rules": args.apply_multiple_pricing_rules or 0 }) @@ -678,6 +678,8 @@ def make_pricing_rule(**args): if args.get(applicable_for): doc.db_set(applicable_for, args.get(applicable_for)) + return doc + def setup_pricing_rule_data(): if not frappe.db.exists('Campaign', '_Test Campaign'): frappe.get_doc({ diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index 33fbd748c7b..c5cc05749a7 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -503,11 +503,11 @@ class PurchaseInvoice(BuyingController): # Checked both rounding_adjustment and rounded_total # because rounded_total had value even before introcution of posting GLE based on rounded total grand_total = self.rounded_total if (self.rounding_adjustment and self.rounded_total) else self.grand_total + base_grand_total = flt(self.base_rounded_total if (self.base_rounding_adjustment and self.base_rounded_total) + else self.base_grand_total, self.precision("base_grand_total")) if grand_total and not self.is_internal_transfer(): # Did not use base_grand_total to book rounding loss gle - grand_total_in_company_currency = flt(grand_total * self.conversion_rate, - self.precision("grand_total")) gl_entries.append( self.get_gl_dict({ "account": self.credit_to, @@ -515,8 +515,8 @@ class PurchaseInvoice(BuyingController): "party": self.supplier, "due_date": self.due_date, "against": self.against_expense_account, - "credit": grand_total_in_company_currency, - "credit_in_account_currency": grand_total_in_company_currency \ + "credit": base_grand_total, + "credit_in_account_currency": base_grand_total \ if self.party_account_currency==self.company_currency else grand_total, "against_voucher": self.return_against if cint(self.is_return) and self.return_against else self.name, "against_voucher_type": self.doctype, diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json index 545abf77e6b..5062c1c807a 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json @@ -651,7 +651,7 @@ "hide_seconds": 1, "label": "Ignore Pricing Rule", "no_copy": 1, - "permlevel": 1, + "permlevel": 0, "print_hide": 1 }, { @@ -2038,7 +2038,7 @@ "link_fieldname": "consolidated_invoice" } ], - "modified": "2021-10-21 20:19:38.667508", + "modified": "2021-12-23 20:19:38.667508", "modified_by": "Administrator", "module": "Accounts", "name": "Sales Invoice", diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 176d47897d6..9077ee73b3a 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -45,6 +45,7 @@ from erpnext.setup.doctype.company.company import update_company_current_month_s from erpnext.stock.doctype.batch.batch import set_batch_nos from erpnext.stock.doctype.delivery_note.delivery_note import update_billed_amount_based_on_so from erpnext.stock.doctype.serial_no.serial_no import get_delivery_note_serial_no, get_serial_nos +from erpnext.stock.utils import calculate_mapped_packed_items_return form_grid_templates = { "items": "templates/form_grid/item_grid.html" @@ -745,8 +746,11 @@ class SalesInvoice(SellingController): def update_packing_list(self): if cint(self.update_stock) == 1: - from erpnext.stock.doctype.packed_item.packed_item import make_packing_list - make_packing_list(self) + if cint(self.is_return) and self.return_against: + calculate_mapped_packed_items_return(self) + else: + from erpnext.stock.doctype.packed_item.packed_item import make_packing_list + make_packing_list(self) else: self.set('packed_items', []) @@ -879,11 +883,11 @@ class SalesInvoice(SellingController): # Checked both rounding_adjustment and rounded_total # because rounded_total had value even before introcution of posting GLE based on rounded total grand_total = self.rounded_total if (self.rounding_adjustment and self.rounded_total) else self.grand_total + base_grand_total = flt(self.base_rounded_total if (self.base_rounding_adjustment and self.base_rounded_total) + else self.base_grand_total, self.precision("base_grand_total")) + if grand_total and not self.is_internal_transfer(): # Didnot use base_grand_total to book rounding loss gle - grand_total_in_company_currency = flt(grand_total * self.conversion_rate, - self.precision("grand_total")) - gl_entries.append( self.get_gl_dict({ "account": self.debit_to, @@ -891,8 +895,8 @@ class SalesInvoice(SellingController): "party": self.customer, "due_date": self.due_date, "against": self.against_income_account, - "debit": grand_total_in_company_currency, - "debit_in_account_currency": grand_total_in_company_currency \ + "debit": base_grand_total, + "debit_in_account_currency": base_grand_total \ if self.party_account_currency==self.company_currency else grand_total, "against_voucher": self.return_against if cint(self.is_return) and self.return_against else self.name, "against_voucher_type": self.doctype, diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index e2c1f79bd2e..0cda5a05b78 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -1789,47 +1789,6 @@ class TestSalesInvoice(unittest.TestCase): check_gl_entries(self, si.name, expected_gle, "2019-01-30") - def test_deferred_revenue_post_account_freeze_upto_by_admin(self): - frappe.set_user("Administrator") - - frappe.db.set_value('Accounts Settings', None, 'acc_frozen_upto', None) - frappe.db.set_value('Accounts Settings', None, 'frozen_accounts_modifier', None) - - deferred_account = create_account(account_name="Deferred Revenue", - parent_account="Current Liabilities - _TC", company="_Test Company") - - item = create_item("_Test Item for Deferred Accounting") - item.enable_deferred_revenue = 1 - item.deferred_revenue_account = deferred_account - item.no_of_months = 12 - item.save() - - si = create_sales_invoice(item=item.name, posting_date="2019-01-10", do_not_save=True) - si.items[0].enable_deferred_revenue = 1 - si.items[0].service_start_date = "2019-01-10" - si.items[0].service_end_date = "2019-03-15" - si.items[0].deferred_revenue_account = deferred_account - si.save() - si.submit() - - frappe.db.set_value('Accounts Settings', None, 'acc_frozen_upto', getdate('2019-01-31')) - frappe.db.set_value('Accounts Settings', None, 'frozen_accounts_modifier', 'System Manager') - - pda1 = frappe.get_doc(dict( - doctype='Process Deferred Accounting', - posting_date=nowdate(), - start_date="2019-01-01", - end_date="2019-03-31", - type="Income", - company="_Test Company" - )) - - pda1.insert() - self.assertRaises(frappe.ValidationError, pda1.submit) - - frappe.db.set_value('Accounts Settings', None, 'acc_frozen_upto', None) - frappe.db.set_value('Accounts Settings', None, 'frozen_accounts_modifier', None) - def test_fixed_deferred_revenue(self): deferred_account = create_account(account_name="Deferred Revenue", parent_account="Current Liabilities - _TC", company="_Test Company") @@ -2206,9 +2165,9 @@ class TestSalesInvoice(unittest.TestCase): asset.load_from_db() expected_values = [ - ["2020-06-30", 1311.48, 1311.48], - ["2021-06-30", 20000.0, 21311.48], - ["2021-09-30", 5041.1, 26352.58] + ["2020-06-30", 1366.12, 1366.12], + ["2021-06-30", 20000.0, 21366.12], + ["2021-09-30", 5041.1, 26407.22] ] for i, schedule in enumerate(asset.schedules): @@ -2256,12 +2215,12 @@ class TestSalesInvoice(unittest.TestCase): asset.load_from_db() expected_values = [ - ["2020-06-30", 1311.48, 1311.48, True], - ["2021-06-30", 20000.0, 21311.48, True], - ["2022-06-30", 20000.0, 41311.48, False], - ["2023-06-30", 20000.0, 61311.48, False], - ["2024-06-30", 20000.0, 81311.48, False], - ["2025-06-06", 18688.52, 100000.0, False] + ["2020-06-30", 1366.12, 1366.12, True], + ["2021-06-30", 20000.0, 21366.12, True], + ["2022-06-30", 20000.0, 41366.12, False], + ["2023-06-30", 20000.0, 61366.12, False], + ["2024-06-30", 20000.0, 81366.12, False], + ["2025-06-06", 18633.88, 100000.0, False] ] for i, schedule in enumerate(asset.schedules): @@ -2455,6 +2414,74 @@ class TestSalesInvoice(unittest.TestCase): frappe.db.set_value('Accounts Settings', None, 'over_billing_allowance', over_billing_allowance) + def test_multi_currency_deferred_revenue_via_journal_entry(self): + deferred_account = create_account(account_name="Deferred Revenue", + parent_account="Current Liabilities - _TC", company="_Test Company") + + acc_settings = frappe.get_single('Accounts Settings') + acc_settings.book_deferred_entries_via_journal_entry = 1 + acc_settings.submit_journal_entries = 1 + acc_settings.save() + + item = create_item("_Test Item for Deferred Accounting") + item.enable_deferred_expense = 1 + item.deferred_revenue_account = deferred_account + item.save() + + si = create_sales_invoice(customer='_Test Customer USD', currency='USD', + item=item.name, qty=1, rate=100, conversion_rate=60, do_not_save=True) + + si.set_posting_time = 1 + si.posting_date = '2019-01-01' + si.debit_to = '_Test Receivable USD - _TC' + si.items[0].enable_deferred_revenue = 1 + si.items[0].service_start_date = "2019-01-01" + si.items[0].service_end_date = "2019-03-30" + si.items[0].deferred_expense_account = deferred_account + si.save() + si.submit() + + frappe.db.set_value('Accounts Settings', None, 'acc_frozen_upto', getdate('2019-01-31')) + + pda1 = frappe.get_doc(dict( + doctype='Process Deferred Accounting', + posting_date=nowdate(), + start_date="2019-01-01", + end_date="2019-03-31", + type="Income", + company="_Test Company" + )) + + pda1.insert() + pda1.submit() + + expected_gle = [ + ["Sales - _TC", 0.0, 2089.89, "2019-01-28"], + [deferred_account, 2089.89, 0.0, "2019-01-28"], + ["Sales - _TC", 0.0, 1887.64, "2019-02-28"], + [deferred_account, 1887.64, 0.0, "2019-02-28"], + ["Sales - _TC", 0.0, 2022.47, "2019-03-15"], + [deferred_account, 2022.47, 0.0, "2019-03-15"] + ] + + gl_entries = gl_entries = frappe.db.sql("""select account, debit, credit, posting_date + from `tabGL Entry` + where voucher_type='Journal Entry' and voucher_detail_no=%s and posting_date <= %s + order by posting_date asc, account asc""", (si.items[0].name, si.posting_date), as_dict=1) + + for i, gle in enumerate(gl_entries): + self.assertEqual(expected_gle[i][0], gle.account) + self.assertEqual(expected_gle[i][1], gle.credit) + self.assertEqual(expected_gle[i][2], gle.debit) + self.assertEqual(getdate(expected_gle[i][3]), gle.posting_date) + + acc_settings = frappe.get_single('Accounts Settings') + acc_settings.book_deferred_entries_via_journal_entry = 0 + acc_settings.submit_journal_entriessubmit_journal_entries = 0 + acc_settings.save() + + frappe.db.set_value('Accounts Settings', None, 'acc_frozen_upto', None) + def get_sales_invoice_for_e_invoice(): si = make_sales_invoice_for_ewaybill() si.naming_series = 'INV-2020-.#####' diff --git a/erpnext/accounts/doctype/tax_withholding_rate/tax_withholding_rate.json b/erpnext/accounts/doctype/tax_withholding_rate/tax_withholding_rate.json index d2c505c6300..e032bb307b0 100644 --- a/erpnext/accounts/doctype/tax_withholding_rate/tax_withholding_rate.json +++ b/erpnext/accounts/doctype/tax_withholding_rate/tax_withholding_rate.json @@ -28,14 +28,14 @@ { "columns": 2, "fieldname": "single_threshold", - "fieldtype": "Currency", + "fieldtype": "Float", "in_list_view": 1, "label": "Single Transaction Threshold" }, { "columns": 3, "fieldname": "cumulative_threshold", - "fieldtype": "Currency", + "fieldtype": "Float", "in_list_view": 1, "label": "Cumulative Transaction Threshold" }, @@ -59,7 +59,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2021-08-31 11:42:12.213977", + "modified": "2022-01-13 12:04:42.904263", "modified_by": "Administrator", "module": "Accounts", "name": "Tax Withholding Rate", @@ -68,5 +68,6 @@ "quick_entry": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.js b/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.js index 305cddb102a..715cd6476e8 100644 --- a/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.js +++ b/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.js @@ -117,6 +117,11 @@ frappe.query_reports["Accounts Receivable Summary"] = { "label": __("Show Future Payments"), "fieldtype": "Check", }, + { + "fieldname":"show_gl_balance", + "label": __("Show GL Balance"), + "fieldtype": "Check", + }, ], onload: function(report) { diff --git a/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py b/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py index a95bcf83ef7..4559fa94a4a 100644 --- a/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py +++ b/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py @@ -4,7 +4,7 @@ import frappe from frappe import _, scrub -from frappe.utils import cint +from frappe.utils import cint, flt from six import iteritems from erpnext.accounts.party import get_partywise_advanced_payment_amount @@ -37,6 +37,9 @@ class AccountsReceivableSummary(ReceivablePayableReport): party_advance_amount = get_partywise_advanced_payment_amount(self.party_type, self.filters.report_date, self.filters.show_future_payments, self.filters.company) or {} + if self.filters.show_gl_balance: + gl_balance_map = get_gl_balance(self.filters.report_date) + for party, party_dict in iteritems(self.party_total): if party_dict.outstanding == 0: continue @@ -56,6 +59,10 @@ class AccountsReceivableSummary(ReceivablePayableReport): # but in summary report advance shown in separate column row.paid -= row.advance + if self.filters.show_gl_balance: + row.gl_balance = gl_balance_map.get(party) + row.diff = flt(row.outstanding) - flt(row.gl_balance) + self.data.append(row) def get_party_total(self, args): @@ -115,6 +122,10 @@ class AccountsReceivableSummary(ReceivablePayableReport): self.add_column(_(credit_debit_label), fieldname='credit_note') self.add_column(_('Outstanding Amount'), fieldname='outstanding') + if self.filters.show_gl_balance: + self.add_column(_('GL Balance'), fieldname='gl_balance') + self.add_column(_('Difference'), fieldname='diff') + self.setup_ageing_columns() if self.party_type == "Customer": @@ -141,3 +152,7 @@ class AccountsReceivableSummary(ReceivablePayableReport): # Add column for total due amount self.add_column(label="Total Amount Due", fieldname='total_due') + +def get_gl_balance(report_date): + return frappe._dict(frappe.db.get_all("GL Entry", fields=['party', 'sum(debit - credit)'], + filters={'posting_date': ("<=", report_date), 'is_cancelled': 0}, group_by='party', as_list=1)) diff --git a/erpnext/accounts/report/deferred_revenue_and_expense/deferred_revenue_and_expense.py b/erpnext/accounts/report/deferred_revenue_and_expense/deferred_revenue_and_expense.py index a4842c1844f..3a51db8a97f 100644 --- a/erpnext/accounts/report/deferred_revenue_and_expense/deferred_revenue_and_expense.py +++ b/erpnext/accounts/report/deferred_revenue_and_expense/deferred_revenue_and_expense.py @@ -121,20 +121,21 @@ class Deferred_Item(object): """ simulate future posting by creating dummy gl entries. starts from the last posting date. """ - if add_days(self.last_entry_date, 1) < self.period_list[-1].to_date: - self.estimate_for_period_list = get_period_list( - self.filters.from_fiscal_year, - self.filters.to_fiscal_year, - add_days(self.last_entry_date, 1), - self.period_list[-1].to_date, - "Date Range", - "Monthly", - company=self.filters.company, - ) - for period in self.estimate_for_period_list: - amount = self.calculate_amount(period.from_date, period.to_date) - gle = self.make_dummy_gle(period.key, period.to_date, amount) - self.gle_entries.append(gle) + if self.service_start_date != self.service_end_date: + if add_days(self.last_entry_date, 1) < self.period_list[-1].to_date: + self.estimate_for_period_list = get_period_list( + self.filters.from_fiscal_year, + self.filters.to_fiscal_year, + add_days(self.last_entry_date, 1), + self.period_list[-1].to_date, + "Date Range", + "Monthly", + company=self.filters.company, + ) + for period in self.estimate_for_period_list: + amount = self.calculate_amount(period.from_date, period.to_date) + gle = self.make_dummy_gle(period.key, period.to_date, amount) + self.gle_entries.append(gle) def calculate_item_revenue_expense_for_period(self): """ diff --git a/erpnext/accounts/report/general_ledger/general_ledger.js b/erpnext/accounts/report/general_ledger/general_ledger.js index b2968761c63..010284c2ea5 100644 --- a/erpnext/accounts/report/general_ledger/general_ledger.js +++ b/erpnext/accounts/report/general_ledger/general_ledger.js @@ -167,7 +167,7 @@ frappe.query_reports["General Ledger"] = { "fieldname": "include_dimensions", "label": __("Consider Accounting Dimensions"), "fieldtype": "Check", - "default": 0 + "default": 1 }, { "fieldname": "show_opening_entries", diff --git a/erpnext/accounts/report/general_ledger/general_ledger.py b/erpnext/accounts/report/general_ledger/general_ledger.py index 9aad52137b4..452a60d3055 100644 --- a/erpnext/accounts/report/general_ledger/general_ledger.py +++ b/erpnext/accounts/report/general_ledger/general_ledger.py @@ -449,9 +449,11 @@ def get_accountwise_gle(filters, accounting_dimensions, gl_entries, gle_map): elif group_by_voucher_consolidated: keylist = [gle.get("voucher_type"), gle.get("voucher_no"), gle.get("account")] - for dim in accounting_dimensions: - keylist.append(gle.get(dim)) - keylist.append(gle.get("cost_center")) + if filters.get("include_dimensions"): + for dim in accounting_dimensions: + keylist.append(gle.get(dim)) + keylist.append(gle.get("cost_center")) + key = tuple(keylist) if key not in consolidated_gle: consolidated_gle.setdefault(key, gle) @@ -595,14 +597,14 @@ def get_columns(filters): "fieldname": dim.fieldname, "width": 100 }) - - columns.extend([ - { + columns.append({ "label": _("Cost Center"), "options": "Cost Center", "fieldname": "cost_center", "width": 100 - }, + }) + + columns.extend([ { "label": _("Against Voucher Type"), "fieldname": "against_voucher_type", diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py index 90257c784ee..84929b5b4c2 100644 --- a/erpnext/assets/doctype/asset/asset.py +++ b/erpnext/assets/doctype/asset/asset.py @@ -186,83 +186,85 @@ class Asset(AccountsController): if not self.available_for_use_date: return - for d in self.get('finance_books'): - self.validate_asset_finance_books(d) + start = self.clear_depreciation_schedule() - start = self.clear_depreciation_schedule() + for finance_book in self.get('finance_books'): + self.validate_asset_finance_books(finance_book) # value_after_depreciation - current Asset value - if self.docstatus == 1 and d.value_after_depreciation: - value_after_depreciation = flt(d.value_after_depreciation) + if self.docstatus == 1 and finance_book.value_after_depreciation: + value_after_depreciation = flt(finance_book.value_after_depreciation) else: value_after_depreciation = (flt(self.gross_purchase_amount) - flt(self.opening_accumulated_depreciation)) - d.value_after_depreciation = value_after_depreciation + finance_book.value_after_depreciation = value_after_depreciation - number_of_pending_depreciations = cint(d.total_number_of_depreciations) - \ + number_of_pending_depreciations = cint(finance_book.total_number_of_depreciations) - \ cint(self.number_of_depreciations_booked) - has_pro_rata = self.check_is_pro_rata(d) + has_pro_rata = self.check_is_pro_rata(finance_book) if has_pro_rata: number_of_pending_depreciations += 1 skip_row = False - for n in range(start, number_of_pending_depreciations): + + for n in range(start[finance_book.idx-1], number_of_pending_depreciations): # If depreciation is already completed (for double declining balance) if skip_row: continue - depreciation_amount = get_depreciation_amount(self, value_after_depreciation, d) + depreciation_amount = get_depreciation_amount(self, value_after_depreciation, finance_book) if not has_pro_rata or n < cint(number_of_pending_depreciations) - 1: - schedule_date = add_months(d.depreciation_start_date, - n * cint(d.frequency_of_depreciation)) + schedule_date = add_months(finance_book.depreciation_start_date, + n * cint(finance_book.frequency_of_depreciation)) # schedule date will be a year later from start date # so monthly schedule date is calculated by removing 11 months from it - monthly_schedule_date = add_months(schedule_date, - d.frequency_of_depreciation + 1) + monthly_schedule_date = add_months(schedule_date, - finance_book.frequency_of_depreciation + 1) # if asset is being sold if date_of_sale: - from_date = self.get_from_date(d.finance_book) - depreciation_amount, days, months = self.get_pro_rata_amt(d, depreciation_amount, + from_date = self.get_from_date(finance_book.finance_book) + depreciation_amount, days, months = self.get_pro_rata_amt(finance_book, depreciation_amount, from_date, date_of_sale) if depreciation_amount > 0: self.append("schedules", { "schedule_date": date_of_sale, "depreciation_amount": depreciation_amount, - "depreciation_method": d.depreciation_method, - "finance_book": d.finance_book, - "finance_book_id": d.idx + "depreciation_method": finance_book.depreciation_method, + "finance_book": finance_book.finance_book, + "finance_book_id": finance_book.idx }) break # For first row if has_pro_rata and not self.opening_accumulated_depreciation and n==0: - depreciation_amount, days, months = self.get_pro_rata_amt(d, depreciation_amount, - self.available_for_use_date, d.depreciation_start_date) + from_date = add_days(self.available_for_use_date, -1) # needed to calc depr amount for available_for_use_date too + depreciation_amount, days, months = self.get_pro_rata_amt(finance_book, depreciation_amount, + from_date, finance_book.depreciation_start_date) # For first depr schedule date will be the start date # so monthly schedule date is calculated by removing month difference between use date and start date - monthly_schedule_date = add_months(d.depreciation_start_date, - months + 1) + monthly_schedule_date = add_months(finance_book.depreciation_start_date, - months + 1) # For last row elif has_pro_rata and n == cint(number_of_pending_depreciations) - 1: if not self.flags.increase_in_asset_life: # In case of increase_in_asset_life, the self.to_date is already set on asset_repair submission self.to_date = add_months(self.available_for_use_date, - (n + self.number_of_depreciations_booked) * cint(d.frequency_of_depreciation)) + (n + self.number_of_depreciations_booked) * cint(finance_book.frequency_of_depreciation)) depreciation_amount_without_pro_rata = depreciation_amount - depreciation_amount, days, months = self.get_pro_rata_amt(d, + depreciation_amount, days, months = self.get_pro_rata_amt(finance_book, depreciation_amount, schedule_date, self.to_date) depreciation_amount = self.get_adjusted_depreciation_amount(depreciation_amount_without_pro_rata, - depreciation_amount, d.finance_book) + depreciation_amount, finance_book.finance_book) monthly_schedule_date = add_months(schedule_date, 1) schedule_date = add_days(schedule_date, days) @@ -273,10 +275,10 @@ class Asset(AccountsController): self.precision("gross_purchase_amount")) # Adjust depreciation amount in the last period based on the expected value after useful life - if d.expected_value_after_useful_life and ((n == cint(number_of_pending_depreciations) - 1 - and value_after_depreciation != d.expected_value_after_useful_life) - or value_after_depreciation < d.expected_value_after_useful_life): - depreciation_amount += (value_after_depreciation - d.expected_value_after_useful_life) + if finance_book.expected_value_after_useful_life and ((n == cint(number_of_pending_depreciations) - 1 + and value_after_depreciation != finance_book.expected_value_after_useful_life) + or value_after_depreciation < finance_book.expected_value_after_useful_life): + depreciation_amount += (value_after_depreciation - finance_book.expected_value_after_useful_life) skip_row = True if depreciation_amount > 0: @@ -286,7 +288,7 @@ class Asset(AccountsController): # In pro rata case, for first and last depreciation, month range would be different month_range = months \ if (has_pro_rata and n==0) or (has_pro_rata and n == cint(number_of_pending_depreciations) - 1) \ - else d.frequency_of_depreciation + else finance_book.frequency_of_depreciation for r in range(month_range): if (has_pro_rata and n == 0): @@ -312,27 +314,52 @@ class Asset(AccountsController): self.append("schedules", { "schedule_date": date, "depreciation_amount": amount, - "depreciation_method": d.depreciation_method, - "finance_book": d.finance_book, - "finance_book_id": d.idx + "depreciation_method": finance_book.depreciation_method, + "finance_book": finance_book.finance_book, + "finance_book_id": finance_book.idx }) else: self.append("schedules", { "schedule_date": schedule_date, "depreciation_amount": depreciation_amount, - "depreciation_method": d.depreciation_method, - "finance_book": d.finance_book, - "finance_book_id": d.idx + "depreciation_method": finance_book.depreciation_method, + "finance_book": finance_book.finance_book, + "finance_book_id": finance_book.idx }) - # used when depreciation schedule needs to be modified due to increase in asset life + # depreciation schedules need to be cleared before modification due to increase in asset life/asset sales + # JE: Journal Entry, FB: Finance Book def clear_depreciation_schedule(self): - start = 0 - for n in range(len(self.schedules)): - if not self.schedules[n].journal_entry: - del self.schedules[n:] - start = n - break + start = [] + num_of_depreciations_completed = 0 + depr_schedule = [] + + for schedule in self.get('schedules'): + + # to update start when there are JEs linked with all the schedule rows corresponding to an FB + if len(start) == (int(schedule.finance_book_id) - 2): + start.append(num_of_depreciations_completed) + num_of_depreciations_completed = 0 + + # to ensure that start will only be updated once for each FB + if len(start) == (int(schedule.finance_book_id) - 1): + if schedule.journal_entry: + num_of_depreciations_completed += 1 + depr_schedule.append(schedule) + else: + start.append(num_of_depreciations_completed) + num_of_depreciations_completed = 0 + + # to update start when all the schedule rows corresponding to the last FB are linked with JEs + if len(start) == (len(self.finance_books) - 1): + start.append(num_of_depreciations_completed) + + # when the Depreciation Schedule is being created for the first time + if start == []: + start = [0] * len(self.finance_books) + else: + self.schedules = depr_schedule + return start def get_from_date(self, finance_book): @@ -349,7 +376,9 @@ class Asset(AccountsController): if from_date: return from_date - return self.available_for_use_date + + # since depr for available_for_use_date is not yet booked + return add_days(self.available_for_use_date, -1) # if it returns True, depreciation_amount will not be equal for the first and last rows def check_is_pro_rata(self, row): @@ -583,7 +612,17 @@ class Asset(AccountsController): return purchase_document def get_fixed_asset_account(self): - return get_asset_category_account('fixed_asset_account', None, self.name, None, self.asset_category, self.company) + fixed_asset_account = get_asset_category_account('fixed_asset_account', None, self.name, None, self.asset_category, self.company) + if not fixed_asset_account: + frappe.throw( + _("Set {0} in asset category {1} for company {2}").format( + frappe.bold("Fixed Asset Account"), + frappe.bold(self.asset_category), + frappe.bold(self.company), + ), + title=_("Account not Found"), + ) + return fixed_asset_account def get_cwip_account(self, cwip_enabled=False): cwip_account = None diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py index ba4dbee72da..0ddfb6c1c02 100644 --- a/erpnext/assets/doctype/asset/test_asset.py +++ b/erpnext/assets/doctype/asset/test_asset.py @@ -207,9 +207,9 @@ class TestAsset(AssetSetup): self.assertEqual(frappe.db.get_value("Asset", asset.name, "status"), "Sold") expected_gle = ( - ("_Test Accumulated Depreciations - _TC", 20392.16, 0.0), + ("_Test Accumulated Depreciations - _TC", 20490.2, 0.0), ("_Test Fixed Asset - _TC", 0.0, 100000.0), - ("_Test Gain/Loss on Asset Disposal - _TC", 54607.84, 0.0), + ("_Test Gain/Loss on Asset Disposal - _TC", 54509.8, 0.0), ("Debtors - _TC", 25000.0, 0.0) ) @@ -491,10 +491,10 @@ class TestDepreciationMethods(AssetSetup): ) expected_schedules = [ - ["2030-12-31", 27534.25, 27534.25], - ["2031-12-31", 30000.0, 57534.25], - ["2032-12-31", 30000.0, 87534.25], - ["2033-01-30", 2465.75, 90000.0] + ['2030-12-31', 27616.44, 27616.44], + ['2031-12-31', 30000.0, 57616.44], + ['2032-12-31', 30000.0, 87616.44], + ['2033-01-30', 2383.56, 90000.0] ] schedules = [[cstr(d.schedule_date), flt(d.depreciation_amount, 2), flt(d.accumulated_depreciation_amount, 2)] @@ -544,10 +544,10 @@ class TestDepreciationMethods(AssetSetup): self.assertEqual(asset.finance_books[0].rate_of_depreciation, 50.0) expected_schedules = [ - ["2030-12-31", 28493.15, 28493.15], - ["2031-12-31", 35753.43, 64246.58], - ["2032-12-31", 17876.71, 82123.29], - ["2033-06-06", 5376.71, 87500.0] + ['2030-12-31', 28630.14, 28630.14], + ['2031-12-31', 35684.93, 64315.07], + ['2032-12-31', 17842.47, 82157.54], + ['2033-06-06', 5342.46, 87500.0] ] schedules = [[cstr(d.schedule_date), flt(d.depreciation_amount, 2), flt(d.accumulated_depreciation_amount, 2)] @@ -580,10 +580,10 @@ class TestDepreciationMethods(AssetSetup): self.assertEqual(asset.finance_books[0].rate_of_depreciation, 50.0) expected_schedules = [ - ["2030-12-31", 11780.82, 11780.82], - ["2031-12-31", 44109.59, 55890.41], - ["2032-12-31", 22054.8, 77945.21], - ["2033-07-12", 9554.79, 87500.0] + ["2030-12-31", 11849.32, 11849.32], + ["2031-12-31", 44075.34, 55924.66], + ["2032-12-31", 22037.67, 77962.33], + ["2033-07-12", 9537.67, 87500.0] ] schedules = [[cstr(d.schedule_date), flt(d.depreciation_amount, 2), flt(d.accumulated_depreciation_amount, 2)] @@ -642,7 +642,7 @@ class TestDepreciationBasics(AssetSetup): asset = create_asset( item_code = "Macbook Pro", calculate_depreciation = 1, - available_for_use_date = getdate("2019-12-31"), + available_for_use_date = getdate("2020-01-01"), total_number_of_depreciations = 3, expected_value_after_useful_life = 10000, depreciation_start_date = getdate("2020-07-01"), @@ -653,7 +653,7 @@ class TestDepreciationBasics(AssetSetup): ["2020-07-01", 15000, 15000], ["2021-07-01", 30000, 45000], ["2022-07-01", 30000, 75000], - ["2022-12-31", 15000, 90000] + ["2023-01-01", 15000, 90000] ] for i, schedule in enumerate(asset.schedules): @@ -976,6 +976,82 @@ class TestDepreciationBasics(AssetSetup): self.assertEqual(len(asset.schedules), 1) + def test_clear_depreciation_schedule_for_multiple_finance_books(self): + asset = create_asset( + item_code = "Macbook Pro", + available_for_use_date = "2019-12-31", + do_not_save = 1 + ) + + asset.calculate_depreciation = 1 + asset.append("finance_books", { + "depreciation_method": "Straight Line", + "frequency_of_depreciation": 1, + "total_number_of_depreciations": 3, + "expected_value_after_useful_life": 10000, + "depreciation_start_date": "2020-01-31" + }) + asset.append("finance_books", { + "depreciation_method": "Straight Line", + "frequency_of_depreciation": 1, + "total_number_of_depreciations": 6, + "expected_value_after_useful_life": 10000, + "depreciation_start_date": "2020-01-31" + }) + asset.append("finance_books", { + "depreciation_method": "Straight Line", + "frequency_of_depreciation": 12, + "total_number_of_depreciations": 3, + "expected_value_after_useful_life": 10000, + "depreciation_start_date": "2020-12-31" + }) + asset.submit() + + post_depreciation_entries(date="2020-04-01") + asset.load_from_db() + + asset.clear_depreciation_schedule() + + self.assertEqual(len(asset.schedules), 6) + + for schedule in asset.schedules: + if schedule.idx <= 3: + self.assertEqual(schedule.finance_book_id, "1") + else: + self.assertEqual(schedule.finance_book_id, "2") + + def test_depreciation_schedules_are_set_up_for_multiple_finance_books(self): + asset = create_asset( + item_code = "Macbook Pro", + available_for_use_date = "2019-12-31", + do_not_save = 1 + ) + + asset.calculate_depreciation = 1 + asset.append("finance_books", { + "depreciation_method": "Straight Line", + "frequency_of_depreciation": 12, + "total_number_of_depreciations": 3, + "expected_value_after_useful_life": 10000, + "depreciation_start_date": "2020-12-31" + }) + asset.append("finance_books", { + "depreciation_method": "Straight Line", + "frequency_of_depreciation": 12, + "total_number_of_depreciations": 6, + "expected_value_after_useful_life": 10000, + "depreciation_start_date": "2020-12-31" + }) + asset.save() + + self.assertEqual(len(asset.schedules), 9) + + for schedule in asset.schedules: + if schedule.idx <= 3: + self.assertEqual(schedule.finance_book_id, 1) + else: + self.assertEqual(schedule.finance_book_id, 2) + def test_depreciation_entry_cancellation(self): asset = create_asset( item_code = "Macbook Pro", diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 1da8affb285..b7b198eac4c 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -114,7 +114,7 @@ class AccountsController(TransactionBase): _('{0} is blocked so this transaction cannot proceed').format(supplier_name), raise_exception=1) def validate(self): - if not self.get('is_return'): + if not self.get('is_return') and not self.get('is_debit_note'): self.validate_qty_is_not_zero() if self.get("_action") and self._action != "update_after_submit": @@ -191,8 +191,6 @@ class AccountsController(TransactionBase): frappe.throw(_("Row #{0}: Service Start Date cannot be greater than Service End Date").format(d.idx)) elif getdate(self.posting_date) > getdate(d.service_end_date): frappe.throw(_("Row #{0}: Service End Date cannot be before Invoice Posting Date").format(d.idx)) - elif getdate(self.posting_date) > getdate(d.service_start_date): - frappe.throw(_("Row #{0}: Service Start Date cannot be before Invoice Posting Date").format(d.idx)) def validate_invoice_documents_schedule(self): self.validate_payment_schedule_dates() diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py index cc773b75963..4ff851d7f94 100644 --- a/erpnext/controllers/selling_controller.py +++ b/erpnext/controllers/selling_controller.py @@ -385,7 +385,7 @@ class SellingController(StockController): # Get incoming rate based on original item cost based on valuation method qty = flt(d.get('stock_qty') or d.get('actual_qty')) - if not d.incoming_rate: + if not (self.get("is_return") and d.incoming_rate): d.incoming_rate = get_incoming_rate({ "item_code": d.item_code, "warehouse": d.warehouse, diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 7073e32f536..b97432e7485 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -17,7 +17,7 @@ from erpnext.accounts.general_ledger import ( from erpnext.accounts.utils import get_fiscal_year from erpnext.controllers.accounts_controller import AccountsController from erpnext.stock import get_warehouse_account_map -from erpnext.stock.stock_ledger import get_items_to_be_repost, get_valuation_rate +from erpnext.stock.stock_ledger import get_items_to_be_repost class QualityInspectionRequiredError(frappe.ValidationError): pass @@ -111,17 +111,6 @@ class StockController(AccountsController): self.check_expense_account(item_row) - # If the item does not have the allow zero valuation rate flag set - # and ( valuation rate not mentioned in an incoming entry - # or incoming entry not found while delivering the item), - # try to pick valuation rate from previous sle or Item master and update in SLE - # Otherwise, throw an exception - - if not sle.stock_value_difference and self.doctype != "Stock Reconciliation" \ - and not item_row.get("allow_zero_valuation_rate"): - - sle = self.update_stock_ledger_entries(sle) - # expense account/ target_warehouse / source_warehouse if item_row.get('target_warehouse'): warehouse = item_row.get('target_warehouse') @@ -164,26 +153,6 @@ class StockController(AccountsController): return frappe.flags.debit_field_precision - def update_stock_ledger_entries(self, sle): - sle.valuation_rate = get_valuation_rate(sle.item_code, sle.warehouse, - self.doctype, self.name, currency=self.company_currency, company=self.company) - - sle.stock_value = flt(sle.qty_after_transaction) * flt(sle.valuation_rate) - sle.stock_value_difference = flt(sle.actual_qty) * flt(sle.valuation_rate) - - if sle.name: - frappe.db.sql(""" - update - `tabStock Ledger Entry` - set - stock_value = %(stock_value)s, - valuation_rate = %(valuation_rate)s, - stock_value_difference = %(stock_value_difference)s - where - name = %(name)s""", (sle)) - - return sle - def get_voucher_details(self, default_expense_account, default_cost_center, sle_map): if self.doctype == "Stock Reconciliation": reconciliation_purpose = frappe.db.get_value(self.doctype, self.name, "purpose") @@ -287,11 +256,7 @@ class StockController(AccountsController): for d in self.items: if not d.batch_no: continue - serial_nos = [sr.name for sr in frappe.get_all("Serial No", - {'batch_no': d.batch_no, 'status': 'Inactive'})] - - if serial_nos: - frappe.db.set_value("Serial No", { 'name': ['in', serial_nos] }, "batch_no", None) + frappe.db.set_value("Serial No", {"batch_no": d.batch_no, "status": "Inactive"}, "batch_no", None) d.batch_no = None d.db_set("batch_no", None) diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index b9c95508152..02b1b3b1734 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -139,6 +139,8 @@ class calculate_taxes_and_totals(object): if not item.qty and self.doc.get("is_return"): item.amount = flt(-1 * item.rate, item.precision("amount")) + elif not item.qty and self.doc.get("is_debit_note"): + item.amount = flt(item.rate, item.precision("amount")) else: item.amount = flt(item.rate * item.qty, item.precision("amount")) @@ -594,13 +596,14 @@ class calculate_taxes_and_totals(object): if self.doc.doctype in ["Sales Invoice", "Purchase Invoice"]: grand_total = self.doc.rounded_total or self.doc.grand_total + base_grand_total = self.doc.base_rounded_total or self.doc.base_grand_total + if self.doc.party_account_currency == self.doc.currency: total_amount_to_pay = flt(grand_total - self.doc.total_advance - flt(self.doc.write_off_amount), self.doc.precision("grand_total")) else: - total_amount_to_pay = flt(flt(grand_total * - self.doc.conversion_rate, self.doc.precision("grand_total")) - self.doc.total_advance - - flt(self.doc.base_write_off_amount), self.doc.precision("grand_total")) + total_amount_to_pay = flt(flt(base_grand_total, self.doc.precision("base_grand_total")) - self.doc.total_advance + - flt(self.doc.base_write_off_amount), self.doc.precision("base_grand_total")) self.doc.round_floats_in(self.doc, ["paid_amount"]) change_amount = 0 diff --git a/erpnext/controllers/tests/test_queries.py b/erpnext/controllers/tests/test_queries.py index 05541d16887..908d78c15bf 100644 --- a/erpnext/controllers/tests/test_queries.py +++ b/erpnext/controllers/tests/test_queries.py @@ -1,6 +1,8 @@ import unittest from functools import partial +import frappe + from erpnext.controllers import queries @@ -85,3 +87,6 @@ class TestQueries(unittest.TestCase): wh = query(filters=[["Bin", "item_code", "=", "_Test Item"]]) self.assertGreaterEqual(len(wh), 1) + + def test_default_uoms(self): + self.assertGreaterEqual(frappe.db.count("UOM", {"enabled": 1}), 10) diff --git a/erpnext/controllers/tests/test_transaction_base.py b/erpnext/controllers/tests/test_transaction_base.py index 13aa697610e..f4d3f97ef0d 100644 --- a/erpnext/controllers/tests/test_transaction_base.py +++ b/erpnext/controllers/tests/test_transaction_base.py @@ -4,19 +4,72 @@ import frappe class TestUtils(unittest.TestCase): - def test_reset_default_field_value(self): - doc = frappe.get_doc({ - "doctype": "Purchase Receipt", - "set_warehouse": "Warehouse 1", - }) + def test_reset_default_field_value(self): + doc = frappe.get_doc({ + "doctype": "Purchase Receipt", + "set_warehouse": "Warehouse 1", + }) - # Same values - doc.items = [{"warehouse": "Warehouse 1"}, {"warehouse": "Warehouse 1"}, {"warehouse": "Warehouse 1"}] - doc.reset_default_field_value("set_warehouse", "items", "warehouse") - self.assertEqual(doc.set_warehouse, "Warehouse 1") + # Same values + doc.items = [{"warehouse": "Warehouse 1"}, {"warehouse": "Warehouse 1"}, {"warehouse": "Warehouse 1"}] + doc.reset_default_field_value("set_warehouse", "items", "warehouse") + self.assertEqual(doc.set_warehouse, "Warehouse 1") - # Mixed values - doc.items = [{"warehouse": "Warehouse 1"}, {"warehouse": "Warehouse 2"}, {"warehouse": "Warehouse 1"}] - doc.reset_default_field_value("set_warehouse", "items", "warehouse") - self.assertEqual(doc.set_warehouse, None) + # Mixed values + doc.items = [{"warehouse": "Warehouse 1"}, {"warehouse": "Warehouse 2"}, {"warehouse": "Warehouse 1"}] + doc.reset_default_field_value("set_warehouse", "items", "warehouse") + self.assertEqual(doc.set_warehouse, None) + def test_reset_default_field_value_in_mfg_stock_entry(self): + # manufacture stock entry with rows having blank source/target wh + se = frappe.get_doc( + doctype="Stock Entry", + purpose="Manufacture", + stock_entry_type="Manufacture", + company="_Test Company", + from_warehouse="_Test Warehouse - _TC", + to_warehouse="_Test Warehouse 1 - _TC", + items=[ + frappe._dict(item_code="_Test Item", qty=1, basic_rate=200, s_warehouse="_Test Warehouse - _TC"), + frappe._dict(item_code="_Test FG Item", qty=4, t_warehouse="_Test Warehouse 1 - _TC", is_finished_item=1) + ] + ) + se.save() + + # default fields must be untouched + self.assertEqual(se.from_warehouse, "_Test Warehouse - _TC") + self.assertEqual(se.to_warehouse, "_Test Warehouse 1 - _TC") + + se.delete() + + def test_reset_default_field_value_in_transfer_stock_entry(self): + doc = frappe.get_doc({ + "doctype": "Stock Entry", + "purpose": "Material Receipt", + "from_warehouse": "Warehouse 1", + "to_warehouse": "Warehouse 2", + }) + + # Same values + doc.items = [ + {"s_warehouse": "Warehouse 1", "t_warehouse": "Warehouse 2"}, + {"s_warehouse": "Warehouse 1", "t_warehouse": "Warehouse 2"}, + {"s_warehouse": "Warehouse 1", "t_warehouse": "Warehouse 2"} + ] + + doc.reset_default_field_value("from_warehouse", "items", "s_warehouse") + doc.reset_default_field_value("to_warehouse", "items", "t_warehouse") + self.assertEqual(doc.from_warehouse, "Warehouse 1") + self.assertEqual(doc.to_warehouse, "Warehouse 2") + + # Mixed values in source wh + doc.items = [ + {"s_warehouse": "Warehouse 1", "t_warehouse": "Warehouse 2"}, + {"s_warehouse": "Warehouse 3", "t_warehouse": "Warehouse 2"}, + {"s_warehouse": "Warehouse 1", "t_warehouse": "Warehouse 2"} + ] + + doc.reset_default_field_value("from_warehouse", "items", "s_warehouse") + doc.reset_default_field_value("to_warehouse", "items", "t_warehouse") + self.assertEqual(doc.from_warehouse, None) + self.assertEqual(doc.to_warehouse, "Warehouse 2") \ No newline at end of file diff --git a/erpnext/e_commerce/doctype/website_item/website_item.py b/erpnext/e_commerce/doctype/website_item/website_item.py index 2e60dfd9459..181aaa1292e 100644 --- a/erpnext/e_commerce/doctype/website_item/website_item.py +++ b/erpnext/e_commerce/doctype/website_item/website_item.py @@ -1,7 +1,6 @@ # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt -import itertools import json import frappe @@ -203,16 +202,15 @@ class WebsiteItem(WebsiteGenerator): context.body_class = "product-page" context.parents = get_parent_item_groups(self.item_group, from_item=True) # breadcumbs - self.attributes = frappe.get_all("Item Variant Attribute", + self.attributes = frappe.get_all( + "Item Variant Attribute", fields=["attribute", "attribute_value"], - filters={"parent": self.item_code}) + filters={"parent": self.item_code} + ) if self.slideshow: context.update(get_slideshow(self)) - self.set_variant_context(context) - self.set_attribute_context(context) - self.set_disabled_attributes(context) self.set_metatags(context) self.set_shopping_cart_data(context) @@ -237,61 +235,6 @@ class WebsiteItem(WebsiteGenerator): return context - def set_variant_context(self, context): - if not self.has_variants: - return - - context.no_cache = True - variant = frappe.form_dict.variant - - # load variants - # also used in set_attribute_context - context.variants = frappe.get_all( - "Item", - filters={ - "variant_of": self.item_code, - "published_in_website": 1 - }, - order_by="name asc") - - # the case when the item is opened for the first time from its list - if not variant and context.variants: - variant = context.variants[0] - - if variant: - context.variant = frappe.get_doc("Item", variant) - fields = ("website_image", "website_image_alt", "web_long_description", "description", - "website_specifications") - - for fieldname in fields: - if context.variant.get(fieldname): - value = context.variant.get(fieldname) - if isinstance(value, list): - value = [d.as_dict() for d in value] - - context[fieldname] = value - - if self.slideshow and context.variant and context.variant.slideshow: - context.update(get_slideshow(context.variant)) - - - def set_attribute_context(self, context): - if not self.has_variants: - return - - attribute_values_available = {} - context.attribute_values = {} - context.selected_attributes = {} - - # load attributes - self.set_selected_attributes(context.variants, context, attribute_values_available) - - # filter attributes, order based on attribute table - item = frappe.get_cached_doc("Item", self.item_code) - self.set_attribute_values(item.attributes, context, attribute_values_available) - - context.variant_info = json.dumps(context.variants) - def set_selected_attributes(self, variants, context, attribute_values_available): for variant in variants: variant.attributes = frappe.get_all( @@ -328,50 +271,6 @@ class WebsiteItem(WebsiteGenerator): if attr_value.attribute_value in attribute_values_available.get(attr.attribute, []): values.append(attr_value.attribute_value) - def set_disabled_attributes(self, context): - """Disable selection options of attribute combinations that do not result in a variant""" - - if not self.attributes or not self.has_variants: - return - - context.disabled_attributes = {} - attributes = [attr.attribute for attr in self.attributes] - - def find_variant(combination): - for variant in context.variants: - if len(variant.attributes) < len(attributes): - continue - - if "combination" not in variant: - ref_combination = [] - - for attr in variant.attributes: - idx = attributes.index(attr.attribute) - ref_combination.insert(idx, attr.attribute_value) - - variant["combination"] = ref_combination - - if not (set(combination) - set(variant["combination"])): - # check if the combination is a subset of a variant combination - # eg. [Blue, 0.5] is a possible combination if exists [Blue, Large, 0.5] - return True - - for i, attr in enumerate(self.attributes): - if i == 0: - continue - - combination_source = [] - - # loop through previous attributes - for prev_attr in self.attributes[:i]: - combination_source.append([context.selected_attributes.get(prev_attr.attribute)]) - - combination_source.append(context.attribute_values[attr.attribute]) - - for combination in itertools.product(*combination_source): - if not find_variant(combination): - context.disabled_attributes.setdefault(attr.attribute, []).append(combination[-1]) - def set_metatags(self, context): context.metatags = frappe._dict({}) diff --git a/erpnext/e_commerce/product_data_engine/query.py b/erpnext/e_commerce/product_data_engine/query.py index ddd9987bb92..b927b0146e1 100644 --- a/erpnext/e_commerce/product_data_engine/query.py +++ b/erpnext/e_commerce/product_data_engine/query.py @@ -197,7 +197,10 @@ class ProductQuery: website_item_groups = frappe.db.get_all( "Website Item", fields=self.fields + ["`tabWebsite Item Group`.parent as wig_parent"], - filters=[["Website Item Group", "item_group", "=", item_group]] + filters=[ + ["Website Item Group", "item_group", "=", item_group], + ["published", "=", 1] + ] ) return website_item_groups diff --git a/erpnext/e_commerce/shopping_cart/cart.py b/erpnext/e_commerce/shopping_cart/cart.py index 03aa36fa883..7a509cab6b0 100644 --- a/erpnext/e_commerce/shopping_cart/cart.py +++ b/erpnext/e_commerce/shopping_cart/cart.py @@ -276,10 +276,29 @@ def guess_territory(): def decorate_quotation_doc(doc): for d in doc.get("items", []): + item_code = d.item_code + fields = ["web_item_name", "thumbnail", "website_image", "description", "route"] + + # Variant Item + if not frappe.db.exists("Website Item", {"item_code": item_code}): + variant_data = frappe.db.get_values( + "Item", + filters={"item_code": item_code}, + fieldname=["variant_of", "item_name", "image"], + as_dict=True + )[0] + item_code = variant_data.variant_of + fields = fields[1:] + d.website_item_name = variant_data.item_name + + if variant_data.image: # get image from variant or template web item + d.thumbnail = variant_data.image + fields = fields[2:] + d.update(frappe.db.get_value( "Website Item", - {"item_code": d.item_code}, - ["web_item_name", "thumbnail", "website_image", "description", "route"], + {"item_code": item_code}, + fields, as_dict=True) ) diff --git a/erpnext/e_commerce/shopping_cart/test_shopping_cart.py b/erpnext/e_commerce/shopping_cart/test_shopping_cart.py index 784f869e579..4e14068c647 100644 --- a/erpnext/e_commerce/shopping_cart/test_shopping_cart.py +++ b/erpnext/e_commerce/shopping_cart/test_shopping_cart.py @@ -9,8 +9,13 @@ from frappe.utils import add_months, nowdate from erpnext.accounts.doctype.tax_rule.tax_rule import ConflictingTaxRule from erpnext.e_commerce.doctype.website_item.website_item import make_website_item -from erpnext.e_commerce.shopping_cart.cart import _get_cart_quotation, get_party, update_cart -from erpnext.tests.utils import create_test_contact_and_address +from erpnext.e_commerce.shopping_cart.cart import ( + _get_cart_quotation, + get_cart_quotation, + get_party, + update_cart, +) +from erpnext.tests.utils import change_settings, create_test_contact_and_address class TestShoppingCart(unittest.TestCase): @@ -34,6 +39,7 @@ class TestShoppingCart(unittest.TestCase): make_website_item(frappe.get_cached_doc("Item", "_Test Item 2")) def tearDown(self): + frappe.db.rollback() frappe.set_user("Administrator") self.disable_shopping_cart() @@ -128,6 +134,40 @@ class TestShoppingCart(unittest.TestCase): self.remove_test_quotation(quotation) + @change_settings("E Commerce Settings",{ + "company": "_Test Company", + "enabled": 1, + "default_customer_group": "_Test Customer Group", + "price_list": "_Test Price List India", + "show_price": 1 + }) + def test_add_item_variant_without_web_item_to_cart(self): + "Test adding Variants having no Website Items in cart via Template Web Item." + from erpnext.controllers.item_variant import create_variant + from erpnext.e_commerce.doctype.website_item.website_item import make_website_item + from erpnext.stock.doctype.item.test_item import make_item + + template_item = make_item("Test-Tshirt-Temp", { + "has_variant": 1, + "variant_based_on": "Item Attribute", + "attributes": [ + {"attribute": "Test Size"}, + {"attribute": "Test Colour"} + ] + }) + variant = create_variant("Test-Tshirt-Temp", { + "Test Size": "Small", "Test Colour": "Red" + }) + variant.save() + make_website_item(template_item) # publish template not variant + + update_cart("Test-Tshirt-Temp-S-R", 1) + + cart = get_cart_quotation() # test if cart page gets data without errors + doc = cart.get("doc") + + self.assertEqual(doc.get("items")[0].item_name, "Test-Tshirt-Temp-S-R") + def create_tax_rule(self): tax_rule = frappe.get_test_records("Tax Rule")[0] try: diff --git a/erpnext/e_commerce/variant_selector/item_variants_cache.py b/erpnext/e_commerce/variant_selector/item_variants_cache.py index 39eb9155d5e..bb6b3ef37fe 100644 --- a/erpnext/e_commerce/variant_selector/item_variants_cache.py +++ b/erpnext/e_commerce/variant_selector/item_variants_cache.py @@ -44,7 +44,7 @@ class ItemVariantsCacheManager: val = frappe.cache().get_value('ordered_attribute_values_map') if val: return val - all_attribute_values = frappe.db.get_all('Item Attribute Value', + all_attribute_values = frappe.get_all('Item Attribute Value', ['attribute_value', 'idx', 'parent'], order_by='idx asc') ordered_attribute_values_map = frappe._dict({}) @@ -57,25 +57,34 @@ class ItemVariantsCacheManager: def build_cache(self): parent_item_code = self.item_code - attributes = [a.attribute for a in frappe.db.get_all('Item Variant Attribute', - {'parent': parent_item_code}, ['attribute'], order_by='idx asc') + attributes = [ + a.attribute for a in frappe.get_all( + 'Item Variant Attribute', + {'parent': parent_item_code}, + ['attribute'], + order_by='idx asc' + ) ] - item_variants_data = frappe.db.get_all('Item Variant Attribute', - {'variant_of': parent_item_code}, ['parent', 'attribute', 'attribute_value'], + # join with Website Item + item_variants_data = frappe.get_all( + 'Item Variant Attribute', + {'variant_of': parent_item_code}, + ['parent', 'attribute', 'attribute_value'], order_by='name', as_list=1 ) - unpublished_items = set([i.item_code for i in frappe.db.get_all('Website Item', filters={'published': 0}, fields=["item_code"])]) + disabled_items = set( + [i.name for i in frappe.db.get_all('Item', {'disabled': 1})] + ) - attribute_value_item_map = frappe._dict({}) - item_attribute_value_map = frappe._dict({}) + attribute_value_item_map = frappe._dict() + item_attribute_value_map = frappe._dict() - # dont consider variants that are unpublished - # (either have no Website Item or are unpublished in Website Item) - item_variants_data = [r for r in item_variants_data if r[0] not in unpublished_items] - item_variants_data = [r for r in item_variants_data if frappe.db.exists("Website Item", {"item_code": r[0]})] + # dont consider variants that are disabled + # pull all other variants + item_variants_data = [r for r in item_variants_data if r[0] not in disabled_items] for row in item_variants_data: item_code, attribute, attribute_value = row diff --git a/erpnext/e_commerce/variant_selector/test_variant_selector.py b/erpnext/e_commerce/variant_selector/test_variant_selector.py index c70fee5fff5..83f08733612 100644 --- a/erpnext/e_commerce/variant_selector/test_variant_selector.py +++ b/erpnext/e_commerce/variant_selector/test_variant_selector.py @@ -1,11 +1,114 @@ -# import frappe import unittest -# from erpnext.e_commerce.product_data_engine.query import ProductQuery -# from erpnext.e_commerce.doctype.website_item.website_item import make_website_item +import frappe + +from erpnext.controllers.item_variant import create_variant +from erpnext.e_commerce.doctype.website_item.website_item import make_website_item +from erpnext.e_commerce.variant_selector.utils import get_next_attribute_and_values +from erpnext.stock.doctype.item.test_item import make_item +from erpnext.tests.utils import ERPNextTestCase, change_settings test_dependencies = ["Item"] -class TestVariantSelector(unittest.TestCase): - # TODO: Variant Selector Tests - pass \ No newline at end of file +class TestVariantSelector(ERPNextTestCase): + + @classmethod + def setUpClass(cls): + template_item = make_item("Test-Tshirt-Temp", { + "has_variant": 1, + "variant_based_on": "Item Attribute", + "attributes": [ + {"attribute": "Test Size"}, + {"attribute": "Test Colour"} + ] + }) + + # create L-R, L-G, M-R, M-G and S-R + for size in ("Large", "Medium",): + for colour in ("Red", "Green",): + variant = create_variant("Test-Tshirt-Temp", { + "Test Size": size, "Test Colour": colour + }) + variant.save() + + variant = create_variant("Test-Tshirt-Temp", { + "Test Size": "Small", "Test Colour": "Red" + }) + variant.save() + + make_website_item(template_item) # publish template not variants + + def test_item_attributes(self): + """ + Test if the right attributes are fetched in the popup. + (Attributes must only come from active items) + + Attribute selection must not be linked to Website Items. + """ + from erpnext.e_commerce.variant_selector.utils import get_attributes_and_values + + attr_data = get_attributes_and_values("Test-Tshirt-Temp") + + self.assertEqual(attr_data[0]["attribute"], "Test Size") + self.assertEqual(attr_data[1]["attribute"], "Test Colour") + self.assertEqual(len(attr_data[0]["values"]), 3) # ['Small', 'Medium', 'Large'] + self.assertEqual(len(attr_data[1]["values"]), 2) # ['Red', 'Green'] + + # disable small red tshirt, now there are no small tshirts. + # but there are some red tshirts + small_variant = frappe.get_doc("Item", "Test-Tshirt-Temp-S-R") + small_variant.disabled = 1 + small_variant.save() # trigger cache rebuild + + attr_data = get_attributes_and_values("Test-Tshirt-Temp") + + # Only L and M attribute values must be fetched since S is disabled + self.assertEqual(len(attr_data[0]["values"]), 2) # ['Medium', 'Large'] + + # teardown + small_variant.disabled = 0 + small_variant.save() + + def test_next_item_variant_values(self): + """ + Test if on selecting an attribute value, the next possible values + are filtered accordingly. + Values that dont apply should not be fetched. + E.g. + There is a ** Small-Red ** Tshirt. No other colour in this size. + On selecting ** Small **, only ** Red ** should be selectable next. + """ + next_values = get_next_attribute_and_values("Test-Tshirt-Temp", selected_attributes={"Test Size": "Small"}) + next_colours = next_values["valid_options_for_attributes"]["Test Colour"] + filtered_items = next_values["filtered_items"] + + self.assertEqual(len(next_colours), 1) + self.assertEqual(next_colours.pop(), "Red") + self.assertEqual(len(filtered_items), 1) + self.assertEqual(filtered_items.pop(), "Test-Tshirt-Temp-S-R") + + @change_settings("E Commerce Settings",{ + "company": "_Test Company", + "enabled": 1, + "default_customer_group": "_Test Customer Group", + "price_list": "_Test Price List India", + "show_price": 1 + }) + def test_exact_match_with_price(self): + """ + Test price fetching and matching of variant without Website Item + """ + from erpnext.e_commerce.doctype.website_item.test_website_item import make_web_item_price + + frappe.set_user("Administrator") + make_web_item_price(item_code="Test-Tshirt-Temp-S-R", price_list_rate=100) + next_values = get_next_attribute_and_values( + "Test-Tshirt-Temp", + selected_attributes={"Test Size": "Small", "Test Colour": "Red"} + ) + price_info = next_values["product_info"]["price"] + + self.assertEqual(next_values["exact_match"][0],"Test-Tshirt-Temp-S-R") + self.assertEqual(next_values["exact_match"][0],"Test-Tshirt-Temp-S-R") + self.assertEqual(price_info["price_list_rate"], 100.0) + self.assertEqual(price_info["formatted_price_sales_uom"], "₹ 100.00") \ No newline at end of file diff --git a/erpnext/e_commerce/variant_selector/utils.py b/erpnext/e_commerce/variant_selector/utils.py index 61df3adca58..5caa4d0819f 100644 --- a/erpnext/e_commerce/variant_selector/utils.py +++ b/erpnext/e_commerce/variant_selector/utils.py @@ -1,7 +1,12 @@ import frappe from frappe.utils import cint +from erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings import ( + get_shopping_cart_settings, +) +from erpnext.e_commerce.shopping_cart.cart import _set_price_list from erpnext.e_commerce.variant_selector.item_variants_cache import ItemVariantsCacheManager +from erpnext.utilities.product import get_price def get_item_codes_by_attributes(attribute_filters, template_item_code=None): @@ -143,14 +148,13 @@ def get_next_attribute_and_values(item_code, selected_attributes): filtered_items_count = len(filtered_items) # get product info if exact match - from erpnext.e_commerce.shopping_cart.product_info import get_product_info_for_website + # from erpnext.e_commerce.shopping_cart.product_info import get_product_info_for_website if exact_match: - data = get_product_info_for_website(exact_match[0]) - product_info = data.product_info + cart_settings = get_shopping_cart_settings() + product_info = get_item_variant_price_dict(exact_match[0], cart_settings) + if product_info: - product_info["allow_items_not_in_stock"] = cint(data.cart_settings.allow_items_not_in_stock) - if not data.cart_settings.show_price: - product_info = None + product_info["allow_items_not_in_stock"] = cint(cart_settings.allow_items_not_in_stock) else: product_info = None @@ -195,3 +199,20 @@ def get_item_attributes(item_code): return attributes +def get_item_variant_price_dict(item_code, cart_settings): + if cart_settings.enabled and cart_settings.show_price: + is_guest = frappe.session.user == "Guest" + # Show Price if logged in. + # If not logged in, check if price is hidden for guest. + if not is_guest or not cart_settings.hide_price_for_guest: + price_list = _set_price_list(cart_settings, None) + price = get_price( + item_code, + price_list, + cart_settings.default_customer_group, + cart_settings.company + ) + return {"price": price} + + return None + diff --git a/erpnext/education/api.py b/erpnext/education/api.py index d9013b08161..636b948a1cc 100644 --- a/erpnext/education/api.py +++ b/erpnext/education/api.py @@ -201,8 +201,8 @@ def get_course_schedule_events(start, end, filters=None): conditions = get_event_conditions("Course Schedule", filters) data = frappe.db.sql("""select name, course, color, - timestamp(schedule_date, from_time) as from_datetime, - timestamp(schedule_date, to_time) as to_datetime, + timestamp(schedule_date, from_time) as from_time, + timestamp(schedule_date, to_time) as to_time, room, student_group, 0 as 'allDay' from `tabCourse Schedule` where ( schedule_date between %(start)s and %(end)s ) diff --git a/erpnext/education/doctype/course_schedule/course_schedule.py b/erpnext/education/doctype/course_schedule/course_schedule.py index 335b6d28d0c..335cec43527 100644 --- a/erpnext/education/doctype/course_schedule/course_schedule.py +++ b/erpnext/education/doctype/course_schedule/course_schedule.py @@ -3,6 +3,8 @@ # For license information, please see license.txt +from datetime import datetime + import frappe from frappe import _ from frappe.model.document import Document @@ -30,6 +32,14 @@ class CourseSchedule(Document): if self.from_time > self.to_time: frappe.throw(_("From Time cannot be greater than To Time.")) + """Handles specicfic case to update schedule date in calendar """ + if isinstance(self.from_time, str): + try: + datetime_obj = datetime.strptime(self.from_time, '%Y-%m-%d %H:%M:%S') + self.schedule_date = datetime_obj + except ValueError: + pass + def validate_overlap(self): """Validates overlap for Student Group, Instructor, Room""" @@ -47,4 +57,4 @@ class CourseSchedule(Document): validate_overlap_for(self, "Assessment Plan", "student_group") validate_overlap_for(self, "Assessment Plan", "room") - validate_overlap_for(self, "Assessment Plan", "supervisor", self.instructor) + validate_overlap_for(self, "Assessment Plan", "supervisor", self.instructor) \ No newline at end of file diff --git a/erpnext/education/doctype/course_schedule/course_schedule_calendar.js b/erpnext/education/doctype/course_schedule/course_schedule_calendar.js index 803527e5480..cacd539b224 100644 --- a/erpnext/education/doctype/course_schedule/course_schedule_calendar.js +++ b/erpnext/education/doctype/course_schedule/course_schedule_calendar.js @@ -1,11 +1,10 @@ frappe.views.calendar["Course Schedule"] = { field_map: { - // from_datetime and to_datetime don't exist as docfields but are used in onload - "start": "from_datetime", - "end": "to_datetime", + "start": "from_time", + "end": "to_time", "id": "name", "title": "course", - "allDay": "allDay" + "allDay": "allDay", }, gantt: false, order_by: "schedule_date", diff --git a/erpnext/education/doctype/course_schedule/test_course_schedule.py b/erpnext/education/doctype/course_schedule/test_course_schedule.py index a7324195557..56149affcea 100644 --- a/erpnext/education/doctype/course_schedule/test_course_schedule.py +++ b/erpnext/education/doctype/course_schedule/test_course_schedule.py @@ -6,6 +6,7 @@ import unittest import frappe from frappe.utils import to_timedelta, today +from frappe.utils.data import add_to_date from erpnext.education.utils import OverlapError @@ -39,6 +40,11 @@ class TestCourseSchedule(unittest.TestCase): make_course_schedule_test_record(from_time= cs1.from_time, to_time= cs1.to_time, student_group="Course-TC102-2014-2015 (_Test Academic Term)", instructor="_Test Instructor 2", room=frappe.get_all("Room")[1].name) + def test_update_schedule_date(self): + doc = make_course_schedule_test_record(schedule_date= add_to_date(today(), days=1)) + doc.schedule_date = add_to_date(doc.schedule_date, days=1) + doc.save() + def make_course_schedule_test_record(**args): args = frappe._dict(args) diff --git a/erpnext/healthcare/doctype/patient/patient.py b/erpnext/healthcare/doctype/patient/patient.py index 89f146576bb..46d63003cce 100644 --- a/erpnext/healthcare/doctype/patient/patient.py +++ b/erpnext/healthcare/doctype/patient/patient.py @@ -143,7 +143,7 @@ class Patient(Document): age = self.age if not age: return - age_str = str(age.years) + ' ' + _("Years(s)") + ' ' + str(age.months) + ' ' + _("Month(s)") + ' ' + str(age.days) + ' ' + _("Day(s)") + age_str = str(age.years) + ' ' + _("Year(s)") + ' ' + str(age.months) + ' ' + _("Month(s)") + ' ' + str(age.days) + ' ' + _("Day(s)") return age_str @frappe.whitelist() diff --git a/erpnext/hr/doctype/attendance/attendance.py b/erpnext/hr/doctype/attendance/attendance.py index 7dcfac249f4..b1eaaf8b587 100644 --- a/erpnext/hr/doctype/attendance/attendance.py +++ b/erpnext/hr/doctype/attendance/attendance.py @@ -5,9 +5,9 @@ import frappe from frappe import _ from frappe.model.document import Document -from frappe.utils import cstr, formatdate, get_datetime, getdate, nowdate +from frappe.utils import cint, cstr, formatdate, get_datetime, getdate, nowdate -from erpnext.hr.utils import validate_active_employee +from erpnext.hr.utils import get_holiday_dates_for_employee, validate_active_employee class Attendance(Document): @@ -171,7 +171,7 @@ def get_month_map(): }) @frappe.whitelist() -def get_unmarked_days(employee, month): +def get_unmarked_days(employee, month, exclude_holidays=0): import calendar month_map = get_month_map() @@ -191,6 +191,11 @@ def get_unmarked_days(employee, month): ]) marked_days = [get_datetime(record.attendance_date) for record in records] + if cint(exclude_holidays): + holiday_dates = get_holiday_dates_for_employee(employee, month_start, month_end) + holidays = [get_datetime(record) for record in holiday_dates] + marked_days.extend(holidays) + unmarked_days = [] for date in dates_of_month: diff --git a/erpnext/hr/doctype/attendance/attendance_list.js b/erpnext/hr/doctype/attendance/attendance_list.js index 6b3c29a76b4..3a5c5915396 100644 --- a/erpnext/hr/doctype/attendance/attendance_list.js +++ b/erpnext/hr/doctype/attendance/attendance_list.js @@ -28,6 +28,7 @@ frappe.listview_settings['Attendance'] = { onchange: function() { dialog.set_df_property("unmarked_days", "hidden", 1); dialog.set_df_property("status", "hidden", 1); + dialog.set_df_property("exclude_holidays", "hidden", 1); dialog.set_df_property("month", "value", ''); dialog.set_df_property("unmarked_days", "options", []); dialog.no_unmarked_days_left = false; @@ -42,9 +43,14 @@ frappe.listview_settings['Attendance'] = { onchange: function() { if (dialog.fields_dict.employee.value && dialog.fields_dict.month.value) { dialog.set_df_property("status", "hidden", 0); + dialog.set_df_property("exclude_holidays", "hidden", 0); dialog.set_df_property("unmarked_days", "options", []); dialog.no_unmarked_days_left = false; - me.get_multi_select_options(dialog.fields_dict.employee.value, dialog.fields_dict.month.value).then(options => { + me.get_multi_select_options( + dialog.fields_dict.employee.value, + dialog.fields_dict.month.value, + dialog.fields_dict.exclude_holidays.get_value() + ).then(options => { if (options.length > 0) { dialog.set_df_property("unmarked_days", "hidden", 0); dialog.set_df_property("unmarked_days", "options", options); @@ -64,6 +70,31 @@ frappe.listview_settings['Attendance'] = { reqd: 1, }, + { + label: __("Exclude Holidays"), + fieldtype: "Check", + fieldname: "exclude_holidays", + hidden: 1, + onchange: function() { + if (dialog.fields_dict.employee.value && dialog.fields_dict.month.value) { + dialog.set_df_property("status", "hidden", 0); + dialog.set_df_property("unmarked_days", "options", []); + dialog.no_unmarked_days_left = false; + me.get_multi_select_options( + dialog.fields_dict.employee.value, + dialog.fields_dict.month.value, + dialog.fields_dict.exclude_holidays.get_value() + ).then(options => { + if (options.length > 0) { + dialog.set_df_property("unmarked_days", "hidden", 0); + dialog.set_df_property("unmarked_days", "options", options); + } else { + dialog.no_unmarked_days_left = true; + } + }); + } + } + }, { label: __("Unmarked Attendance for days"), fieldname: "unmarked_days", @@ -105,7 +136,7 @@ frappe.listview_settings['Attendance'] = { }); }, - get_multi_select_options: function(employee, month) { + get_multi_select_options: function(employee, month, exclude_holidays) { return new Promise(resolve => { frappe.call({ method: 'erpnext.hr.doctype.attendance.attendance.get_unmarked_days', @@ -113,6 +144,7 @@ frappe.listview_settings['Attendance'] = { args: { employee: employee, month: month, + exclude_holidays: exclude_holidays } }).then(r => { var options = []; diff --git a/erpnext/hr/doctype/employee_onboarding/test_employee_onboarding.py b/erpnext/hr/doctype/employee_onboarding/test_employee_onboarding.py index df6e9bde006..daa068e6e03 100644 --- a/erpnext/hr/doctype/employee_onboarding/test_employee_onboarding.py +++ b/erpnext/hr/doctype/employee_onboarding/test_employee_onboarding.py @@ -17,7 +17,10 @@ class TestEmployeeOnboarding(unittest.TestCase): def test_employee_onboarding_incomplete_task(self): if frappe.db.exists('Employee Onboarding', {'employee_name': 'Test Researcher'}): frappe.delete_doc('Employee Onboarding', {'employee_name': 'Test Researcher'}) - _set_up() + frappe.db.sql("delete from `tabEmployee Onboarding`") + project = "Employee Onboarding : test@researcher.com" + frappe.db.sql("delete from tabProject where name=%s", project) + frappe.db.sql("delete from tabTask where project=%s", project) applicant = get_job_applicant() job_offer = create_job_offer(job_applicant=applicant.name) @@ -42,7 +45,7 @@ class TestEmployeeOnboarding(unittest.TestCase): onboarding.submit() project_name = frappe.db.get_value("Project", onboarding.project, "project_name") - self.assertEqual(project_name, 'Employee Onboarding : Test Researcher - test@researcher.com') + self.assertEqual(project_name, 'Employee Onboarding : test@researcher.com') # don't allow making employee if onboarding is not complete self.assertRaises(IncompleteTaskError, make_employee, onboarding.name) @@ -65,8 +68,8 @@ class TestEmployeeOnboarding(unittest.TestCase): self.assertEqual(employee.employee_name, 'Test Researcher') def get_job_applicant(): - if frappe.db.exists('Job Applicant', 'Test Researcher - test@researcher.com'): - return frappe.get_doc('Job Applicant', 'Test Researcher - test@researcher.com') + if frappe.db.exists('Job Applicant', 'test@researcher.com'): + return frappe.get_doc('Job Applicant', 'test@researcher.com') applicant = frappe.new_doc('Job Applicant') applicant.applicant_name = 'Test Researcher' applicant.email_id = 'test@researcher.com' diff --git a/erpnext/hr/doctype/job_applicant/job_applicant.json b/erpnext/hr/doctype/job_applicant/job_applicant.json index 200f675221b..66b609cf990 100644 --- a/erpnext/hr/doctype/job_applicant/job_applicant.json +++ b/erpnext/hr/doctype/job_applicant/job_applicant.json @@ -192,10 +192,11 @@ "idx": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2021-09-29 23:06:10.904260", + "modified": "2022-01-12 16:28:53.196881", "modified_by": "Administrator", "module": "HR", "name": "Job Applicant", + "naming_rule": "Expression (old style)", "owner": "Administrator", "permissions": [ { @@ -210,10 +211,11 @@ "write": 1 } ], - "search_fields": "applicant_name", + "search_fields": "applicant_name, email_id, job_title, phone_number", "sender_field": "email_id", "sort_field": "modified", "sort_order": "ASC", + "states": [], "subject_field": "notes", "title_field": "applicant_name" } \ No newline at end of file diff --git a/erpnext/hr/doctype/job_applicant/job_applicant.py b/erpnext/hr/doctype/job_applicant/job_applicant.py index f0b470b35e8..54ccfca38f7 100644 --- a/erpnext/hr/doctype/job_applicant/job_applicant.py +++ b/erpnext/hr/doctype/job_applicant/job_applicant.py @@ -7,6 +7,7 @@ import frappe from frappe import _ from frappe.model.document import Document +from frappe.model.naming import append_number_if_name_exists from frappe.utils import validate_email_address from erpnext.hr.doctype.interview.interview import get_interviewers @@ -21,10 +22,11 @@ class JobApplicant(Document): self.get("__onload").job_offer = job_offer[0].name def autoname(self): - keys = filter(None, (self.applicant_name, self.email_id, self.job_title)) - if not keys: - frappe.throw(_("Name or Email is mandatory"), frappe.NameError) - self.name = " - ".join(keys) + self.name = self.email_id + + # applicant can apply more than once for a different job title or reapply + if frappe.db.exists("Job Applicant", self.name): + self.name = append_number_if_name_exists("Job Applicant", self.name) def validate(self): if self.email_id: diff --git a/erpnext/hr/doctype/job_applicant/test_job_applicant.py b/erpnext/hr/doctype/job_applicant/test_job_applicant.py index 36dcf6b0740..bf1622028d8 100644 --- a/erpnext/hr/doctype/job_applicant/test_job_applicant.py +++ b/erpnext/hr/doctype/job_applicant/test_job_applicant.py @@ -9,7 +9,26 @@ from erpnext.hr.doctype.designation.test_designation import create_designation class TestJobApplicant(unittest.TestCase): - pass + def test_job_applicant_naming(self): + applicant = frappe.get_doc({ + "doctype": "Job Applicant", + "status": "Open", + "applicant_name": "_Test Applicant", + "email_id": "job_applicant_naming@example.com" + }).insert() + self.assertEqual(applicant.name, 'job_applicant_naming@example.com') + + applicant = frappe.get_doc({ + "doctype": "Job Applicant", + "status": "Open", + "applicant_name": "_Test Applicant", + "email_id": "job_applicant_naming@example.com" + }).insert() + self.assertEqual(applicant.name, 'job_applicant_naming@example.com-1') + + def tearDown(self): + frappe.db.rollback() + def create_job_applicant(**args): args = frappe._dict(args) diff --git a/erpnext/hr/doctype/leave_period/leave_period.json b/erpnext/hr/doctype/leave_period/leave_period.json index 9e895c34fb2..84ce1147e9a 100644 --- a/erpnext/hr/doctype/leave_period/leave_period.json +++ b/erpnext/hr/doctype/leave_period/leave_period.json @@ -1,294 +1,108 @@ { - "allow_copy": 0, - "allow_guest_to_view": 0, + "actions": [], "allow_import": 1, "allow_rename": 1, "autoname": "HR-LPR-.YYYY.-.#####", - "beta": 0, "creation": "2018-04-13 15:20:52.864288", - "custom": 0, - "docstatus": 0, "doctype": "DocType", - "document_type": "", "editable_grid": 1, "engine": "InnoDB", + "field_order": [ + "from_date", + "to_date", + "is_active", + "column_break_3", + "company", + "optional_holiday_list" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "from_date", "fieldtype": "Date", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, "in_list_view": 1, - "in_standard_filter": 0, "label": "From Date", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "reqd": 1 }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "to_date", "fieldtype": "Date", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, "in_list_view": 1, - "in_standard_filter": 0, "label": "To Date", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "reqd": 1 }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, + "default": "0", "fieldname": "is_active", "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Is Active", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Is Active" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "column_break_3", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "fieldtype": "Column Break" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "company", "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, "in_list_view": 1, - "in_standard_filter": 0, "label": "Company", - "length": 0, - "no_copy": 0, "options": "Company", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "reqd": 1 }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "optional_holiday_list", "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, "label": "Holiday List for Optional Leave", - "length": 0, - "no_copy": 0, - "options": "Holiday List", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "options": "Holiday List" } ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 0, - "image_view": 0, - "in_create": 0, - "is_submittable": 0, - "issingle": 0, - "istable": 0, - "max_attachments": 0, - "modified": "2019-05-30 16:15:43.305502", + "links": [], + "modified": "2022-01-13 13:28:12.951025", "modified_by": "Administrator", "module": "HR", "name": "Leave Period", - "name_case": "", + "naming_rule": "Expression (old style)", "owner": "Administrator", "permissions": [ { - "amend": 0, - "cancel": 0, "create": 1, "delete": 1, "email": 1, "export": 1, - "if_owner": 0, - "import": 0, - "permlevel": 0, "print": 1, "read": 1, "report": 1, "role": "System Manager", - "set_user_permissions": 0, "share": 1, - "submit": 0, "write": 1 }, { - "amend": 0, - "cancel": 0, "create": 1, "delete": 1, "email": 1, "export": 1, - "if_owner": 0, - "import": 0, - "permlevel": 0, "print": 1, "read": 1, "report": 1, "role": "HR Manager", - "set_user_permissions": 0, "share": 1, - "submit": 0, "write": 1 }, { - "amend": 0, - "cancel": 0, "create": 1, "delete": 1, "email": 1, "export": 1, - "if_owner": 0, - "import": 0, - "permlevel": 0, "print": 1, "read": 1, "report": 1, "role": "HR User", - "set_user_permissions": 0, "share": 1, - "submit": 0, "write": 1 } ], - "quick_entry": 0, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 0, + "search_fields": "from_date, to_date, company", "sort_field": "modified", "sort_order": "DESC", - "track_changes": 1, - "track_seen": 0, - "track_views": 0 + "states": [], + "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.json b/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.json index 3373350e733..27f0540b247 100644 --- a/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.json +++ b/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.json @@ -113,10 +113,11 @@ ], "is_submittable": 1, "links": [], - "modified": "2021-03-01 17:54:01.014509", + "modified": "2022-01-13 13:37:11.218882", "modified_by": "Administrator", "module": "HR", "name": "Leave Policy Assignment", + "naming_rule": "Expression (old style)", "owner": "Administrator", "permissions": [ { @@ -164,5 +165,7 @@ ], "sort_field": "modified", "sort_order": "DESC", + "states": [], + "title_field": "employee_name", "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment_list.js b/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment_list.js index 8b954c46a10..6b75817cba9 100644 --- a/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment_list.js +++ b/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment_list.js @@ -48,7 +48,16 @@ frappe.listview_settings['Leave Policy Assignment'] = { if (cur_dialog.fields_dict.leave_period.value) { me.set_effective_date(); } - } + }, + get_query() { + let filters = {"is_active": 1}; + if (cur_dialog.fields_dict.company.value) + filters["company"] = cur_dialog.fields_dict.company.value; + + return { + filters: filters + }; + }, }, { fieldtype: "Column Break" diff --git a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.json b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.json index 6479853246f..93ef2170420 100644 --- a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.json +++ b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.json @@ -13,8 +13,10 @@ "column_break_3", "company", "posting_date", - "is_term_loan", "rate_of_interest", + "payroll_payable_account", + "is_term_loan", + "repay_from_salary", "payment_details_section", "due_date", "pending_principal_amount", @@ -243,15 +245,31 @@ "label": "Total Penalty Paid", "options": "Company:company:default_currency", "read_only": 1 + }, + { + "depends_on": "eval:doc.repay_from_salary", + "fieldname": "payroll_payable_account", + "fieldtype": "Link", + "label": "Payroll Payable Account", + "mandatory_depends_on": "eval:doc.repay_from_salary", + "options": "Account" + }, + { + "default": "0", + "fetch_from": "against_loan.repay_from_salary", + "fieldname": "repay_from_salary", + "fieldtype": "Check", + "label": "Repay From Salary" } ], "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2021-04-19 18:10:00.935364", + "modified": "2022-01-06 01:51:06.707782", "modified_by": "Administrator", "module": "Loan Management", "name": "Loan Repayment", + "naming_rule": "Expression (old style)", "owner": "Administrator", "permissions": [ { @@ -287,5 +305,6 @@ ], "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py index ceaaecb93a0..65099da49b4 100644 --- a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py +++ b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py @@ -321,74 +321,79 @@ class LoanRepayment(AccountsController): else: remarks = _("Repayment against Loan: ") + self.against_loan - if not loan_details.repay_from_salary: - if self.total_penalty_paid: - gle_map.append( - self.get_gl_dict({ - "account": loan_details.loan_account, - "against": loan_details.payment_account, - "debit": self.total_penalty_paid, - "debit_in_account_currency": self.total_penalty_paid, - "against_voucher_type": "Loan", - "against_voucher": self.against_loan, - "remarks": _("Penalty against loan:") + self.against_loan, - "cost_center": self.cost_center, - "party_type": self.applicant_type, - "party": self.applicant, - "posting_date": getdate(self.posting_date) - }) - ) - - gle_map.append( - self.get_gl_dict({ - "account": loan_details.penalty_income_account, - "against": loan_details.payment_account, - "credit": self.total_penalty_paid, - "credit_in_account_currency": self.total_penalty_paid, - "against_voucher_type": "Loan", - "against_voucher": self.against_loan, - "remarks": _("Penalty against loan:") + self.against_loan, - "cost_center": self.cost_center, - "posting_date": getdate(self.posting_date) - }) - ) - - gle_map.append( - self.get_gl_dict({ - "account": loan_details.payment_account, - "against": loan_details.loan_account + ", " + loan_details.interest_income_account - + ", " + loan_details.penalty_income_account, - "debit": self.amount_paid, - "debit_in_account_currency": self.amount_paid, - "against_voucher_type": "Loan", - "against_voucher": self.against_loan, - "remarks": remarks, - "cost_center": self.cost_center, - "posting_date": getdate(self.posting_date) - }) - ) + if self.repay_from_salary: + payment_account = self.payroll_payable_account + else: + payment_account = loan_details.payment_account + if self.total_penalty_paid: gle_map.append( self.get_gl_dict({ "account": loan_details.loan_account, - "party_type": loan_details.applicant_type, - "party": loan_details.applicant, "against": loan_details.payment_account, - "credit": self.amount_paid, - "credit_in_account_currency": self.amount_paid, + "debit": self.total_penalty_paid, + "debit_in_account_currency": self.total_penalty_paid, "against_voucher_type": "Loan", "against_voucher": self.against_loan, - "remarks": remarks, + "remarks": _("Penalty against loan:") + self.against_loan, + "cost_center": self.cost_center, + "party_type": self.applicant_type, + "party": self.applicant, + "posting_date": getdate(self.posting_date) + }) + ) + + gle_map.append( + self.get_gl_dict({ + "account": loan_details.penalty_income_account, + "against": payment_account, + "credit": self.total_penalty_paid, + "credit_in_account_currency": self.total_penalty_paid, + "against_voucher_type": "Loan", + "against_voucher": self.against_loan, + "remarks": _("Penalty against loan:") + self.against_loan, "cost_center": self.cost_center, "posting_date": getdate(self.posting_date) }) ) - if gle_map: - make_gl_entries(gle_map, cancel=cancel, adv_adj=adv_adj, merge_entries=False) + gle_map.append( + self.get_gl_dict({ + "account": payment_account, + "against": loan_details.loan_account + ", " + loan_details.interest_income_account + + ", " + loan_details.penalty_income_account, + "debit": self.amount_paid, + "debit_in_account_currency": self.amount_paid, + "against_voucher_type": "Loan", + "against_voucher": self.against_loan, + "remarks": remarks, + "cost_center": self.cost_center, + "posting_date": getdate(self.posting_date) + }) + ) + + gle_map.append( + self.get_gl_dict({ + "account": loan_details.loan_account, + "party_type": loan_details.applicant_type, + "party": loan_details.applicant, + "against": payment_account, + "credit": self.amount_paid, + "credit_in_account_currency": self.amount_paid, + "against_voucher_type": "Loan", + "against_voucher": self.against_loan, + "remarks": remarks, + "cost_center": self.cost_center, + "posting_date": getdate(self.posting_date) + }) + ) + + if gle_map: + make_gl_entries(gle_map, cancel=cancel, adv_adj=adv_adj, merge_entries=False) def create_repayment_entry(loan, applicant, company, posting_date, loan_type, - payment_type, interest_payable, payable_principal_amount, amount_paid, penalty_amount=None): + payment_type, interest_payable, payable_principal_amount, amount_paid, penalty_amount=None, + payroll_payable_account=None): lr = frappe.get_doc({ "doctype": "Loan Repayment", @@ -401,7 +406,8 @@ def create_repayment_entry(loan, applicant, company, posting_date, loan_type, "interest_payable": interest_payable, "payable_principal_amount": payable_principal_amount, "amount_paid": amount_paid, - "loan_type": loan_type + "loan_type": loan_type, + "payroll_payable_account": payroll_payable_account }).insert() return lr diff --git a/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.py b/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.py index 2ffae1a4f2a..07d928c221f 100644 --- a/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.py +++ b/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.py @@ -1,7 +1,6 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt - import frappe from frappe import _, throw from frappe.utils import add_days, cint, cstr, date_diff, formatdate, getdate @@ -306,13 +305,18 @@ class MaintenanceSchedule(TransactionBase): return schedule.name @frappe.whitelist() -def update_serial_nos(s_id): - serial_nos = frappe.db.get_value('Maintenance Schedule Detail', s_id, 'serial_no') +def get_serial_nos_from_schedule(item_code, schedule=None): + serial_nos = [] + if schedule: + serial_nos = frappe.db.get_value('Maintenance Schedule Item', { + 'parent': schedule, + 'item_code': item_code + }, 'serial_no') + if serial_nos: serial_nos = get_serial_nos(serial_nos) - return serial_nos - else: - return False + + return serial_nos @frappe.whitelist() def make_maintenance_visit(source_name, target_doc=None, item_name=None, s_id=None): @@ -320,12 +324,9 @@ def make_maintenance_visit(source_name, target_doc=None, item_name=None, s_id=No def update_status_and_detail(source, target, parent): target.maintenance_type = "Scheduled" - target.maintenance_schedule = source.name target.maintenance_schedule_detail = s_id - def update_sales_and_serial(source, target, parent): - sales_person = frappe.db.get_value('Maintenance Schedule Detail', s_id, 'sales_person') - target.service_person = sales_person + def update_serial(source, target, parent): serial_nos = get_serial_nos(target.serial_no) if len(serial_nos) == 1: target.serial_no = serial_nos[0] @@ -346,7 +347,10 @@ def make_maintenance_visit(source_name, target_doc=None, item_name=None, s_id=No "Maintenance Schedule Item": { "doctype": "Maintenance Visit Purpose", "condition": lambda doc: doc.item_name == item_name, - "postprocess": update_sales_and_serial + "field_map": { + "sales_person": "service_person" + }, + "postprocess": update_serial } }, target_doc) diff --git a/erpnext/maintenance/doctype/maintenance_schedule/test_maintenance_schedule.py b/erpnext/maintenance/doctype/maintenance_schedule/test_maintenance_schedule.py index 501712613a8..6e727e53efd 100644 --- a/erpnext/maintenance/doctype/maintenance_schedule/test_maintenance_schedule.py +++ b/erpnext/maintenance/doctype/maintenance_schedule/test_maintenance_schedule.py @@ -4,11 +4,15 @@ import unittest import frappe +from frappe.utils import format_date from frappe.utils.data import add_days, formatdate, today from erpnext.maintenance.doctype.maintenance_schedule.maintenance_schedule import ( + get_serial_nos_from_schedule, make_maintenance_visit, ) +from erpnext.stock.doctype.item.test_item import create_item +from erpnext.stock.doctype.stock_entry.test_stock_entry import make_serialized_item # test_records = frappe.get_test_records('Maintenance Schedule') @@ -79,6 +83,49 @@ class TestMaintenanceSchedule(unittest.TestCase): #checks if visit status is back updated in schedule self.assertTrue(ms.schedules[1].completion_status, "Partially Completed") + self.assertEqual(format_date(visit.mntc_date), format_date(ms.schedules[1].actual_date)) + + #checks if visit status is updated on cancel + visit.cancel() + ms.reload() + self.assertTrue(ms.schedules[1].completion_status, "Pending") + self.assertEqual(ms.schedules[1].actual_date, None) + + def test_serial_no_filters(self): + # Without serial no. set in schedule -> returns None + item_code = "_Test Serial Item" + make_serial_item_with_serial(item_code) + ms = make_maintenance_schedule(item_code=item_code) + ms.submit() + + s_item = ms.schedules[0] + mv = make_maintenance_visit(source_name=ms.name, item_name=item_code, s_id=s_item.name) + mvi = mv.purposes[0] + serial_nos = get_serial_nos_from_schedule(mvi.item_name, ms.name) + self.assertEqual(serial_nos, None) + + # With serial no. set in schedule -> returns serial nos. + make_serial_item_with_serial(item_code) + ms = make_maintenance_schedule(item_code=item_code, serial_no="TEST001, TEST002") + ms.submit() + + s_item = ms.schedules[0] + mv = make_maintenance_visit(source_name=ms.name, item_name=item_code, s_id=s_item.name) + mvi = mv.purposes[0] + serial_nos = get_serial_nos_from_schedule(mvi.item_name, ms.name) + self.assertEqual(serial_nos, ["TEST001", "TEST002"]) + + frappe.db.rollback() + +def make_serial_item_with_serial(item_code): + serial_item_doc = create_item(item_code, is_stock_item=1) + if not serial_item_doc.has_serial_no or not serial_item_doc.serial_no_series: + serial_item_doc.has_serial_no = 1 + serial_item_doc.serial_no_series = "TEST.###" + serial_item_doc.save(ignore_permissions=True) + active_serials = frappe.db.get_all('Serial No', {"status": "Active", "item_code": item_code}) + if len(active_serials) < 2: + make_serialized_item(item_code=item_code) def get_events(ms): return frappe.get_all("Event Participants", filters={ @@ -87,17 +134,18 @@ def get_events(ms): "parenttype": "Event" }) -def make_maintenance_schedule(): +def make_maintenance_schedule(**args): ms = frappe.new_doc("Maintenance Schedule") ms.company = "_Test Company" ms.customer = "_Test Customer" ms.transaction_date = today() ms.append("items", { - "item_code": "_Test Item", + "item_code": args.get("item_code") or "_Test Item", "start_date": today(), "periodicity": "Weekly", "no_of_visits": 4, + "serial_no": args.get("serial_no"), "sales_person": "Sales Team", }) ms.insert(ignore_permissions=True) diff --git a/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.js b/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.js index 78289679744..f4a0d4d399c 100644 --- a/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.js +++ b/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.js @@ -2,52 +2,54 @@ // License: GNU General Public License v3. See license.txt frappe.provide("erpnext.maintenance"); -var serial_nos = []; frappe.ui.form.on('Maintenance Visit', { - refresh: function (frm) { - //filters for serial_no based on item_code - frm.set_query('serial_no', 'purposes', function (frm, cdt, cdn) { - let item = locals[cdt][cdn]; - if (serial_nos) { - return { - filters: { - 'item_code': item.item_code, - 'name': ["in", serial_nos] - } - }; - } else { - return { - filters: { - 'item_code': item.item_code - } - }; - } - }); - }, setup: function (frm) { frm.set_query('contact_person', erpnext.queries.contact_query); frm.set_query('customer_address', erpnext.queries.address_query); frm.set_query('customer', erpnext.queries.customer); }, - onload: function (frm, cdt, cdn) { - let item = locals[cdt][cdn]; + onload: function (frm) { + // filters for serial no based on item code if (frm.doc.maintenance_type === "Scheduled") { - const schedule_id = item.purposes[0].prevdoc_detail_docname || frm.doc.maintenance_schedule_detail; + let item_code = frm.doc.purposes[0].item_code; frappe.call({ - method: "erpnext.maintenance.doctype.maintenance_schedule.maintenance_schedule.update_serial_nos", + method: "erpnext.maintenance.doctype.maintenance_schedule.maintenance_schedule.get_serial_nos_from_schedule", args: { - s_id: schedule_id - }, - callback: function (r) { - serial_nos = r.message; + schedule: frm.doc.maintenance_schedule, + item_code: item_code } + }).then((r) => { + let serial_nos = r.message; + frm.set_query('serial_no', 'purposes', () => { + if (serial_nos.length > 0) { + return { + filters: { + 'item_code': item_code, + 'name': ["in", serial_nos] + } + }; + } + return { + filters: { + 'item_code': item_code + } + }; + }); + }); + } else { + frm.set_query('serial_no', 'purposes', (frm, cdt, cdn) => { + let row = locals[cdt][cdn]; + return { + filters: { + 'item_code': row.item_code + } + }; }); } if (!frm.doc.status) { frm.set_value({ status: 'Draft' }); } if (frm.doc.__islocal) { - frm.doc.maintenance_type == 'Unscheduled' && frm.clear_table("purposes"); frm.set_value({ mntc_date: frappe.datetime.get_today() }); } }, @@ -60,7 +62,6 @@ frappe.ui.form.on('Maintenance Visit', { contact_person: function (frm) { erpnext.utils.get_contact_details(frm); } - }) // TODO commonify this code diff --git a/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.json b/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.json index ec32239518f..4a6aa0a34bf 100644 --- a/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.json +++ b/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.json @@ -179,8 +179,7 @@ "label": "Purposes", "oldfieldname": "maintenance_visit_details", "oldfieldtype": "Table", - "options": "Maintenance Visit Purpose", - "reqd": 1 + "options": "Maintenance Visit Purpose" }, { "fieldname": "more_info", @@ -294,10 +293,11 @@ "idx": 1, "is_submittable": 1, "links": [], - "modified": "2021-05-27 16:06:17.352572", + "modified": "2021-12-17 03:10:27.608112", "modified_by": "Administrator", "module": "Maintenance", "name": "Maintenance Visit", + "naming_rule": "By \"Naming Series\" field", "owner": "Administrator", "permissions": [ { diff --git a/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.py b/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.py index 5a87b162af6..6fe2466be22 100644 --- a/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.py +++ b/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.py @@ -4,7 +4,7 @@ import frappe from frappe import _ -from frappe.utils import get_datetime +from frappe.utils import format_date, get_datetime from erpnext.utilities.transaction_base import TransactionBase @@ -18,25 +18,34 @@ class MaintenanceVisit(TransactionBase): if d.serial_no and not frappe.db.exists("Serial No", d.serial_no): frappe.throw(_("Serial No {0} does not exist").format(d.serial_no)) + def validate_purpose_table(self): + if not self.purposes: + frappe.throw(_("Add Items in the Purpose Table"), title="Purposes Required") + def validate_maintenance_date(self): if self.maintenance_type == "Scheduled" and self.maintenance_schedule_detail: item_ref = frappe.db.get_value('Maintenance Schedule Detail', self.maintenance_schedule_detail, 'item_reference') if item_ref: start_date, end_date = frappe.db.get_value('Maintenance Schedule Item', item_ref, ['start_date', 'end_date']) if get_datetime(self.mntc_date) < get_datetime(start_date) or get_datetime(self.mntc_date) > get_datetime(end_date): - frappe.throw(_("Date must be between {0} and {1}").format(start_date, end_date)) + frappe.throw(_("Date must be between {0} and {1}") + .format(format_date(start_date), format_date(end_date))) + def validate(self): self.validate_serial_no() self.validate_maintenance_date() + self.validate_purpose_table() - def update_completion_status(self): + def update_status_and_actual_date(self, cancel=False): + status = "Pending" + actual_date = None + if not cancel: + status = self.completion_status + actual_date = self.mntc_date if self.maintenance_schedule_detail: - frappe.db.set_value('Maintenance Schedule Detail', self.maintenance_schedule_detail, 'completion_status', self.completion_status) - - def update_actual_date(self): - if self.maintenance_schedule_detail: - frappe.db.set_value('Maintenance Schedule Detail', self.maintenance_schedule_detail, 'actual_date', self.mntc_date) + frappe.db.set_value('Maintenance Schedule Detail', self.maintenance_schedule_detail, 'completion_status', status) + frappe.db.set_value('Maintenance Schedule Detail', self.maintenance_schedule_detail, 'actual_date', actual_date) def update_customer_issue(self, flag): if not self.maintenance_schedule: @@ -97,12 +106,12 @@ class MaintenanceVisit(TransactionBase): def on_submit(self): self.update_customer_issue(1) frappe.db.set(self, 'status', 'Submitted') - self.update_completion_status() - self.update_actual_date() + self.update_status_and_actual_date() def on_cancel(self): self.check_if_last_visit() frappe.db.set(self, 'status', 'Cancelled') + self.update_status_and_actual_date(cancel=True) def on_update(self): pass diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 0ac64c2cfca..f75038eab34 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -531,16 +531,6 @@ class BOM(WebsiteGenerator): row.hour_rate = (hour_rate / flt(self.conversion_rate) if self.conversion_rate and hour_rate else hour_rate) - if self.routing: - time_in_mins = flt(frappe.db.get_value("BOM Operation", { - "workstation": row.workstation, - "operation": row.operation, - "parent": self.routing - }, ["time_in_mins"])) - - if time_in_mins: - row.time_in_mins = time_in_mins - if row.hour_rate and row.time_in_mins: row.base_hour_rate = flt(row.hour_rate) * flt(self.conversion_rate) row.operating_cost = flt(row.hour_rate) * flt(row.time_in_mins) / 60.0 diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index a63ed999e44..106777b82ef 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -948,11 +948,8 @@ def get_materials_from_other_locations(item, warehouses, new_mr_items, company): locations = get_available_item_locations(item.get("item_code"), warehouses, item.get("quantity"), company, ignore_validation=True) - if not locations: - new_mr_items.append(item) - return - required_qty = item.get("quantity") + # get available material by transferring to production warehouse for d in locations: if required_qty <=0: return @@ -963,14 +960,34 @@ def get_materials_from_other_locations(item, warehouses, new_mr_items, company): new_dict.update({ "quantity": quantity, "material_request_type": "Material Transfer", + "uom": new_dict.get("stock_uom"), # internal transfer should be in stock UOM "from_warehouse": d.get("warehouse") }) required_qty -= quantity new_mr_items.append(new_dict) + # raise purchase request for remaining qty if required_qty: + stock_uom, purchase_uom = frappe.db.get_value( + 'Item', + item['item_code'], + ['stock_uom', 'purchase_uom'] + ) + + if purchase_uom != stock_uom and purchase_uom == item['uom']: + conversion_factor = get_uom_conversion_factor(item['item_code'], item['uom']) + if not (conversion_factor or frappe.flags.show_qty_in_stock_uom): + frappe.throw(_("UOM Conversion factor ({0} -> {1}) not found for item: {2}") + .format(purchase_uom, stock_uom, item['item_code'])) + + required_qty = required_qty / conversion_factor + + if frappe.db.get_value("UOM", purchase_uom, "must_be_whole_number"): + required_qty = ceil(required_qty) + item["quantity"] = required_qty + new_mr_items.append(item) @frappe.whitelist() diff --git a/erpnext/manufacturing/doctype/routing/test_routing.py b/erpnext/manufacturing/doctype/routing/test_routing.py index e90b0a7d6d2..8bd60ea4aca 100644 --- a/erpnext/manufacturing/doctype/routing/test_routing.py +++ b/erpnext/manufacturing/doctype/routing/test_routing.py @@ -46,6 +46,7 @@ class TestRouting(ERPNextTestCase): wo_doc.delete() def test_update_bom_operation_time(self): + """Update cost shouldn't update routing times.""" operations = [ { "operation": "Test Operation A", @@ -85,8 +86,8 @@ class TestRouting(ERPNextTestCase): routing_doc.save() bom_doc.update_cost() bom_doc.reload() - self.assertEqual(bom_doc.operations[0].time_in_mins, 90) - self.assertEqual(bom_doc.operations[1].time_in_mins, 42.2) + self.assertEqual(bom_doc.operations[0].time_in_mins, 30) + self.assertEqual(bom_doc.operations[1].time_in_mins, 20) def setup_operations(rows): diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index aa19b2f1003..1c9cd0cbf4d 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -2,7 +2,7 @@ # License: GNU General Public License v3. See license.txt import frappe -from frappe.utils import add_months, cint, flt, now, today +from frappe.utils import add_days, add_months, cint, flt, now, today from erpnext.manufacturing.doctype.job_card.job_card import JobCardCancelError from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom @@ -12,6 +12,7 @@ from erpnext.manufacturing.doctype.work_order.work_order import ( OverProductionError, StockOverProductionError, close_work_order, + make_job_card, make_stock_entry, stop_unstop, ) @@ -801,6 +802,34 @@ class TestWorkOrder(ERPNextTestCase): if row.is_scrap_item: self.assertEqual(row.qty, 1) + # Partial Job Card 1 with qty 10 + wo_order = make_wo_order_test_record(item=item, company=company, planned_start_date=add_days(now(), 60), qty=20, skip_transfer=1) + job_card = frappe.db.get_value('Job Card', {'work_order': wo_order.name}, 'name') + update_job_card(job_card, 10) + + stock_entry = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 10)) + for row in stock_entry.items: + if row.is_scrap_item: + self.assertEqual(row.qty, 2) + + # Partial Job Card 2 with qty 10 + operations = [] + wo_order.load_from_db() + for row in wo_order.operations: + n_dict = row.as_dict() + n_dict['qty'] = 10 + n_dict['pending_qty'] = 10 + operations.append(n_dict) + + make_job_card(wo_order.name, operations) + job_card = frappe.db.get_value('Job Card', {'work_order': wo_order.name, 'docstatus': 0}, 'name') + update_job_card(job_card, 10) + + stock_entry = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 10)) + for row in stock_entry.items: + if row.is_scrap_item: + self.assertEqual(row.qty, 2) + def test_close_work_order(self): items = ['Test FG Item for Closed WO', 'Test RM Item 1 for Closed WO', 'Test RM Item 2 for Closed WO'] @@ -841,7 +870,9 @@ class TestWorkOrder(ERPNextTestCase): close_work_order(wo_order, "Closed") self.assertEqual(wo_order.get('status'), "Closed") -def update_job_card(job_card): +def update_job_card(job_card, jc_qty=None): + employee = frappe.db.get_value('Employee', {'status': 'Active'}, 'name') + job_card_doc = frappe.get_doc('Job Card', job_card) job_card_doc.set('scrap_items', [ { @@ -854,15 +885,18 @@ def update_job_card(job_card): }, ]) + if jc_qty: + job_card_doc.for_quantity = jc_qty + job_card_doc.append('time_logs', { 'from_time': now(), + 'employee': employee, 'time_in_mins': 60, 'completed_qty': job_card_doc.for_quantity }) job_card_doc.submit() - def get_scrap_item_details(bom_no): scrap_items = {} for item in frappe.db.sql("""select item_code, stock_qty from `tabBOM Scrap Item` diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index e47103599e9..3f2f39e73af 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -131,16 +131,14 @@ frappe.ui.form.on("Work Order", { erpnext.work_order.set_custom_buttons(frm); frm.set_intro(""); - if (frm.doc.docstatus === 0 && !frm.doc.__islocal) { + if (frm.doc.docstatus === 0 && !frm.is_new()) { frm.set_intro(__("Submit this Work Order for further processing.")); + } else { + frm.trigger("show_progress_for_items"); + frm.trigger("show_progress_for_operations"); } if (frm.doc.status != "Closed") { - if (frm.doc.docstatus===1) { - frm.trigger('show_progress_for_items'); - frm.trigger('show_progress_for_operations'); - } - if (frm.doc.docstatus === 1 && frm.doc.operations && frm.doc.operations.length) { diff --git a/erpnext/manufacturing/report/bom_operations_time/bom_operations_time.js b/erpnext/manufacturing/report/bom_operations_time/bom_operations_time.js index 7468e34020c..0eb22a22f73 100644 --- a/erpnext/manufacturing/report/bom_operations_time/bom_operations_time.js +++ b/erpnext/manufacturing/report/bom_operations_time/bom_operations_time.js @@ -4,6 +4,39 @@ frappe.query_reports["BOM Operations Time"] = { "filters": [ - + { + "fieldname": "item_code", + "label": __("Item Code"), + "fieldtype": "Link", + "width": "100", + "options": "Item", + "get_query": () =>{ + return { + filters: { "disabled": 0, "is_stock_item": 1 } + } + } + }, + { + "fieldname": "bom_id", + "label": __("BOM ID"), + "fieldtype": "MultiSelectList", + "width": "100", + "options": "BOM", + "get_data": function(txt) { + return frappe.db.get_link_options("BOM", txt); + }, + "get_query": () =>{ + return { + filters: { "docstatus": 1, "is_active": 1, "with_operations": 1 } + } + } + }, + { + "fieldname": "workstation", + "label": __("Workstation"), + "fieldtype": "Link", + "width": "100", + "options": "Workstation" + }, ] }; diff --git a/erpnext/manufacturing/report/bom_operations_time/bom_operations_time.json b/erpnext/manufacturing/report/bom_operations_time/bom_operations_time.json index 665c5b9f79e..8162017ca81 100644 --- a/erpnext/manufacturing/report/bom_operations_time/bom_operations_time.json +++ b/erpnext/manufacturing/report/bom_operations_time/bom_operations_time.json @@ -1,14 +1,16 @@ { - "add_total_row": 0, + "add_total_row": 1, + "columns": [], "creation": "2020-03-03 01:41:20.862521", "disable_prepared_report": 0, "disabled": 0, "docstatus": 0, "doctype": "Report", + "filters": [], "idx": 0, "is_standard": "Yes", "letter_head": "", - "modified": "2020-03-03 01:41:20.862521", + "modified": "2022-01-20 14:21:47.771591", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM Operations Time", diff --git a/erpnext/manufacturing/report/bom_operations_time/bom_operations_time.py b/erpnext/manufacturing/report/bom_operations_time/bom_operations_time.py index e7a818abd5d..eda9eb9d701 100644 --- a/erpnext/manufacturing/report/bom_operations_time/bom_operations_time.py +++ b/erpnext/manufacturing/report/bom_operations_time/bom_operations_time.py @@ -12,19 +12,15 @@ def execute(filters=None): return columns, data def get_data(filters): - data = [] + bom_wise_data = {} + bom_data, report_data = [], [] - bom_data = [] - for d in frappe.db.sql(""" - SELECT - bom.name, bom.item, bom.item_name, bom.uom, - bomps.operation, bomps.workstation, bomps.time_in_mins - FROM `tabBOM` bom, `tabBOM Operation` bomps - WHERE - bom.docstatus = 1 and bom.is_active = 1 and bom.name = bomps.parent - """, as_dict=1): + bom_operation_data = get_filtered_data(filters) + + for d in bom_operation_data: row = get_args() if d.name not in bom_data: + bom_wise_data[d.name] = [] bom_data.append(d.name) row.update(d) else: @@ -34,14 +30,49 @@ def get_data(filters): "time_in_mins": d.time_in_mins }) - data.append(row) + # maintain BOM wise data for grouping such as: + # {"BOM A": [{Row1}, {Row2}], "BOM B": ...} + bom_wise_data[d.name].append(row) used_as_subassembly_items = get_bom_count(bom_data) - for d in data: - d.used_as_subassembly_items = used_as_subassembly_items.get(d.name, 0) + for d in bom_wise_data: + for row in bom_wise_data[d]: + row.used_as_subassembly_items = used_as_subassembly_items.get(row.name, 0) + report_data.append(row) - return data + return report_data + +def get_filtered_data(filters): + bom = frappe.qb.DocType("BOM") + bom_ops = frappe.qb.DocType("BOM Operation") + + bom_ops_query = ( + frappe.qb.from_(bom) + .join(bom_ops).on(bom.name == bom_ops.parent) + .select( + bom.name, bom.item, bom.item_name, bom.uom, + bom_ops.operation, bom_ops.workstation, bom_ops.time_in_mins + ).where( + (bom.docstatus == 1) + & (bom.is_active == 1) + ) + ) + + if filters.get("item_code"): + bom_ops_query = bom_ops_query.where(bom.item == filters.get("item_code")) + + if filters.get("bom_id"): + bom_ops_query = bom_ops_query.where(bom.name.isin(filters.get("bom_id"))) + + if filters.get("workstation"): + bom_ops_query = bom_ops_query.where( + bom_ops.workstation == filters.get("workstation") + ) + + bom_operation_data = bom_ops_query.run(as_dict=True) + + return bom_operation_data def get_bom_count(bom_data): data = frappe.get_all("BOM Item", @@ -68,13 +99,13 @@ def get_columns(filters): "options": "BOM", "fieldname": "name", "fieldtype": "Link", - "width": 140 + "width": 220 }, { - "label": _("BOM Item Code"), + "label": _("Item Code"), "options": "Item", "fieldname": "item", "fieldtype": "Link", - "width": 140 + "width": 150 }, { "label": _("Item Name"), "fieldname": "item_name", @@ -85,13 +116,13 @@ def get_columns(filters): "options": "UOM", "fieldname": "uom", "fieldtype": "Link", - "width": 140 + "width": 100 }, { "label": _("Operation"), "options": "Operation", "fieldname": "operation", "fieldtype": "Link", - "width": 120 + "width": 140 }, { "label": _("Workstation"), "options": "Workstation", @@ -101,11 +132,11 @@ def get_columns(filters): }, { "label": _("Time (In Mins)"), "fieldname": "time_in_mins", - "fieldtype": "Int", - "width": 140 + "fieldtype": "Float", + "width": 120 }, { "label": _("Sub-assembly BOM Count"), "fieldname": "used_as_subassembly_items", "fieldtype": "Int", - "width": 180 + "width": 200 }] diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 67f41d0678b..30b055f919c 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -178,7 +178,6 @@ erpnext.patches.v12_0.set_updated_purpose_in_pick_list erpnext.patches.v12_0.set_default_payroll_based_on erpnext.patches.v12_0.repost_stock_ledger_entries_for_target_warehouse erpnext.patches.v12_0.update_end_date_and_status_in_email_campaign -erpnext.patches.v13_0.validate_options_for_data_field erpnext.patches.v13_0.move_tax_slabs_from_payroll_period_to_income_tax_slab #123 erpnext.patches.v12_0.fix_quotation_expired_status erpnext.patches.v12_0.update_appointment_reminder_scheduler_entry @@ -306,6 +305,7 @@ erpnext.patches.v13_0.shopify_deprecation_warning erpnext.patches.v13_0.add_custom_field_for_south_africa #2 erpnext.patches.v13_0.rename_discharge_ordered_date_in_ip_record erpnext.patches.v13_0.remove_bad_selling_defaults +erpnext.patches.v13_0.trim_whitespace_from_serial_nos # 16-01-2022 erpnext.patches.v13_0.migrate_stripe_api erpnext.patches.v13_0.reset_clearance_date_for_intracompany_payment_entries execute:frappe.reload_doc("erpnext_integrations", "doctype", "TaxJar Settings") @@ -331,12 +331,17 @@ erpnext.patches.v13_0.enable_scheduler_job_for_item_reposting erpnext.patches.v13_0.requeue_failed_reposts erpnext.patches.v13_0.fetch_thumbnail_in_website_items erpnext.patches.v13_0.update_job_card_status +erpnext.patches.v13_0.enable_uoms erpnext.patches.v12_0.update_production_plan_status erpnext.patches.v13_0.item_naming_series_not_mandatory erpnext.patches.v13_0.update_category_in_ltds_certificate erpnext.patches.v13_0.create_ksa_vat_custom_fields erpnext.patches.v13_0.rename_ksa_qr_field +erpnext.patches.v13_0.wipe_serial_no_field_for_0_qty erpnext.patches.v13_0.disable_ksa_print_format_for_others # 16-12-2021 erpnext.patches.v13_0.update_tax_category_for_rcm erpnext.patches.v13_0.convert_to_website_item_in_item_card_group_template erpnext.patches.v13_0.agriculture_deprecation_warning +erpnext.patches.v13_0.update_maintenance_schedule_field_in_visit +erpnext.patches.v13_0.hospitality_deprecation_warning +erpnext.patches.v13_0.delete_bank_reconciliation_detail diff --git a/erpnext/patches/v13_0/delete_bank_reconciliation_detail.py b/erpnext/patches/v13_0/delete_bank_reconciliation_detail.py new file mode 100644 index 00000000000..75953b0e304 --- /dev/null +++ b/erpnext/patches/v13_0/delete_bank_reconciliation_detail.py @@ -0,0 +1,13 @@ +# Copyright (c) 2019, Frappe and Contributors +# License: GNU General Public License v3. See license.txt + + +import frappe + + +def execute(): + + if frappe.db.exists('DocType', 'Bank Reconciliation Detail') and \ + frappe.db.exists('DocType', 'Bank Clearance Detail'): + + frappe.delete_doc("DocType", 'Bank Reconciliation Detail', force=1) diff --git a/erpnext/patches/v13_0/enable_uoms.py b/erpnext/patches/v13_0/enable_uoms.py new file mode 100644 index 00000000000..4d3f6376303 --- /dev/null +++ b/erpnext/patches/v13_0/enable_uoms.py @@ -0,0 +1,13 @@ +import frappe + + +def execute(): + frappe.reload_doc('setup', 'doctype', 'uom') + + uom = frappe.qb.DocType("UOM") + + (frappe.qb + .update(uom) + .set(uom.enabled, 1) + .where(uom.creation >= "2021-10-18") # date when this field was released + ).run() diff --git a/erpnext/patches/v13_0/hospitality_deprecation_warning.py b/erpnext/patches/v13_0/hospitality_deprecation_warning.py new file mode 100644 index 00000000000..9f9cf54f693 --- /dev/null +++ b/erpnext/patches/v13_0/hospitality_deprecation_warning.py @@ -0,0 +1,10 @@ +import click + + +def execute(): + + click.secho( + "Hospitality Domain is moved to a separate app and will be removed from ERPNext in version-14.\n" + "When upgrading to ERPNext version-14, please install the app to continue using the Agriculture domain: https://github.com/frappe/hospitality", + fg="yellow", + ) diff --git a/erpnext/patches/v13_0/trim_whitespace_from_serial_nos.py b/erpnext/patches/v13_0/trim_whitespace_from_serial_nos.py new file mode 100644 index 00000000000..4ec22e9d0e1 --- /dev/null +++ b/erpnext/patches/v13_0/trim_whitespace_from_serial_nos.py @@ -0,0 +1,67 @@ +import frappe + +from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos + + +def execute(): + broken_sles = frappe.db.sql(""" + select name, serial_no + from `tabStock Ledger Entry` + where + is_cancelled = 0 + and ( serial_no like %s or serial_no like %s or serial_no like %s or serial_no like %s + or serial_no = %s ) + """, + ( + " %", # leading whitespace + "% ", # trailing whitespace + "%\n %", # leading whitespace on newline + "% \n%", # trailing whitespace on newline + "\n", # just new line + ), + as_dict=True, + ) + + frappe.db.MAX_WRITES_PER_TRANSACTION += len(broken_sles) + + if not broken_sles: + return + + broken_serial_nos = set() + + # patch SLEs + for sle in broken_sles: + serial_no_list = get_serial_nos(sle.serial_no) + correct_sr_no = "\n".join(serial_no_list) + + if correct_sr_no == sle.serial_no: + continue + + frappe.db.set_value("Stock Ledger Entry", sle.name, "serial_no", correct_sr_no, update_modified=False) + broken_serial_nos.update(serial_no_list) + + if not broken_serial_nos: + return + + # Patch serial No documents if they don't have purchase info + # Purchase info is used for fetching incoming rate + broken_sr_no_records = frappe.get_list("Serial No", + filters={ + "status":"Active", + "name": ("in", broken_serial_nos), + "purchase_document_type": ("is", "not set") + }, + pluck="name", + ) + + frappe.db.MAX_WRITES_PER_TRANSACTION += len(broken_sr_no_records) + + patch_savepoint = "serial_no_patch" + for serial_no in broken_sr_no_records: + try: + frappe.db.savepoint(patch_savepoint) + sn = frappe.get_doc("Serial No", serial_no) + sn.update_serial_no_reference() + sn.db_update() + except Exception: + frappe.db.rollback(save_point=patch_savepoint) diff --git a/erpnext/patches/v13_0/update_maintenance_schedule_field_in_visit.py b/erpnext/patches/v13_0/update_maintenance_schedule_field_in_visit.py new file mode 100644 index 00000000000..43096991943 --- /dev/null +++ b/erpnext/patches/v13_0/update_maintenance_schedule_field_in_visit.py @@ -0,0 +1,24 @@ + +import frappe + + +def execute(): + frappe.reload_doc("maintenance", "doctype", "maintenance_visit") + + # Updates the Maintenance Schedule link to fetch serial nos + from frappe.query_builder.functions import Coalesce + mvp = frappe.qb.DocType('Maintenance Visit Purpose') + mv = frappe.qb.DocType('Maintenance Visit') + + frappe.qb.update( + mv + ).join( + mvp + ).on(mvp.parent == mv.name).set( + mv.maintenance_schedule, + Coalesce(mvp.prevdoc_docname, '') + ).where( + (mv.maintenance_type == "Scheduled") + & (mvp.prevdoc_docname.notnull()) + & (mv.docstatus < 2) + ).run(as_dict=1) diff --git a/erpnext/patches/v13_0/validate_options_for_data_field.py b/erpnext/patches/v13_0/validate_options_for_data_field.py deleted file mode 100644 index ad777b8586d..00000000000 --- a/erpnext/patches/v13_0/validate_options_for_data_field.py +++ /dev/null @@ -1,26 +0,0 @@ -# Copyright (c) 2021, Frappe and Contributors -# License: GNU General Public License v3. See license.txt - - -import frappe -from frappe.model import data_field_options - - -def execute(): - - for field in frappe.get_all('Custom Field', - fields = ['name'], - filters = { - 'fieldtype': 'Data', - 'options': ['!=', None] - }): - - if field not in data_field_options: - frappe.db.sql(""" - UPDATE - `tabCustom Field` - SET - options=NULL - WHERE - name=%s - """, (field)) diff --git a/erpnext/patches/v13_0/wipe_serial_no_field_for_0_qty.py b/erpnext/patches/v13_0/wipe_serial_no_field_for_0_qty.py new file mode 100644 index 00000000000..e43a8bad8ea --- /dev/null +++ b/erpnext/patches/v13_0/wipe_serial_no_field_for_0_qty.py @@ -0,0 +1,18 @@ +import frappe + + +def execute(): + + doctype = "Stock Reconciliation Item" + + if not frappe.db.has_column(doctype, "current_serial_no"): + # nothing to fix if column doesn't exist + return + + sr_item = frappe.qb.DocType(doctype) + + (frappe.qb + .update(sr_item) + .set(sr_item.current_serial_no, None) + .where(sr_item.current_qty == 0) + ).run() diff --git a/erpnext/payroll/doctype/payroll_entry/payroll_entry.py b/erpnext/payroll/doctype/payroll_entry/payroll_entry.py index e3ddaf93988..99dfc231c74 100644 --- a/erpnext/payroll/doctype/payroll_entry/payroll_entry.py +++ b/erpnext/payroll/doctype/payroll_entry/payroll_entry.py @@ -60,6 +60,8 @@ class PayrollEntry(Document): def on_cancel(self): frappe.delete_doc("Salary Slip", frappe.db.sql_list("""select name from `tabSalary Slip` where payroll_entry=%s """, (self.name))) + self.db_set("salary_slips_created", 0) + self.db_set("salary_slips_submitted", 0) def get_emp_list(self): """ diff --git a/erpnext/payroll/doctype/salary_slip/salary_slip.py b/erpnext/payroll/doctype/salary_slip/salary_slip.py index 8b82b3a4af4..8ea791f620f 100644 --- a/erpnext/payroll/doctype/salary_slip/salary_slip.py +++ b/erpnext/payroll/doctype/salary_slip/salary_slip.py @@ -1146,15 +1146,17 @@ class SalarySlip(TransactionBase): }) def make_loan_repayment_entry(self): + payroll_payable_account = get_payroll_payable_account(self.company, self.payroll_entry) for loan in self.loans: - repayment_entry = create_repayment_entry(loan.loan, self.employee, - self.company, self.posting_date, loan.loan_type, "Regular Payment", loan.interest_amount, - loan.principal_amount, loan.total_payment) + if loan.total_payment: + repayment_entry = create_repayment_entry(loan.loan, self.employee, + self.company, self.posting_date, loan.loan_type, "Regular Payment", loan.interest_amount, + loan.principal_amount, loan.total_payment, payroll_payable_account=payroll_payable_account) - repayment_entry.save() - repayment_entry.submit() + repayment_entry.save() + repayment_entry.submit() - frappe.db.set_value("Salary Slip Loan", loan.name, "loan_repayment_entry", repayment_entry.name) + frappe.db.set_value("Salary Slip Loan", loan.name, "loan_repayment_entry", repayment_entry.name) def cancel_loan_repayment_entry(self): for loan in self.loans: @@ -1388,3 +1390,11 @@ def get_salary_component_data(component): ], as_dict=1, ) + +def get_payroll_payable_account(company, payroll_entry): + if payroll_entry: + payroll_payable_account = frappe.db.get_value('Payroll Entry', payroll_entry, 'payroll_payable_account') + else: + payroll_payable_account = frappe.db.get_value('Company', company, 'default_payroll_payable_account') + + return payroll_payable_account \ No newline at end of file diff --git a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py index 6227863365b..0977561c9e6 100644 --- a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py +++ b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py @@ -384,7 +384,7 @@ class TestSalarySlip(unittest.TestCase): make_salary_structure("Test Loan Repayment Salary Structure", "Monthly", employee=applicant, currency='INR', payroll_period=payroll_period) - frappe.db.sql("delete from tabLoan") + frappe.db.sql("delete from tabLoan where applicant = 'test_loan_repayment_salary_slip@salary.com'") loan = create_loan(applicant, "Car Loan", 11000, "Repay Over Number of Periods", 20, posting_date=add_months(nowdate(), -1)) loan.repay_from_salary = 1 loan.submit() diff --git a/erpnext/projects/doctype/task/task.py b/erpnext/projects/doctype/task/task.py index c8657b29da5..6d3f20f271a 100755 --- a/erpnext/projects/doctype/task/task.py +++ b/erpnext/projects/doctype/task/task.py @@ -105,7 +105,7 @@ class Task(NestedSet): frappe.throw(_("Completed On cannot be greater than Today")) def update_depends_on(self): - depends_on_tasks = self.depends_on_tasks or "" + depends_on_tasks = "" for d in self.depends_on: if d.task and d.task not in depends_on_tasks: depends_on_tasks += d.task + "," diff --git a/erpnext/projects/doctype/timesheet/test_timesheet.py b/erpnext/projects/doctype/timesheet/test_timesheet.py index 148d8ba29c2..989bcd1670d 100644 --- a/erpnext/projects/doctype/timesheet/test_timesheet.py +++ b/erpnext/projects/doctype/timesheet/test_timesheet.py @@ -5,7 +5,7 @@ import datetime import unittest import frappe -from frappe.utils import add_months, now_datetime, nowdate +from frappe.utils import add_months, add_to_date, now_datetime, nowdate from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.hr.doctype.employee.test_employee import make_employee @@ -151,6 +151,27 @@ class TestTimesheet(unittest.TestCase): settings.ignore_employee_time_overlap = initial_setting settings.save() + def test_to_time(self): + emp = make_employee("test_employee_6@salary.com") + from_time = now_datetime() + + timesheet = frappe.new_doc("Timesheet") + timesheet.employee = emp + timesheet.append( + 'time_logs', + { + "billable": 1, + "activity_type": "_Test Activity Type", + "from_time": from_time, + "hours": 2, + "company": "_Test Company" + } + ) + timesheet.save() + + to_time = timesheet.time_logs[0].to_time + self.assertEqual(to_time, add_to_date(from_time, hours=2, as_datetime=True)) + def make_salary_structure_for_timesheet(employee, company=None): salary_structure_name = "Timesheet Salary Structure Test" diff --git a/erpnext/projects/doctype/timesheet/timesheet.py b/erpnext/projects/doctype/timesheet/timesheet.py index e92785e06cf..dd0b5f90f4d 100644 --- a/erpnext/projects/doctype/timesheet/timesheet.py +++ b/erpnext/projects/doctype/timesheet/timesheet.py @@ -7,7 +7,7 @@ import json import frappe from frappe import _ from frappe.model.document import Document -from frappe.utils import flt, getdate, time_diff_in_hours +from frappe.utils import add_to_date, flt, getdate, time_diff_in_hours from erpnext.controllers.queries import get_match_cond from erpnext.hr.utils import validate_active_employee @@ -136,10 +136,19 @@ class Timesheet(Document): def validate_time_logs(self): for data in self.get('time_logs'): + self.set_to_time(data) self.validate_overlap(data) self.set_project(data) self.validate_project(data) + def set_to_time(self, data): + if not (data.from_time and data.hours): + return + + _to_time = add_to_date(data.from_time, hours=data.hours, as_datetime=True) + if data.to_time != _to_time: + data.to_time = _to_time + def validate_overlap(self, data): settings = frappe.get_single('Projects Settings') self.validate_overlap_for("user", data, self.user, settings.ignore_user_time_overlap) diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js index 864c0957d1e..2b80efd6e33 100644 --- a/erpnext/public/js/controllers/taxes_and_totals.js +++ b/erpnext/public/js/controllers/taxes_and_totals.js @@ -114,6 +114,8 @@ erpnext.taxes_and_totals = erpnext.payments.extend({ if ((!item.qty) && me.frm.doc.is_return) { item.amount = flt(item.rate * -1, precision("amount", item)); + } else if ((!item.qty) && me.frm.doc.is_debit_note) { + item.amount = flt(item.rate, precision("amount", item)); } else { item.amount = flt(item.rate * item.qty, precision("amount", item)); } @@ -708,14 +710,15 @@ erpnext.taxes_and_totals = erpnext.payments.extend({ frappe.model.round_floats_in(this.frm.doc, ["grand_total", "total_advance", "write_off_amount"]); if(in_list(["Sales Invoice", "POS Invoice", "Purchase Invoice"], this.frm.doc.doctype)) { - var grand_total = this.frm.doc.rounded_total || this.frm.doc.grand_total; + let grand_total = this.frm.doc.rounded_total || this.frm.doc.grand_total; + let base_grand_total = this.frm.doc.base_rounded_total || this.frm.doc.base_grand_total; if(this.frm.doc.party_account_currency == this.frm.doc.currency) { var total_amount_to_pay = flt((grand_total - this.frm.doc.total_advance - this.frm.doc.write_off_amount), precision("grand_total")); } else { var total_amount_to_pay = flt( - (flt(grand_total*this.frm.doc.conversion_rate, precision("grand_total")) + (flt(base_grand_total, precision("base_grand_total")) - this.frm.doc.total_advance - this.frm.doc.base_write_off_amount), precision("base_grand_total") ); @@ -746,14 +749,15 @@ erpnext.taxes_and_totals = erpnext.payments.extend({ }, set_total_amount_to_default_mop: function() { - var grand_total = this.frm.doc.rounded_total || this.frm.doc.grand_total; + let grand_total = this.frm.doc.rounded_total || this.frm.doc.grand_total; + let base_grand_total = this.frm.doc.base_rounded_total || this.frm.doc.base_grand_total; if(this.frm.doc.party_account_currency == this.frm.doc.currency) { var total_amount_to_pay = flt((grand_total - this.frm.doc.total_advance - this.frm.doc.write_off_amount), precision("grand_total")); } else { var total_amount_to_pay = flt( - (flt(grand_total*this.frm.doc.conversion_rate, precision("grand_total")) + (flt(base_grand_total, precision("base_grand_total")) - this.frm.doc.total_advance - this.frm.doc.base_write_off_amount), precision("base_grand_total") ); diff --git a/erpnext/public/scss/shopping_cart.scss b/erpnext/public/scss/shopping_cart.scss index 429f4ca35df..b743504a527 100644 --- a/erpnext/public/scss/shopping_cart.scss +++ b/erpnext/public/scss/shopping_cart.scss @@ -590,7 +590,6 @@ body.product-page { top: -10px; left: -12px; background: var(--red-600); - width: 16px; align-items: center; height: 16px; font-size: 10px; diff --git a/erpnext/regional/report/gstr_1/gstr_1.js b/erpnext/regional/report/gstr_1/gstr_1.js index ef2bdb67980..4b98978f130 100644 --- a/erpnext/regional/report/gstr_1/gstr_1.js +++ b/erpnext/regional/report/gstr_1/gstr_1.js @@ -53,7 +53,8 @@ frappe.query_reports["GSTR-1"] = { { "value": "CDNR-REG", "label": __("Credit/Debit Notes (Registered) - 9B") }, { "value": "CDNR-UNREG", "label": __("Credit/Debit Notes (Unregistered) - 9B") }, { "value": "EXPORT", "label": __("Export Invoice - 6A") }, - { "value": "Advances", "label": __("Tax Liability (Advances Received) - 11A(1), 11A(2)") } + { "value": "Advances", "label": __("Tax Liability (Advances Received) - 11A(1), 11A(2)") }, + { "value": "NIL Rated", "label": __("NIL RATED/EXEMPTED Invoices") } ], "default": "B2B" } diff --git a/erpnext/regional/report/gstr_1/gstr_1.py b/erpnext/regional/report/gstr_1/gstr_1.py index b095c7293dd..17b11073346 100644 --- a/erpnext/regional/report/gstr_1/gstr_1.py +++ b/erpnext/regional/report/gstr_1/gstr_1.py @@ -41,7 +41,8 @@ class Gstr1Report(object): port_code, shipping_bill_number, shipping_bill_date, - reason_for_issuing_document + reason_for_issuing_document, + company_gstin """ def run(self): @@ -63,6 +64,8 @@ class Gstr1Report(object): self.get_b2c_data() elif self.filters.get("type_of_business") == "Advances": self.get_advance_data() + elif self.filters.get("type_of_business") == "NIL Rated": + self.get_nil_rated_invoices() elif self.invoices: for inv, items_based_on_rate in self.items_based_on_tax_rate.items(): invoice_details = self.invoices.get(inv) @@ -92,6 +95,57 @@ class Gstr1Report(object): row= [key[0], key[1], value[0], value[1]] self.data.append(row) + def get_nil_rated_invoices(self): + nil_exempt_output = [ + { + "description": "Inter-State supplies to registered persons", + "nil_rated": 0.0, + "exempted": 0.0, + "non_gst": 0.0 + }, + { + "description": "Intra-State supplies to registered persons", + "nil_rated": 0.0, + "exempted": 0.0, + "non_gst": 0.0 + }, + { + "description": "Inter-State supplies to unregistered persons", + "nil_rated": 0.0, + "exempted": 0.0, + "non_gst": 0.0 + }, + { + "description": "Intra-State supplies to unregistered persons", + "nil_rated": 0.0, + "exempted": 0.0, + "non_gst": 0.0 + } + ] + + for invoice, details in self.nil_exempt_non_gst.items(): + invoice_detail = self.invoices.get(invoice) + if invoice_detail.get('gst_category') in ("Registered Regular", "Deemed Export", "SEZ"): + if is_inter_state(invoice_detail): + nil_exempt_output[0]["nil_rated"] += details[0] + nil_exempt_output[0]["exempted"] += details[1] + nil_exempt_output[0]["non_gst"] += details[2] + else: + nil_exempt_output[1]["nil_rated"] += details[0] + nil_exempt_output[1]["exempted"] += details[1] + nil_exempt_output[1]["non_gst"] += details[2] + else: + if is_inter_state(invoice_detail): + nil_exempt_output[2]["nil_rated"] += details[0] + nil_exempt_output[2]["exempted"] += details[1] + nil_exempt_output[2]["non_gst"] += details[2] + else: + nil_exempt_output[3]["nil_rated"] += details[0] + nil_exempt_output[3]["exempted"] += details[1] + nil_exempt_output[3]["non_gst"] += details[2] + + self.data = nil_exempt_output + def get_b2c_data(self): b2cs_output = {} @@ -241,10 +295,11 @@ class Gstr1Report(object): def get_invoice_items(self): self.invoice_items = frappe._dict() self.item_tax_rate = frappe._dict() + self.nil_exempt_non_gst = {} items = frappe.db.sql(""" - select item_code, parent, taxable_value, base_net_amount, item_tax_rate - from `tab%s Item` + select item_code, parent, taxable_value, base_net_amount, item_tax_rate, is_nil_exempt, + is_non_gst from `tab%s Item` where parent in (%s) """ % (self.doctype, ', '.join(['%s']*len(self.invoices))), tuple(self.invoices), as_dict=1) @@ -261,6 +316,16 @@ class Gstr1Report(object): tax_rate_dict = self.item_tax_rate.setdefault(d.parent, {}).setdefault(d.item_code, []) tax_rate_dict.append(rate) + if d.is_nil_exempt: + self.nil_exempt_non_gst.setdefault(d.parent, [0.0, 0.0, 0.0]) + if item_tax_rate: + self.nil_exempt_non_gst[d.parent][0] += d.get('taxable_value', 0) + else: + self.nil_exempt_non_gst[d.parent][1] += d.get('taxable_value', 0) + elif d.is_non_gst: + self.nil_exempt_non_gst.setdefault(d.parent, [0.0, 0.0, 0.0]) + self.nil_exempt_non_gst[d.parent][2] += d.get('taxable_value', 0) + def get_items_based_on_tax_rate(self): self.tax_details = frappe.db.sql(""" select @@ -323,21 +388,24 @@ class Gstr1Report(object): self.items_based_on_tax_rate.setdefault(invoice, {}).setdefault(0, items.keys()) def get_columns(self): - self.tax_columns = [ - { - "fieldname": "rate", - "label": "Rate", - "fieldtype": "Int", - "width": 60 - }, - { - "fieldname": "taxable_value", - "label": "Taxable Value", - "fieldtype": "Currency", - "width": 100 - } - ] self.other_columns = [] + self.tax_columns = [] + + if self.filters.get("type_of_business") != "NIL Rated": + self.tax_columns = [ + { + "fieldname": "rate", + "label": "Rate", + "fieldtype": "Int", + "width": 60 + }, + { + "fieldname": "taxable_value", + "label": "Taxable Value", + "fieldtype": "Currency", + "width": 100 + } + ] if self.filters.get("type_of_business") == "B2B": self.invoice_columns = [ @@ -706,6 +774,33 @@ class Gstr1Report(object): "width": 100 } ] + elif self.filters.get("type_of_business") == "NIL Rated": + self.invoice_columns = [ + { + "fieldname": "description", + "label": "Description", + "fieldtype": "Data", + "width": 420 + }, + { + "fieldname": "nil_rated", + "label": "Nil Rated", + "fieldtype": "Currency", + "width": 200 + }, + { + "fieldname": "exempted", + "label": "Exempted", + "fieldtype": "Currency", + "width": 200 + }, + { + "fieldname": "non_gst", + "label": "Non GST", + "fieldtype": "Currency", + "width": 200 + } + ] self.columns = self.invoice_columns + self.tax_columns + self.other_columns @@ -769,6 +864,11 @@ def get_json(filters, report_name, data): out = get_advances_json(res, gstin) gst_json["at"] = out + elif filters["type_of_business"] == "NIL Rated": + res = report_data[:-1] + out = get_exempted_json(res) + gst_json["nil"] = out + return { 'report_name': report_name, 'report_type': filters['type_of_business'], @@ -981,6 +1081,36 @@ def get_cdnr_unreg_json(res, gstin): return out +def get_exempted_json(data): + out = { + "inv": [ + { + "sply_ty": "INTRB2B" + }, + { + "sply_ty": "INTRAB2B" + }, + { + "sply_ty": "INTRB2C" + }, + { + "sply_ty": "INTRAB2C" + } + ] + } + + for i, v in enumerate(data): + if data[i].get('nil_rated'): + out['inv'][i]['nil_amt'] = data[i]['nil_rated'] + + if data[i].get('exempted'): + out['inv'][i]['expt_amt'] = data[i]['exempted'] + + if data[i].get('non_gst'): + out['inv'][i]['ngsup_amt'] = data[i]['non_gst'] + + return out + def get_invoice_type_for_cdnr(row): if row.get('gst_category') == 'SEZ': if row.get('export_type') == 'WPAY': @@ -1065,3 +1195,9 @@ def download_json_file(): frappe.response['filecontent'] = data['data'] frappe.response['content_type'] = 'application/json' frappe.response['type'] = 'download' + +def is_inter_state(invoice_detail): + if invoice_detail.place_of_supply.split("-")[0] != invoice_detail.company_gstin[:2]: + return True + else: + return False \ No newline at end of file diff --git a/erpnext/regional/report/ksa_vat/ksa_vat.py b/erpnext/regional/report/ksa_vat/ksa_vat.py index b41b2b0428f..cc26bd7a57a 100644 --- a/erpnext/regional/report/ksa_vat/ksa_vat.py +++ b/erpnext/regional/report/ksa_vat/ksa_vat.py @@ -20,25 +20,35 @@ def get_columns(): "fieldname": "title", "label": _("Title"), "fieldtype": "Data", - "width": 300 + "width": 300, }, { "fieldname": "amount", "label": _("Amount (SAR)"), "fieldtype": "Currency", + "options": "currency", "width": 150, }, { "fieldname": "adjustment_amount", "label": _("Adjustment (SAR)"), "fieldtype": "Currency", + "options": "currency", "width": 150, }, { "fieldname": "vat_amount", "label": _("VAT Amount (SAR)"), "fieldtype": "Currency", + "options": "currency", "width": 150, + }, + { + "fieldname": "currency", + "label": _("Currency"), + "fieldtype": "Currency", + "width": 150, + "hidden": 1 } ] @@ -47,6 +57,8 @@ def get_data(filters): # Validate if vat settings exist company = filters.get('company') + company_currency = frappe.get_cached_value('Company', company, "default_currency") + if frappe.db.exists('KSA VAT Setting', company) is None: url = get_url_to_list('KSA VAT Setting') frappe.msgprint(_('Create KSA VAT Setting for this company').format(url)) @@ -55,7 +67,7 @@ def get_data(filters): ksa_vat_setting = frappe.get_doc('KSA VAT Setting', company) # Sales Heading - append_data(data, 'VAT on Sales', '', '', '') + append_data(data, 'VAT on Sales', '', '', '', company_currency) grand_total_taxable_amount = 0 grand_total_taxable_adjustment_amount = 0 @@ -67,7 +79,7 @@ def get_data(filters): # Adding results to data append_data(data, vat_setting.title, total_taxable_amount, - total_taxable_adjustment_amount, total_tax) + total_taxable_adjustment_amount, total_tax, company_currency) grand_total_taxable_amount += total_taxable_amount grand_total_taxable_adjustment_amount += total_taxable_adjustment_amount @@ -75,13 +87,13 @@ def get_data(filters): # Sales Grand Total append_data(data, 'Grand Total', grand_total_taxable_amount, - grand_total_taxable_adjustment_amount, grand_total_tax) + grand_total_taxable_adjustment_amount, grand_total_tax, company_currency) # Blank Line - append_data(data, '', '', '', '') + append_data(data, '', '', '', '', company_currency) # Purchase Heading - append_data(data, 'VAT on Purchases', '', '', '') + append_data(data, 'VAT on Purchases', '', '', '', company_currency) grand_total_taxable_amount = 0 grand_total_taxable_adjustment_amount = 0 @@ -93,7 +105,7 @@ def get_data(filters): # Adding results to data append_data(data, vat_setting.title, total_taxable_amount, - total_taxable_adjustment_amount, total_tax) + total_taxable_adjustment_amount, total_tax, company_currency) grand_total_taxable_amount += total_taxable_amount grand_total_taxable_adjustment_amount += total_taxable_adjustment_amount @@ -101,7 +113,7 @@ def get_data(filters): # Purchase Grand Total append_data(data, 'Grand Total', grand_total_taxable_amount, - grand_total_taxable_adjustment_amount, grand_total_tax) + grand_total_taxable_adjustment_amount, grand_total_tax, company_currency) return data @@ -147,9 +159,10 @@ def get_tax_data_for_each_vat_setting(vat_setting, filters, doctype): -def append_data(data, title, amount, adjustment_amount, vat_amount): +def append_data(data, title, amount, adjustment_amount, vat_amount, company_currency): """Returns data with appended value.""" - data.append({"title": _(title), "amount": amount, "adjustment_amount": adjustment_amount, "vat_amount": vat_amount}) + data.append({"title": _(title), "amount": amount, "adjustment_amount": adjustment_amount, "vat_amount": vat_amount, + "currency": company_currency}) def get_tax_amount(item_code, account_head, doctype, parent): if doctype == 'Sales Invoice': diff --git a/erpnext/selling/report/sales_order_analysis/sales_order_analysis.py b/erpnext/selling/report/sales_order_analysis/sales_order_analysis.py index 82e5d0ce57d..c530d30c0c2 100644 --- a/erpnext/selling/report/sales_order_analysis/sales_order_analysis.py +++ b/erpnext/selling/report/sales_order_analysis/sales_order_analysis.py @@ -67,7 +67,8 @@ def get_data(conditions, filters): (soi.billed_amt * IFNULL(so.conversion_rate, 1)) as billed_amount, (soi.base_amount - (soi.billed_amt * IFNULL(so.conversion_rate, 1))) as pending_amount, soi.warehouse as warehouse, - so.company, soi.name + so.company, soi.name, + soi.description as description FROM `tabSales Order` so, `tabSales Order Item` soi @@ -179,6 +180,12 @@ def get_columns(filters): "options": "Item", "width": 100 }) + columns.append({ + "label":_("Description"), + "fieldname": "description", + "fieldtype": "Small Text", + "width": 100 + }) columns.extend([ { diff --git a/erpnext/setup/doctype/company/company.js b/erpnext/setup/doctype/company/company.js index 45e8dccc319..dd185fc6636 100644 --- a/erpnext/setup/doctype/company/company.js +++ b/erpnext/setup/doctype/company/company.js @@ -213,6 +213,9 @@ erpnext.company.setup_queries = function(frm) { ["default_payroll_payable_account", {"root_type": "Liability"}], ["round_off_account", {"root_type": "Expense"}], ["write_off_account", {"root_type": "Expense"}], + ["default_deferred_expense_account", {}], + ["default_deferred_revenue_account", {}], + ["default_expense_claim_payable_account", {}], ["default_discount_account", {}], ["discount_allowed_account", {"root_type": "Expense"}], ["discount_received_account", {"root_type": "Income"}], diff --git a/erpnext/setup/setup_wizard/operations/install_fixtures.py b/erpnext/setup/setup_wizard/operations/install_fixtures.py index dbf991ceff3..d76d97663cd 100644 --- a/erpnext/setup/setup_wizard/operations/install_fixtures.py +++ b/erpnext/setup/setup_wizard/operations/install_fixtures.py @@ -350,7 +350,8 @@ def add_uom_data(): "doctype": "UOM", "uom_name": _(d.get("uom_name")), "name": _(d.get("uom_name")), - "must_be_whole_number": d.get("must_be_whole_number") + "must_be_whole_number": d.get("must_be_whole_number"), + "enabled": 1, }).db_insert() # bootstrap uom conversion factors diff --git a/erpnext/stock/doctype/batch/batch.py b/erpnext/stock/doctype/batch/batch.py index 2fcdba59e35..c0cb30b5edb 100644 --- a/erpnext/stock/doctype/batch/batch.py +++ b/erpnext/stock/doctype/batch/batch.py @@ -293,6 +293,7 @@ def get_batches(item_code, warehouse, qty=1, throw=False, serial_no=None): join `tabStock Ledger Entry` ignore index (item_code, warehouse) on (`tabBatch`.batch_id = `tabStock Ledger Entry`.batch_no ) where `tabStock Ledger Entry`.item_code = %s and `tabStock Ledger Entry`.warehouse = %s + and `tabStock Ledger Entry`.is_cancelled = 0 and (`tabBatch`.expiry_date >= CURDATE() or `tabBatch`.expiry_date IS NULL) {0} group by batch_id order by `tabBatch`.expiry_date ASC, `tabBatch`.creation ASC diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index 70d48a42d72..d1e22440b96 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -14,6 +14,7 @@ from erpnext.controllers.accounts_controller import get_taxes_and_charges from erpnext.controllers.selling_controller import SellingController from erpnext.stock.doctype.batch.batch import set_batch_nos from erpnext.stock.doctype.serial_no.serial_no import get_delivery_note_serial_no +from erpnext.stock.utils import calculate_mapped_packed_items_return form_grid_templates = { "items": "templates/form_grid/item_grid.html" @@ -128,8 +129,12 @@ class DeliveryNote(SellingController): self.validate_uom_is_integer("uom", "qty") self.validate_with_previous_doc() - from erpnext.stock.doctype.packed_item.packed_item import make_packing_list - make_packing_list(self) + # Keeps mapped packed_items in case product bundle is updated. + if self.is_return and self.return_against: + calculate_mapped_packed_items_return(self) + else: + from erpnext.stock.doctype.packed_item.packed_item import make_packing_list + make_packing_list(self) if self._action != 'submit' and not self.is_return: set_batch_nos(self, 'warehouse', throw=True) diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py index 4f89a19f3c7..bd18e788ba6 100644 --- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py @@ -386,8 +386,7 @@ class TestDeliveryNote(ERPNextTestCase): self.assertEqual(actual_qty, 25) # return bundled item - dn1 = create_delivery_note(item_code='_Test Product Bundle Item', is_return=1, - return_against=dn.name, qty=-2, rate=500, company=company, warehouse="Stores - TCP1", expense_account="Cost of Goods Sold - TCP1", cost_center="Main - TCP1") + dn1 = create_return_delivery_note(source_name=dn.name, rate=500, qty=-2) # qty after return actual_qty = get_qty_after_transaction(warehouse="Stores - TCP1") @@ -823,6 +822,15 @@ class TestDeliveryNote(ERPNextTestCase): automatically_fetch_payment_terms(enable=0) +def create_return_delivery_note(**args): + args = frappe._dict(args) + from erpnext.controllers.sales_and_purchase_return import make_return_doc + doc = make_return_doc("Delivery Note", args.source_name, None) + doc.items[0].rate = args.rate + doc.items[0].qty = args.qty + doc.submit() + return doc + def create_delivery_note(**args): dn = frappe.new_doc("Delivery Note") args = frappe._dict(args) diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json index cd7e63b18b2..0ba97d59a14 100644 --- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json @@ -1,7 +1,7 @@ { "actions": [], "autoname": "REPOST-ITEM-VAL-.######", - "creation": "2020-10-22 22:27:07.742161", + "creation": "2022-01-11 15:03:38.273179", "doctype": "DocType", "editable_grid": 1, "engine": "InnoDB", @@ -129,7 +129,7 @@ "reqd": 1 }, { - "default": "0", + "default": "1", "fieldname": "allow_negative_stock", "fieldtype": "Check", "label": "Allow Negative Stock" @@ -177,7 +177,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2021-11-24 02:18:10.524560", + "modified": "2022-01-18 10:57:33.450907", "modified_by": "Administrator", "module": "Stock", "name": "Repost Item Valuation", @@ -227,5 +227,6 @@ } ], "sort_field": "modified", - "sort_order": "DESC" -} + "sort_order": "DESC", + "states": [] +} \ No newline at end of file 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 5ad8f443203..3b76301b4cc 100644 --- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py @@ -27,8 +27,7 @@ class RepostItemValuation(Document): self.item_code = None self.warehouse = None - self.allow_negative_stock = self.allow_negative_stock or \ - cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock")) + self.allow_negative_stock = 1 def set_company(self): if self.based_on == "Transaction": diff --git a/erpnext/stock/doctype/serial_no/serial_no.py b/erpnext/stock/doctype/serial_no/serial_no.py index b2321acf9e8..3b325b80295 100644 --- a/erpnext/stock/doctype/serial_no/serial_no.py +++ b/erpnext/stock/doctype/serial_no/serial_no.py @@ -421,10 +421,16 @@ def update_serial_nos(sle, item_det): def get_auto_serial_nos(serial_no_series, qty): serial_nos = [] for i in range(cint(qty)): - serial_nos.append(make_autoname(serial_no_series, "Serial No")) + serial_nos.append(get_new_serial_number(serial_no_series)) return "\n".join(serial_nos) +def get_new_serial_number(series): + sr_no = make_autoname(series, "Serial No") + if frappe.db.exists("Serial No", sr_no): + sr_no = get_new_serial_number(series) + return sr_no + def auto_make_serial_nos(args): serial_nos = get_serial_nos(args.get('serial_no')) created_numbers = [] diff --git a/erpnext/stock/doctype/serial_no/test_serial_no.py b/erpnext/stock/doctype/serial_no/test_serial_no.py index 99000d12016..f8cea717251 100644 --- a/erpnext/stock/doctype/serial_no/test_serial_no.py +++ b/erpnext/stock/doctype/serial_no/test_serial_no.py @@ -8,8 +8,10 @@ import frappe from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note +from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos +from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry from erpnext.stock.doctype.stock_entry.test_stock_entry import make_serialized_item from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse @@ -21,6 +23,10 @@ from erpnext.tests.utils import ERPNextTestCase class TestSerialNo(ERPNextTestCase): + + def tearDown(self): + frappe.db.rollback() + def test_cannot_create_direct(self): frappe.delete_doc_if_exists("Serial No", "_TCSER0001") @@ -176,6 +182,24 @@ class TestSerialNo(ERPNextTestCase): self.assertEqual(sn_doc.warehouse, "_Test Warehouse - _TC") self.assertEqual(sn_doc.purchase_document_no, se.name) + def test_auto_creation_of_serial_no(self): + """ + Test if auto created Serial No excludes existing serial numbers + """ + item_code = make_item("_Test Auto Serial Item ", { + "has_serial_no": 1, + "serial_no_series": "XYZ.###" + }).item_code + + # Reserve XYZ005 + pr_1 = make_purchase_receipt(item_code=item_code, qty=1, serial_no="XYZ005") + # XYZ005 is already used and will throw an error if used again + pr_2 = make_purchase_receipt(item_code=item_code, qty=10) + + self.assertEqual(get_serial_nos(pr_1.get("items")[0].serial_no)[0], "XYZ005") + for serial_no in get_serial_nos(pr_2.get("items")[0].serial_no): + self.assertNotEqual(serial_no, "XYZ005") + def test_serial_no_sanitation(self): "Test if Serial No input is sanitised before entering the DB." item_code = "_Test Serialized Item" @@ -192,7 +216,28 @@ class TestSerialNo(ERPNextTestCase): self.assertEqual(se.get("items")[0].serial_no, "_TS1\n_TS2\n_TS3\n_TS4 - 2021") - frappe.db.rollback() + def test_correct_serial_no_incoming_rate(self): + """ Check correct consumption rate based on serial no record. + """ + item_code = "_Test Serialized Item" + warehouse = "_Test Warehouse - _TC" + serial_nos = ["LOWVALUATION", "HIGHVALUATION"] + + in1 = make_stock_entry(item_code=item_code, to_warehouse=warehouse, qty=1, rate=42, + serial_no=serial_nos[0]) + in2 = make_stock_entry(item_code=item_code, to_warehouse=warehouse, qty=1, rate=113, + serial_no=serial_nos[1]) + + out = create_delivery_note(item_code=item_code, qty=1, serial_no=serial_nos[0], do_not_submit=True) + + # change serial no + out.items[0].serial_no = serial_nos[1] + out.save() + out.submit() + + value_diff = frappe.db.get_value("Stock Ledger Entry", + {"voucher_no": out.name, "voucher_type": "Delivery Note"}, + "stock_value_difference" + ) + self.assertEqual(value_diff, -113) - def tearDown(self): - frappe.db.rollback() diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index b4865237100..7472c95ecf5 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -8,6 +8,7 @@ from collections import defaultdict import frappe from frappe import _ from frappe.model.mapper import get_mapped_doc +from frappe.query_builder.functions import Sum from frappe.utils import cint, comma_or, cstr, flt, format_time, formatdate, getdate, nowdate from six import iteritems, itervalues, string_types @@ -86,8 +87,11 @@ class StockEntry(StockController): self.validate_warehouse() self.validate_work_order() self.validate_bom() - self.mark_finished_and_scrap_items() - self.validate_finished_goods() + + if self.purpose in ("Manufacture", "Repack"): + self.mark_finished_and_scrap_items() + self.validate_finished_goods() + self.validate_with_material_request() self.validate_batch() self.validate_inspection() @@ -110,8 +114,12 @@ class StockEntry(StockController): self.set_actual_qty() self.calculate_rate_and_amount() self.validate_putaway_capacity() - self.reset_default_field_value("from_warehouse", "items", "s_warehouse") - self.reset_default_field_value("to_warehouse", "items", "t_warehouse") + + if not self.get("purpose") == "Manufacture": + # ignore scrap item wh difference and empty source/target wh + # in Manufacture Entry + self.reset_default_field_value("from_warehouse", "items", "s_warehouse") + self.reset_default_field_value("to_warehouse", "items", "t_warehouse") def on_submit(self): self.update_stock_ledger() @@ -702,26 +710,25 @@ class StockEntry(StockController): validate_bom_no(item_code, d.bom_no) def mark_finished_and_scrap_items(self): - if self.purpose in ("Repack", "Manufacture"): - if any([d.item_code for d in self.items if (d.is_finished_item and d.t_warehouse)]): - return + if any([d.item_code for d in self.items if (d.is_finished_item and d.t_warehouse)]): + return - finished_item = self.get_finished_item() + finished_item = self.get_finished_item() - if not finished_item and self.purpose == "Manufacture": - # In case of independent Manufacture entry, don't auto set - # user must decide and set - return + if not finished_item and self.purpose == "Manufacture": + # In case of independent Manufacture entry, don't auto set + # user must decide and set + return - for d in self.items: - if d.t_warehouse and not d.s_warehouse: - if self.purpose=="Repack" or d.item_code == finished_item: - d.is_finished_item = 1 - else: - d.is_scrap_item = 1 + for d in self.items: + if d.t_warehouse and not d.s_warehouse: + if self.purpose=="Repack" or d.item_code == finished_item: + d.is_finished_item = 1 else: - d.is_finished_item = 0 - d.is_scrap_item = 0 + d.is_scrap_item = 1 + else: + d.is_finished_item = 0 + d.is_scrap_item = 0 def get_finished_item(self): finished_item = None @@ -734,9 +741,9 @@ class StockEntry(StockController): def validate_finished_goods(self): """ - 1. Check if FG exists - 2. Check if Multiple FG Items are present - 3. Check FG Item and Qty against WO if present + 1. Check if FG exists (mfg, repack) + 2. Check if Multiple FG Items are present (mfg) + 3. Check FG Item and Qty against WO if present (mfg) """ production_item, wo_qty, finished_items = None, 0, [] @@ -749,8 +756,9 @@ class StockEntry(StockController): for d in self.get('items'): if d.is_finished_item: if not self.work_order: + # Independent MFG Entry/ Repack Entry, no WO to match against finished_items.append(d.item_code) - continue # Independent Manufacture Entry, no WO to match against + continue if d.item_code != production_item: frappe.throw(_("Finished Item {0} does not match with Work Order {1}") @@ -763,19 +771,17 @@ class StockEntry(StockController): finished_items.append(d.item_code) - if len(set(finished_items)) > 1: + if not finished_items: frappe.throw( - msg=_("Multiple items cannot be marked as finished item"), - title=_("Note"), - exc=FinishedGoodError + msg=_("There must be atleast 1 Finished Good in this Stock Entry").format(self.name), + title=_("Missing Finished Good"), exc=FinishedGoodError ) if self.purpose == "Manufacture": - if not finished_items: + if len(set(finished_items)) > 1: frappe.throw( - msg=_("There must be atleast 1 Finished Good in this Stock Entry").format(self.name), - title=_("Missing Finished Good"), - exc=FinishedGoodError + msg=_("Multiple items cannot be marked as finished item"), + title=_("Note"), exc=FinishedGoodError ) allowance_percentage = flt( @@ -1276,22 +1282,29 @@ class StockEntry(StockController): if not self.pro_doc: self.set_work_order_details() - scrap_items = frappe.db.sql(''' - SELECT - JCSI.item_code, JCSI.item_name, SUM(JCSI.stock_qty) as stock_qty, JCSI.stock_uom, JCSI.description - FROM - `tabJob Card` JC, `tabJob Card Scrap Item` JCSI - WHERE - JCSI.parent = JC.name AND JC.docstatus = 1 - AND JCSI.item_code IS NOT NULL AND JC.work_order = %s - GROUP BY - JCSI.item_code - ''', self.work_order, as_dict=1) - - pending_qty = flt(self.pro_doc.qty) - flt(self.pro_doc.produced_qty) - if pending_qty <=0: + if not self.pro_doc.operations: return [] + job_card = frappe.qb.DocType('Job Card') + job_card_scrap_item = frappe.qb.DocType('Job Card Scrap Item') + + scrap_items = ( + frappe.qb.from_(job_card) + .select( + Sum(job_card_scrap_item.stock_qty).as_('stock_qty'), + job_card_scrap_item.item_code, job_card_scrap_item.item_name, + job_card_scrap_item.description, job_card_scrap_item.stock_uom) + .join(job_card_scrap_item) + .on(job_card_scrap_item.parent == job_card.name) + .where( + (job_card_scrap_item.item_code.isnotnull()) + & (job_card.work_order == self.work_order) + & (job_card.docstatus == 1)) + .groupby(job_card_scrap_item.item_code) + ).run(as_dict=1) + + pending_qty = flt(self.get_completed_job_card_qty()) - flt(self.pro_doc.produced_qty) + used_scrap_items = self.get_used_scrap_items() for row in scrap_items: row.stock_qty -= flt(used_scrap_items.get(row.item_code)) @@ -1305,6 +1318,9 @@ class StockEntry(StockController): return scrap_items + def get_completed_job_card_qty(self): + return flt(min([d.completed_qty for d in self.pro_doc.operations])) + def get_used_scrap_items(self): used_scrap_items = defaultdict(float) data = frappe.get_all( diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index 1e0335da73d..3d936c50f7a 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -227,9 +227,47 @@ class TestStockEntry(ERPNextTestCase): mtn.cancel() - def test_repack_no_change_in_valuation(self): - company = frappe.db.get_value('Warehouse', '_Test Warehouse - _TC', 'company') + def test_repack_multiple_fg(self): + "Test `is_finished_item` for one item repacked into two items." + make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=100, basic_rate=100) + repack = frappe.copy_doc(test_records[3]) + repack.posting_date = nowdate() + repack.posting_time = nowtime() + + repack.items[0].qty = 100.0 + repack.items[0].transfer_qty = 100.0 + repack.items[1].qty = 50.0 + + repack.append("items", { + "conversion_factor": 1.0, + "cost_center": "_Test Cost Center - _TC", + "doctype": "Stock Entry Detail", + "expense_account": "Stock Adjustment - _TC", + "basic_rate": 150, + "item_code": "_Test Item 2", + "parentfield": "items", + "qty": 50.0, + "stock_uom": "_Test UOM", + "t_warehouse": "_Test Warehouse - _TC", + "transfer_qty": 50.0, + "uom": "_Test UOM" + }) + repack.set_stock_entry_type() + repack.insert() + + self.assertEqual(repack.items[1].is_finished_item, 1) + self.assertEqual(repack.items[2].is_finished_item, 1) + + repack.items[1].is_finished_item = 0 + repack.items[2].is_finished_item = 0 + + # must raise error if 0 fg in repack entry + self.assertRaises(FinishedGoodError, repack.validate_finished_goods) + + repack.delete() # teardown + + def test_repack_no_change_in_valuation(self): make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=50, basic_rate=100) make_stock_entry(item_code="_Test Item Home Desktop 100", target="_Test Warehouse - _TC", qty=50, basic_rate=100) @@ -814,6 +852,34 @@ class TestStockEntry(ERPNextTestCase): self.assertEqual(se.get("items")[0].allow_zero_valuation_rate, 1) self.assertEqual(se.get("items")[0].amount, 0) + def test_zero_incoming_rate(self): + """ Make sure incoming rate of 0 is allowed while consuming. + + qty | rate | valuation rate + 1 | 100 | 100 + 1 | 0 | 50 + -1 | 100 | 0 + -1 | 0 <--- assert this + """ + item_code = "_TestZeroVal" + warehouse = "_Test Warehouse - _TC" + create_item('_TestZeroVal') + _receipt = make_stock_entry(item_code=item_code, qty=1, to_warehouse=warehouse, rate=100) + receipt2 = make_stock_entry(item_code=item_code, qty=1, to_warehouse=warehouse, rate=0, do_not_save=True) + receipt2.items[0].allow_zero_valuation_rate = 1 + receipt2.save() + receipt2.submit() + + issue = make_stock_entry(item_code=item_code, qty=1, from_warehouse=warehouse) + + value_diff = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": issue.name, "voucher_type": "Stock Entry"}, "stock_value_difference") + self.assertEqual(value_diff, -100) + + issue2 = make_stock_entry(item_code=item_code, qty=1, from_warehouse=warehouse) + value_diff = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": issue2.name, "voucher_type": "Stock Entry"}, "stock_value_difference") + self.assertEqual(value_diff, 0) + + def test_gle_for_opening_stock_entry(self): mr = make_stock_entry(item_code="_Test Item", target="Stores - TCP1", company="_Test Company with perpetual inventory", qty=50, basic_rate=100, 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 cafbd7581ce..a1030d54964 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 @@ -5,7 +5,10 @@ import frappe from frappe.core.page.permission_manager.permission_manager import reset from frappe.utils import add_days, today -from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note +from erpnext.stock.doctype.delivery_note.test_delivery_note import ( + create_delivery_note, + create_return_delivery_note, +) from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import ( create_landed_cost_voucher, @@ -232,8 +235,7 @@ class TestStockLedgerEntry(ERPNextTestCase): self.assertEqual(outgoing_rate, 100) # Return Entry: Qty = -2, Rate = 150 - return_dn = create_delivery_note(is_return=1, return_against=dn.name, item_code=bundled_item, qty=-2, rate=150, - company=company, warehouse="Stores - _TC", expense_account="Cost of Goods Sold - _TC", cost_center="Main - _TC") + return_dn = create_return_delivery_note(source_name=dn.name, rate=150, qty=-2) # check incoming rate for Return entry incoming_rate, stock_value_difference = frappe.db.get_value("Stock Ledger Entry", diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py index c4ddc9e2d6f..428370cc758 100644 --- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py @@ -6,7 +6,7 @@ import frappe -from frappe.utils import add_days, flt, nowdate, nowtime, random_string +from frappe.utils import add_days, cstr, flt, nowdate, nowtime, random_string from erpnext.accounts.utils import get_stock_and_account_balance from erpnext.stock.doctype.item.test_item import create_item @@ -439,8 +439,8 @@ class TestStockReconciliation(ERPNextTestCase): self.assertRaises(frappe.ValidationError, sr.submit) def test_serial_no_cancellation(self): - from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry + item = create_item("Stock-Reco-Serial-Item-9", is_stock_item=1) if not item.has_serial_no: item.has_serial_no = 1 @@ -466,6 +466,31 @@ class TestStockReconciliation(ERPNextTestCase): self.assertEqual(len(active_sr_no), 10) + def test_serial_no_creation_and_inactivation(self): + item = create_item("_TestItemCreatedWithStockReco", is_stock_item=1) + if not item.has_serial_no: + item.has_serial_no = 1 + item.save() + + item_code = item.name + warehouse = "_Test Warehouse - _TC" + + sr = create_stock_reconciliation(item_code=item.name, warehouse=warehouse, + serial_no="SR-CREATED-SR-NO", qty=1, do_not_submit=True, rate=100) + sr.save() + self.assertEqual(cstr(sr.items[0].current_serial_no), "") + sr.submit() + + active_sr_no = frappe.get_all("Serial No", + filters={"item_code": item_code, "warehouse": warehouse, "status": "Active"}) + self.assertEqual(len(active_sr_no), 1) + + sr.cancel() + active_sr_no = frappe.get_all("Serial No", + filters={"item_code": item_code, "warehouse": warehouse, "status": "Active"}) + self.assertEqual(len(active_sr_no), 0) + + def create_batch_item_with_batch(item_name, batch_id): batch_item_doc = create_item(item_name, is_stock_item=1) if not batch_item_doc.has_batch_no: diff --git a/erpnext/stock/report/batch_item_expiry_status/batch_item_expiry_status.py b/erpnext/stock/report/batch_item_expiry_status/batch_item_expiry_status.py index 44e13869ddb..87097c72fa4 100644 --- a/erpnext/stock/report/batch_item_expiry_status/batch_item_expiry_status.py +++ b/erpnext/stock/report/batch_item_expiry_status/batch_item_expiry_status.py @@ -55,7 +55,8 @@ def get_stock_ledger_entries(filters): return frappe.db.sql("""select item_code, batch_no, warehouse, posting_date, actual_qty from `tabStock Ledger Entry` - where docstatus < 2 and ifnull(batch_no, '') != '' %s order by item_code, warehouse""" % + where is_cancelled = 0 + and docstatus < 2 and ifnull(batch_no, '') != '' %s order by item_code, warehouse""" % conditions, as_dict=1) def get_item_warehouse_batch_map(filters, float_precision): diff --git a/erpnext/stock/report/cogs_by_item_group/cogs_by_item_group.py b/erpnext/stock/report/cogs_by_item_group/cogs_by_item_group.py index 5f6184d6f35..058af77aa21 100644 --- a/erpnext/stock/report/cogs_by_item_group/cogs_by_item_group.py +++ b/erpnext/stock/report/cogs_by_item_group/cogs_by_item_group.py @@ -91,7 +91,7 @@ def get_stock_value_difference_list(filtered_entries: FilteredEntries) -> SVDLis voucher_nos = [fe.get('voucher_no') for fe in filtered_entries] svd_list = frappe.get_list( 'Stock Ledger Entry', fields=['item_code','stock_value_difference'], - filters=[('voucher_no', 'in', voucher_nos)] + filters=[('voucher_no', 'in', voucher_nos), ("is_cancelled", "=", 0)] ) assign_item_groups_to_svd_list(svd_list) return svd_list diff --git a/erpnext/stock/report/incorrect_serial_no_valuation/incorrect_serial_no_valuation.py b/erpnext/stock/report/incorrect_serial_no_valuation/incorrect_serial_no_valuation.py index 5f03c7cbe0a..e4ea9947166 100644 --- a/erpnext/stock/report/incorrect_serial_no_valuation/incorrect_serial_no_valuation.py +++ b/erpnext/stock/report/incorrect_serial_no_valuation/incorrect_serial_no_valuation.py @@ -74,7 +74,7 @@ def get_stock_ledger_entries(report_filters): fields = ['name', 'voucher_type', 'voucher_no', 'item_code', 'serial_no as serial_nos', 'actual_qty', 'posting_date', 'posting_time', 'company', 'warehouse', '(stock_value_difference / actual_qty) as valuation_rate'] - filters = {'serial_no': ("is", "set")} + filters = {'serial_no': ("is", "set"), "is_cancelled": 0} if report_filters.get('item_code'): filters['item_code'] = report_filters.get('item_code') diff --git a/erpnext/stock/report/itemwise_recommended_reorder_level/itemwise_recommended_reorder_level.py b/erpnext/stock/report/itemwise_recommended_reorder_level/itemwise_recommended_reorder_level.py index 3f490653e14..cfa1e474c7b 100644 --- a/erpnext/stock/report/itemwise_recommended_reorder_level/itemwise_recommended_reorder_level.py +++ b/erpnext/stock/report/itemwise_recommended_reorder_level/itemwise_recommended_reorder_level.py @@ -76,6 +76,7 @@ def get_consumed_items(condition): on sle.voucher_no = se.name where actual_qty < 0 + and is_cancelled = 0 and voucher_type not in ('Delivery Note', 'Sales Invoice') %s group by item_code""" % condition, as_dict=1) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 456cfe3d76f..b47097553ba 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -7,7 +7,7 @@ import json import frappe from frappe import _ from frappe.model.meta import get_field_precision -from frappe.utils import cint, cstr, flt, get_link_to_form, getdate, now, nowdate +from frappe.utils import cint, cstr, flt, get_datetime, get_link_to_form, getdate, now, nowdate from six import iteritems import erpnext @@ -105,6 +105,7 @@ def get_args_for_future_sle(row): def validate_serial_no(sle): from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos + for sn in get_serial_nos(sle.serial_no): args = copy.deepcopy(sle) args.serial_no = sn @@ -415,6 +416,8 @@ class update_entries_after(object): return sorted(entries_to_fix, key=lambda k: k['timestamp']) def process_sle(self, sle): + from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos + # previous sle data for this warehouse self.wh_data = self.data[sle.warehouse] @@ -429,7 +432,7 @@ class update_entries_after(object): if not self.args.get("sle_id"): self.get_dynamic_incoming_outgoing_rate(sle) - if sle.serial_no: + if get_serial_nos(sle.serial_no): self.get_serialized_values(sle) self.wh_data.qty_after_transaction += flt(sle.actual_qty) if sle.voucher_type == "Stock Reconciliation": @@ -441,8 +444,9 @@ class update_entries_after(object): # assert self.wh_data.valuation_rate = sle.valuation_rate self.wh_data.qty_after_transaction = sle.qty_after_transaction - self.wh_data.stock_queue = [[self.wh_data.qty_after_transaction, self.wh_data.valuation_rate]] self.wh_data.stock_value = flt(self.wh_data.qty_after_transaction) * flt(self.wh_data.valuation_rate) + if self.valuation_method != "Moving Average": + self.wh_data.stock_queue = [[self.wh_data.qty_after_transaction, self.wh_data.valuation_rate]] else: if self.valuation_method == "Moving Average": self.get_moving_average_values(sle) @@ -595,9 +599,9 @@ class update_entries_after(object): incoming_rate = self.wh_data.valuation_rate stock_value_change = 0 - if incoming_rate: + if actual_qty > 0: stock_value_change = actual_qty * incoming_rate - elif actual_qty < 0: + else: # In case of delivery/stock issue, get average purchase rate # of serial nos of current entry if not sle.is_cancelled: @@ -639,6 +643,7 @@ class update_entries_after(object): where company = %s and actual_qty > 0 + and is_cancelled = 0 and (serial_no = %s or serial_no like %s or serial_no like %s @@ -942,6 +947,7 @@ def get_valuation_rate(item_code, warehouse, voucher_type, voucher_no, item_code = %s AND warehouse = %s AND valuation_rate >= 0 + AND is_cancelled = 0 AND NOT (voucher_no = %s AND voucher_type = %s) order by posting_date desc, posting_time desc, name desc limit 1""", (item_code, warehouse, voucher_no, voucher_type)) @@ -952,6 +958,7 @@ def get_valuation_rate(item_code, warehouse, voucher_type, voucher_no, where item_code = %s AND valuation_rate > 0 + AND is_cancelled = 0 AND NOT(voucher_no = %s AND voucher_type = %s) order by posting_date desc, posting_time desc, name desc limit 1""", (item_code, voucher_no, voucher_type)) @@ -1132,26 +1139,31 @@ def get_future_sle_with_negative_qty(args): def get_future_sle_with_negative_batch_qty(args): - return frappe.db.sql(""" - with batch_ledger as ( - select - posting_date, posting_time, voucher_type, voucher_no, - sum(actual_qty) over (order by posting_date, posting_time, creation) as cumulative_total - from `tabStock Ledger Entry` - where - item_code = %(item_code)s - and warehouse = %(warehouse)s - and batch_no=%(batch_no)s - and is_cancelled = 0 - order by posting_date, posting_time, creation - ) - select * from batch_ledger + batch_ledger = frappe.db.sql(""" + select + posting_date, posting_time, voucher_type, voucher_no, actual_qty + from `tabStock Ledger Entry` where - cumulative_total < 0.0 - and timestamp(posting_date, posting_time) >= timestamp(%(posting_date)s, %(posting_time)s) - limit 1 + item_code = %(item_code)s + and warehouse = %(warehouse)s + and batch_no=%(batch_no)s + and is_cancelled = 0 + order by timestamp(posting_date, posting_time), creation """, args, as_dict=1) + cumulative_total = 0.0 + current_posting_datetime = get_datetime(str(args.posting_date) + " " + str(args.posting_time)) + for entry in batch_ledger: + cumulative_total += entry.actual_qty + if cumulative_total > -1e-6: + continue + + if (get_datetime(str(entry.posting_date) + " " + str(entry.posting_time)) + >= current_posting_datetime): + + entry.cumulative_total = cumulative_total + return [entry] + def _round_off_if_near_zero(number: float, precision: int = 6) -> float: """ Rounds off the number to zero only if number is close to zero for decimal diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py index 4a8c97fb10a..4d77367a051 100644 --- a/erpnext/stock/utils.py +++ b/erpnext/stock/utils.py @@ -104,7 +104,7 @@ def get_stock_balance(item_code, warehouse, posting_date=None, posting_time=None serial_nos = get_serial_nos_data_after_transactions(args) return ((last_entry.qty_after_transaction, last_entry.valuation_rate, serial_nos) - if last_entry else (0.0, 0.0, 0.0)) + if last_entry else (0.0, 0.0, None)) else: return (last_entry.qty_after_transaction, last_entry.valuation_rate) if last_entry else (0.0, 0.0) else: @@ -420,6 +420,19 @@ def is_reposting_item_valuation_in_progress(): if reposting_in_progress: frappe.msgprint(_("Item valuation reposting in progress. Report might show incorrect item valuation."), alert=1) + +def calculate_mapped_packed_items_return(return_doc): + parent_items = set([item.parent_item for item in return_doc.packed_items]) + against_doc = frappe.get_doc(return_doc.doctype, return_doc.return_against) + + for original_bundle, returned_bundle in zip(against_doc.items, return_doc.items): + if original_bundle.item_code in parent_items: + for returned_packed_item, original_packed_item in zip(return_doc.packed_items, against_doc.packed_items): + if returned_packed_item.parent_item == original_bundle.item_code: + returned_packed_item.parent_detail_docname = returned_bundle.name + returned_packed_item.qty = (original_packed_item.qty / original_bundle.qty) * returned_bundle.qty + + def check_pending_reposting(posting_date: str, throw_error: bool = True) -> bool: """Check if there are pending reposting job till the specified posting date.""" diff --git a/erpnext/templates/generators/item/item_configure.js b/erpnext/templates/generators/item/item_configure.js index b5f92989ef4..231ae0587ed 100644 --- a/erpnext/templates/generators/item/item_configure.js +++ b/erpnext/templates/generators/item/item_configure.js @@ -214,7 +214,7 @@ class ItemConfigure { ? `