diff --git a/.flake8 b/.flake8
index 56c9b9a3699..5735456ae7d 100644
--- a/.flake8
+++ b/.flake8
@@ -28,6 +28,7 @@ ignore =
B007,
B950,
W191,
+ E124, # closing bracket, irritating while writing QB code
max-line-length = 200
exclude=.github/helper/semgrep_rules
diff --git a/.github/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/opening_invoice_creation_tool/opening_invoice_creation_tool.py b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py
index a8d7bf7a0e7..00eecd3a4f4 100644
--- a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py
+++ b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py
@@ -135,7 +135,7 @@ class OpeningInvoiceCreationTool(Document):
default_uom = frappe.db.get_single_value("Stock Settings", "stock_uom") or _("Nos")
rate = flt(row.outstanding_amount) / flt(row.qty)
- return frappe._dict({
+ item_dict = frappe._dict({
"uom": default_uom,
"rate": rate or 0.0,
"qty": row.qty,
@@ -146,6 +146,13 @@ class OpeningInvoiceCreationTool(Document):
"cost_center": cost_center
})
+ for dimension in get_accounting_dimensions():
+ item_dict.update({
+ dimension: row.get(dimension)
+ })
+
+ return item_dict
+
item = get_item_dict()
invoice = frappe._dict({
@@ -166,7 +173,7 @@ class OpeningInvoiceCreationTool(Document):
accounting_dimension = get_accounting_dimensions()
for dimension in accounting_dimension:
invoice.update({
- dimension: item.get(dimension)
+ dimension: self.get(dimension) or item.get(dimension)
})
return invoice
diff --git a/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py b/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py
index b5aae9845b6..6700e9b975d 100644
--- a/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py
+++ b/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py
@@ -7,21 +7,26 @@ import frappe
from frappe.cache_manager import clear_doctype_cache
from frappe.custom.doctype.property_setter.property_setter import make_property_setter
+from erpnext.accounts.doctype.accounting_dimension.test_accounting_dimension import (
+ create_dimension,
+ disable_dimension,
+)
from erpnext.accounts.doctype.opening_invoice_creation_tool.opening_invoice_creation_tool import (
get_temporary_opening_account,
)
-test_dependencies = ["Customer", "Supplier"]
+test_dependencies = ["Customer", "Supplier", "Accounting Dimension"]
class TestOpeningInvoiceCreationTool(unittest.TestCase):
def setUp(self):
if not frappe.db.exists("Company", "_Test Opening Invoice Company"):
make_company()
+ create_dimension()
- def make_invoices(self, invoice_type="Sales", company=None, party_1=None, party_2=None, invoice_number=None):
+ def make_invoices(self, invoice_type="Sales", company=None, party_1=None, party_2=None, invoice_number=None, department=None):
doc = frappe.get_single("Opening Invoice Creation Tool")
args = get_opening_invoice_creation_dict(invoice_type=invoice_type, company=company,
- party_1=party_1, party_2=party_2, invoice_number=invoice_number)
+ party_1=party_1, party_2=party_2, invoice_number=invoice_number, department=department)
doc.update(args)
return doc.make_invoices()
@@ -106,6 +111,19 @@ class TestOpeningInvoiceCreationTool(unittest.TestCase):
doc = frappe.get_doc('Sales Invoice', inv)
doc.cancel()
+ def test_opening_invoice_with_accounting_dimension(self):
+ invoices = self.make_invoices(invoice_type="Sales", company="_Test Opening Invoice Company", department='Sales - _TOIC')
+
+ expected_value = {
+ "keys": ["customer", "outstanding_amount", "status", "department"],
+ 0: ["_Test Customer", 300, "Overdue", "Sales - _TOIC"],
+ 1: ["_Test Customer 1", 250, "Overdue", "Sales - _TOIC"],
+ }
+ self.check_expected_values(invoices, expected_value, invoice_type="Sales")
+
+ def tearDown(self):
+ disable_dimension()
+
def get_opening_invoice_creation_dict(**args):
party = "Customer" if args.get("invoice_type", "Sales") == "Sales" else "Supplier"
company = args.get("company", "_Test Company")
diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py
index c8c214500c9..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)
diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
index 5b6d3915119..8927ce3d6ac 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', [])
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/party.py b/erpnext/accounts/party.py
index a1c34a87ba8..4f4f180e542 100644
--- a/erpnext/accounts/party.py
+++ b/erpnext/accounts/party.py
@@ -59,7 +59,7 @@ def _get_party_details(party=None, account=None, party_type="Customer", company=
frappe.throw(_("Not permitted for {0}").format(party), frappe.PermissionError)
party = frappe.get_doc(party_type, party)
- currency = party.default_currency if party.get("default_currency") else get_company_currency(company)
+ currency = party.get("default_currency") or currency or get_company_currency(company)
party_address, shipping_address = set_address_details(party_details, party, party_type, doctype, company, party_address, company_address, shipping_address)
set_contact_details(party_details, party, party_type)
diff --git a/erpnext/accounts/report/deferred_revenue_and_expense/test_deferred_revenue_and_expense.py b/erpnext/accounts/report/deferred_revenue_and_expense/test_deferred_revenue_and_expense.py
index 1de6fb68241..86eb2134fe8 100644
--- a/erpnext/accounts/report/deferred_revenue_and_expense/test_deferred_revenue_and_expense.py
+++ b/erpnext/accounts/report/deferred_revenue_and_expense/test_deferred_revenue_and_expense.py
@@ -17,10 +17,42 @@ from erpnext.stock.doctype.item.test_item import create_item
class TestDeferredRevenueAndExpense(unittest.TestCase):
@classmethod
def setUpClass(self):
- clear_old_entries()
+ clear_accounts_and_items()
create_company()
+ self.maxDiff = None
+
+ def clear_old_entries(self):
+ sinv = qb.DocType("Sales Invoice")
+ sinv_item = qb.DocType("Sales Invoice Item")
+ pinv = qb.DocType("Purchase Invoice")
+ pinv_item = qb.DocType("Purchase Invoice Item")
+
+ # delete existing invoices with deferred items
+ deferred_invoices = (
+ qb.from_(sinv)
+ .join(sinv_item)
+ .on(sinv.name == sinv_item.parent)
+ .select(sinv.name)
+ .where(sinv_item.enable_deferred_revenue == 1)
+ .run()
+ )
+ if deferred_invoices:
+ qb.from_(sinv).delete().where(sinv.name.isin(deferred_invoices)).run()
+
+ deferred_invoices = (
+ qb.from_(pinv)
+ .join(pinv_item)
+ .on(pinv.name == pinv_item.parent)
+ .select(pinv.name)
+ .where(pinv_item.enable_deferred_expense == 1)
+ .run()
+ )
+ if deferred_invoices:
+ qb.from_(pinv).delete().where(pinv.name.isin(deferred_invoices)).run()
def test_deferred_revenue(self):
+ self.clear_old_entries()
+
# created deferred expense accounts, if not found
deferred_revenue_account = create_account(
account_name="Deferred Revenue",
@@ -108,6 +140,8 @@ class TestDeferredRevenueAndExpense(unittest.TestCase):
self.assertEqual(report.period_total, expected)
def test_deferred_expense(self):
+ self.clear_old_entries()
+
# created deferred expense accounts, if not found
deferred_expense_account = create_account(
account_name="Deferred Expense",
@@ -198,6 +232,91 @@ class TestDeferredRevenueAndExpense(unittest.TestCase):
]
self.assertEqual(report.period_total, expected)
+ def test_zero_months(self):
+ self.clear_old_entries()
+ # created deferred expense accounts, if not found
+ deferred_revenue_account = create_account(
+ account_name="Deferred Revenue",
+ parent_account="Current Liabilities - _CD",
+ company="_Test Company DR",
+ )
+
+ acc_settings = frappe.get_doc("Accounts Settings", "Accounts Settings")
+ acc_settings.book_deferred_entries_based_on = "Months"
+ acc_settings.save()
+
+ customer = frappe.new_doc("Customer")
+ customer.customer_name = "_Test Customer DR"
+ customer.type = "Individual"
+ customer.insert()
+
+ item = create_item(
+ "_Test Internet Subscription",
+ is_stock_item=0,
+ warehouse="All Warehouses - _CD",
+ company="_Test Company DR",
+ )
+ item.enable_deferred_revenue = 1
+ item.deferred_revenue_account = deferred_revenue_account
+ item.no_of_months = 0
+ item.save()
+
+ si = create_sales_invoice(
+ item=item.name,
+ company="_Test Company DR",
+ customer="_Test Customer DR",
+ debit_to="Debtors - _CD",
+ posting_date="2021-05-01",
+ parent_cost_center="Main - _CD",
+ cost_center="Main - _CD",
+ do_not_submit=True,
+ rate=300,
+ price_list_rate=300,
+ )
+ si.items[0].enable_deferred_revenue = 1
+ si.items[0].deferred_revenue_account = deferred_revenue_account
+ si.items[0].income_account = "Sales - _CD"
+ si.save()
+ si.submit()
+
+ pda = frappe.get_doc(
+ dict(
+ doctype="Process Deferred Accounting",
+ posting_date=nowdate(),
+ start_date="2021-05-01",
+ end_date="2021-08-01",
+ type="Income",
+ company="_Test Company DR",
+ )
+ )
+ pda.insert()
+ pda.submit()
+
+ # execute report
+ fiscal_year = frappe.get_doc("Fiscal Year", frappe.defaults.get_user_default("fiscal_year"))
+ self.filters = frappe._dict(
+ {
+ "company": frappe.defaults.get_user_default("Company"),
+ "filter_based_on": "Date Range",
+ "period_start_date": "2021-05-01",
+ "period_end_date": "2021-08-01",
+ "from_fiscal_year": fiscal_year.year,
+ "to_fiscal_year": fiscal_year.year,
+ "periodicity": "Monthly",
+ "type": "Revenue",
+ "with_upcoming_postings": False,
+ }
+ )
+
+ report = Deferred_Revenue_and_Expense_Report(filters=self.filters)
+ report.run()
+ expected = [
+ {"key": "may_2021", "total": 300.0, "actual": 300.0},
+ {"key": "jun_2021", "total": 0, "actual": 0},
+ {"key": "jul_2021", "total": 0, "actual": 0},
+ {"key": "aug_2021", "total": 0, "actual": 0},
+ ]
+ self.assertEqual(report.period_total, expected)
def create_company():
company = frappe.db.exists("Company", "_Test Company DR")
@@ -209,15 +328,11 @@ def create_company():
company.insert()
-def clear_old_entries():
+def clear_accounts_and_items():
item = qb.DocType("Item")
account = qb.DocType("Account")
customer = qb.DocType("Customer")
supplier = qb.DocType("Supplier")
- sinv = qb.DocType("Sales Invoice")
- sinv_item = qb.DocType("Sales Invoice Item")
- pinv = qb.DocType("Purchase Invoice")
- pinv_item = qb.DocType("Purchase Invoice Item")
qb.from_(account).delete().where(
(account.account_name == "Deferred Revenue")
@@ -228,26 +343,3 @@ def clear_old_entries():
).run()
qb.from_(customer).delete().where(customer.customer_name == "_Test Customer DR").run()
qb.from_(supplier).delete().where(supplier.supplier_name == "_Test Furniture Supplier").run()
-
- # delete existing invoices with deferred items
- deferred_invoices = (
- qb.from_(sinv)
- .join(sinv_item)
- .on(sinv.name == sinv_item.parent)
- .select(sinv.name)
- .where(sinv_item.enable_deferred_revenue == 1)
- .run()
- )
- if deferred_invoices:
- qb.from_(sinv).delete().where(sinv.name.isin(deferred_invoices)).run()
-
- deferred_invoices = (
- qb.from_(pinv)
- .join(pinv_item)
- .on(pinv.name == pinv_item.parent)
- .select(pinv.name)
- .where(pinv_item.enable_deferred_expense == 1)
- .run()
- )
- if deferred_invoices:
- qb.from_(pinv).delete().where(pinv.name.isin(deferred_invoices)).run()
diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py
index a57a3e281d3..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):
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 2a95553fbf2..b7b198eac4c 100644
--- a/erpnext/controllers/accounts_controller.py
+++ b/erpnext/controllers/accounts_controller.py
@@ -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/e_commerce/shopping_cart/cart.py b/erpnext/e_commerce/shopping_cart/cart.py
index 03aa36fa883..75366e9f33a 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.web_item_name = variant_data.item_name
+
+ if variant_data.image: # get image from variant or template web item
+ d.thumbnail = variant_data.image
+ fields = fields[2:]
+
d.update(frappe.db.get_value(
"Website Item",
- {"item_code": 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..578f3893490 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,43 @@ class TestShoppingCart(unittest.TestCase):
self.remove_test_quotation(quotation)
+ @change_settings("E Commerce Settings",{
+ "company": "_Test Company",
+ "enabled": 1,
+ "default_customer_group": "_Test Customer Group",
+ "price_list": "_Test Price List India",
+ "show_price": 1
+ })
+ def test_add_item_variant_without_web_item_to_cart(self):
+ "Test adding Variants having no Website Items in cart via Template Web Item."
+ from erpnext.controllers.item_variant import create_variant
+ from erpnext.e_commerce.doctype.website_item.website_item import make_website_item
+ from erpnext.stock.doctype.item.test_item import make_item
+
+ template_item = make_item("Test-Tshirt-Temp", {
+ "has_variant": 1,
+ "variant_based_on": "Item Attribute",
+ "attributes": [
+ {"attribute": "Test Size"},
+ {"attribute": "Test Colour"}
+ ]
+ })
+ variant = create_variant("Test-Tshirt-Temp", {
+ "Test Size": "Small", "Test Colour": "Red"
+ })
+ variant.save()
+ make_website_item(template_item) # publish template not variant
+
+ update_cart("Test-Tshirt-Temp-S-R", 1)
+
+ cart = get_cart_quotation() # test if cart page gets data without errors
+ doc = cart.get("doc")
+
+ self.assertEqual(doc.get("items")[0].item_name, "Test-Tshirt-Temp-S-R")
+
+ # test if items are rendered without error
+ frappe.render_template("templates/includes/cart/cart_items.html", cart)
+
def create_tax_rule(self):
tax_rule = frappe.get_test_records("Tax Rule")[0]
try:
diff --git a/erpnext/e_commerce/variant_selector/test_variant_selector.py b/erpnext/e_commerce/variant_selector/test_variant_selector.py
index 31c86f1fd53..0412abb4d9f 100644
--- a/erpnext/e_commerce/variant_selector/test_variant_selector.py
+++ b/erpnext/e_commerce/variant_selector/test_variant_selector.py
@@ -3,24 +3,26 @@ import unittest
import frappe
from erpnext.controllers.item_variant import create_variant
+from erpnext.e_commerce.doctype.e_commerce_settings.test_e_commerce_settings import (
+ setup_e_commerce_settings,
+)
from erpnext.e_commerce.doctype.website_item.website_item import make_website_item
+from erpnext.e_commerce.variant_selector.utils import get_next_attribute_and_values
from erpnext.stock.doctype.item.test_item import make_item
+from erpnext.tests.utils import ERPNextTestCase
test_dependencies = ["Item"]
-class TestVariantSelector(unittest.TestCase):
+class TestVariantSelector(ERPNextTestCase):
- def setUp(self) -> None:
- self.template_item = make_item("Test-Tshirt-Temp", {
+ @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"
- }
+ {"attribute": "Test Size"},
+ {"attribute": "Test Colour"}
]
})
@@ -28,19 +30,16 @@ class TestVariantSelector(unittest.TestCase):
for size in ("Large", "Medium",):
for colour in ("Red", "Green",):
variant = create_variant("Test-Tshirt-Temp", {
- "Test Size": size,
- "Test Colour": colour
+ "Test Size": size, "Test Colour": colour
})
variant.save()
variant = create_variant("Test-Tshirt-Temp", {
- "Test Size": "Small",
- "Test Colour": "Red"
+ "Test Size": "Small", "Test Colour": "Red"
})
variant.save()
- def tearDown(self):
- frappe.db.rollback()
+ make_website_item(template_item) # publish template not variants
def test_item_attributes(self):
"""
@@ -51,8 +50,6 @@ class TestVariantSelector(unittest.TestCase):
"""
from erpnext.e_commerce.variant_selector.utils import get_attributes_and_values
- make_website_item(self.template_item) # publish template not variants
-
attr_data = get_attributes_and_values("Test-Tshirt-Temp")
self.assertEqual(attr_data[0]["attribute"], "Test Size")
@@ -72,7 +69,7 @@ class TestVariantSelector(unittest.TestCase):
self.assertEqual(len(attr_data[0]["values"]), 2) # ['Medium', 'Large']
# teardown
- small_variant.disabled = 1
+ small_variant.disabled = 0
small_variant.save()
def test_next_item_variant_values(self):
@@ -84,8 +81,6 @@ class TestVariantSelector(unittest.TestCase):
There is a ** Small-Red ** Tshirt. No other colour in this size.
On selecting ** Small **, only ** Red ** should be selectable next.
"""
- from erpnext.e_commerce.variant_selector.utils import get_next_attribute_and_values
-
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"]
@@ -94,3 +89,31 @@ class TestVariantSelector(unittest.TestCase):
self.assertEqual(next_colours.pop(), "Red")
self.assertEqual(len(filtered_items), 1)
self.assertEqual(filtered_items.pop(), "Test-Tshirt-Temp-S-R")
+
+ def test_exact_match_with_price(self):
+ """
+ Test price fetching and matching of variant without Website Item
+ """
+ from erpnext.e_commerce.doctype.website_item.test_website_item import make_web_item_price
+
+ frappe.set_user("Administrator")
+ setup_e_commerce_settings({
+ "company": "_Test Company",
+ "enabled": 1,
+ "default_customer_group": "_Test Customer Group",
+ "price_list": "_Test Price List India",
+ "show_price": 1
+ })
+
+ make_web_item_price(item_code="Test-Tshirt-Temp-S-R", price_list_rate=100)
+ next_values = get_next_attribute_and_values(
+ "Test-Tshirt-Temp",
+ selected_attributes={"Test Size": "Small", "Test Colour": "Red"}
+ )
+ print(">>>>", next_values)
+ price_info = next_values["product_info"]["price"]
+
+ self.assertEqual(next_values["exact_match"][0],"Test-Tshirt-Temp-S-R")
+ self.assertEqual(next_values["exact_match"][0],"Test-Tshirt-Temp-S-R")
+ self.assertEqual(price_info["price_list_rate"], 100.0)
+ self.assertEqual(price_info["formatted_price_sales_uom"], "₹ 100.00")
\ No newline at end of file
diff --git a/erpnext/e_commerce/variant_selector/utils.py b/erpnext/e_commerce/variant_selector/utils.py
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/lab_test/lab_test.json b/erpnext/healthcare/doctype/lab_test/lab_test.json
index ac61fea3ad7..cdf27aa06b3 100644
--- a/erpnext/healthcare/doctype/lab_test/lab_test.json
+++ b/erpnext/healthcare/doctype/lab_test/lab_test.json
@@ -99,7 +99,6 @@
"search_index": 1
},
{
- "fetch_from": "inpatient_record.patient",
"fieldname": "patient",
"fieldtype": "Link",
"ignore_user_permissions": 1,
@@ -559,7 +558,7 @@
],
"is_submittable": 1,
"links": [],
- "modified": "2020-11-30 11:04:17.195848",
+ "modified": "2022-01-20 12:37:07.943153",
"modified_by": "Administrator",
"module": "Healthcare",
"name": "Lab Test",
diff --git a/erpnext/healthcare/doctype/patient_appointment/patient_appointment.js b/erpnext/healthcare/doctype/patient_appointment/patient_appointment.js
index bba001c9c0e..ba862783caa 100644
--- a/erpnext/healthcare/doctype/patient_appointment/patient_appointment.js
+++ b/erpnext/healthcare/doctype/patient_appointment/patient_appointment.js
@@ -337,9 +337,13 @@ let check_and_set_availability = function(frm) {
});
d.fields_dict['department'].df.onchange = () => {
- d.set_values({
- 'practitioner': ''
- });
+ if (d.get_value('department') == frm.doc.department) {
+ d.set_value('practitioner', frm.doc.practitioner);
+ } else {
+ d.set_value('practitioner', '');
+ d.fields_dict.available_slots.html('');
+ d.get_primary_btn().attr('disabled', true);
+ }
let department = d.get_value('department');
if (department) {
d.fields_dict.practitioner.get_query = function() {
@@ -426,7 +430,8 @@ let check_and_set_availability = function(frm) {
slot_details.forEach((slot_info) => {
slot_html += `
-
${__('Practitioner Schedule:')} ${slot_info.slot_name}
+
${slot_info.practitioner_name}
+
${__('Schedule:')} ${slot_info.slot_name}
${__('Service Unit:')} ${slot_info.service_unit} `;
if (slot_info.service_unit_capacity) {
diff --git a/erpnext/healthcare/doctype/patient_appointment/patient_appointment.py b/erpnext/healthcare/doctype/patient_appointment/patient_appointment.py
index 1e4608f84e0..c4f253a062f 100755
--- a/erpnext/healthcare/doctype/patient_appointment/patient_appointment.py
+++ b/erpnext/healthcare/doctype/patient_appointment/patient_appointment.py
@@ -388,7 +388,8 @@ def get_available_slots(practitioner_doc, date):
fields=['name', 'appointment_time', 'duration', 'status'])
slot_details.append({'slot_name': slot_name, 'service_unit': schedule_entry.service_unit, 'avail_slot': available_slots,
- 'appointments': appointments, 'allow_overlap': allow_overlap, 'service_unit_capacity': service_unit_capacity})
+ 'appointments': appointments, 'allow_overlap': allow_overlap, 'service_unit_capacity': service_unit_capacity,
+ 'practitioner_name': practitioner_doc.practitioner_name})
return slot_details
diff --git a/erpnext/healthcare/doctype/sample_collection/sample_collection.json b/erpnext/healthcare/doctype/sample_collection/sample_collection.json
index 83383e34457..f8525f7e14b 100644
--- a/erpnext/healthcare/doctype/sample_collection/sample_collection.json
+++ b/erpnext/healthcare/doctype/sample_collection/sample_collection.json
@@ -66,7 +66,6 @@
"search_index": 1
},
{
- "fetch_from": "inpatient_record.patient",
"fieldname": "patient",
"fieldtype": "Link",
"hide_days": 1,
@@ -224,7 +223,7 @@
],
"is_submittable": 1,
"links": [],
- "modified": "2020-07-30 16:53:13.076104",
+ "modified": "2022-01-20 12:38:55.382621",
"modified_by": "Administrator",
"module": "Healthcare",
"name": "Sample Collection",
diff --git a/erpnext/healthcare/doctype/vital_signs/vital_signs.json b/erpnext/healthcare/doctype/vital_signs/vital_signs.json
index 15ab5047bc4..a945032c7e0 100644
--- a/erpnext/healthcare/doctype/vital_signs/vital_signs.json
+++ b/erpnext/healthcare/doctype/vital_signs/vital_signs.json
@@ -51,7 +51,6 @@
"read_only": 1
},
{
- "fetch_from": "inpatient_record.patient",
"fieldname": "patient",
"fieldtype": "Link",
"ignore_user_permissions": 1,
@@ -259,7 +258,7 @@
],
"is_submittable": 1,
"links": [],
- "modified": "2020-05-17 22:23:24.632286",
+ "modified": "2022-01-20 12:30:07.515185",
"modified_by": "Administrator",
"module": "Healthcare",
"name": "Vital Signs",
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/employee.py b/erpnext/hr/doctype/employee/employee.py
index 88e5ca9d4c5..8a2950696af 100755
--- a/erpnext/hr/doctype/employee/employee.py
+++ b/erpnext/hr/doctype/employee/employee.py
@@ -68,12 +68,18 @@ class Employee(NestedSet):
self.employee_name = ' '.join(filter(lambda x: x, [self.first_name, self.middle_name, self.last_name]))
def validate_user_details(self):
- data = frappe.db.get_value('User',
- self.user_id, ['enabled', 'user_image'], as_dict=1)
- if data.get("user_image") and self.image == '':
- self.image = data.get("user_image")
- self.validate_for_enabled_user_id(data.get("enabled", 0))
- self.validate_duplicate_user_id()
+ if self.user_id:
+ data = frappe.db.get_value("User",
+ self.user_id, ["enabled", "user_image"], as_dict=1)
+
+ if not data:
+ self.user_id = None
+ return
+
+ if data.get("user_image") and self.image == "":
+ self.image = data.get("user_image")
+ self.validate_for_enabled_user_id(data.get("enabled", 0))
+ self.validate_duplicate_user_id()
def update_nsm_model(self):
frappe.utils.nestedset.update_nsm(self)
diff --git a/erpnext/hr/doctype/leave_application/leave_application.py b/erpnext/hr/doctype/leave_application/leave_application.py
index 1dc5b31461e..70250f5bcf8 100755
--- a/erpnext/hr/doctype/leave_application/leave_application.py
+++ b/erpnext/hr/doctype/leave_application/leave_application.py
@@ -22,6 +22,7 @@ from erpnext.hr.doctype.employee.employee import get_holiday_list_for_employee
from erpnext.hr.doctype.leave_block_list.leave_block_list import get_applicable_block_dates
from erpnext.hr.doctype.leave_ledger_entry.leave_ledger_entry import create_leave_ledger_entry
from erpnext.hr.utils import (
+ get_holiday_dates_for_employee,
get_leave_period,
set_employee_name,
share_doc_with_approver,
@@ -159,33 +160,57 @@ class LeaveApplication(Document):
.format(formatdate(future_allocation[0].from_date), future_allocation[0].name))
def update_attendance(self):
- if self.status == "Approved":
- for dt in daterange(getdate(self.from_date), getdate(self.to_date)):
- date = dt.strftime("%Y-%m-%d")
- status = "Half Day" if self.half_day_date and getdate(date) == getdate(self.half_day_date) else "On Leave"
- attendance_name = frappe.db.exists('Attendance', dict(employee = self.employee,
- attendance_date = date, docstatus = ('!=', 2)))
+ if self.status != "Approved":
+ return
+ holiday_dates = []
+ if not frappe.db.get_value("Leave Type", self.leave_type, "include_holiday"):
+ holiday_dates = get_holiday_dates_for_employee(self.employee, self.from_date, self.to_date)
+
+ for dt in daterange(getdate(self.from_date), getdate(self.to_date)):
+ date = dt.strftime("%Y-%m-%d")
+ attendance_name = frappe.db.exists("Attendance", dict(employee = self.employee,
+ attendance_date = date, docstatus = ('!=', 2)))
+
+ # don't mark attendance for holidays
+ # if leave type does not include holidays within leaves as leaves
+ if date in holiday_dates:
if attendance_name:
- # update existing attendance, change absent to on leave
- doc = frappe.get_doc('Attendance', attendance_name)
- if doc.status != status:
- doc.db_set('status', status)
- doc.db_set('leave_type', self.leave_type)
- doc.db_set('leave_application', self.name)
- else:
- # make new attendance and submit it
- doc = frappe.new_doc("Attendance")
- doc.employee = self.employee
- doc.employee_name = self.employee_name
- doc.attendance_date = date
- doc.company = self.company
- doc.leave_type = self.leave_type
- doc.leave_application = self.name
- doc.status = status
- doc.flags.ignore_validate = True
- doc.insert(ignore_permissions=True)
- doc.submit()
+ # cancel and delete existing attendance for holidays
+ attendance = frappe.get_doc("Attendance", attendance_name)
+ attendance.flags.ignore_permissions = True
+ if attendance.docstatus == 1:
+ attendance.cancel()
+ frappe.delete_doc("Attendance", attendance_name, force=1)
+ continue
+
+ self.create_or_update_attendance(attendance_name, date)
+
+ def create_or_update_attendance(self, attendance_name, date):
+ status = "Half Day" if self.half_day_date and getdate(date) == getdate(self.half_day_date) else "On Leave"
+
+ if attendance_name:
+ # update existing attendance, change absent to on leave
+ doc = frappe.get_doc('Attendance', attendance_name)
+ if doc.status != status:
+ doc.db_set({
+ 'status': status,
+ 'leave_type': self.leave_type,
+ 'leave_application': self.name
+ })
+ else:
+ # make new attendance and submit it
+ doc = frappe.new_doc("Attendance")
+ doc.employee = self.employee
+ doc.employee_name = self.employee_name
+ doc.attendance_date = date
+ doc.company = self.company
+ doc.leave_type = self.leave_type
+ doc.leave_application = self.name
+ doc.status = status
+ doc.flags.ignore_validate = True
+ doc.insert(ignore_permissions=True)
+ doc.submit()
def cancel_attendance(self):
if self.docstatus == 2:
diff --git a/erpnext/hr/doctype/leave_application/test_leave_application.py b/erpnext/hr/doctype/leave_application/test_leave_application.py
index f73d3e52da1..0d2e3989e3e 100644
--- a/erpnext/hr/doctype/leave_application/test_leave_application.py
+++ b/erpnext/hr/doctype/leave_application/test_leave_application.py
@@ -5,7 +5,16 @@ import unittest
import frappe
from frappe.permissions import clear_user_permissions_for_doctype
-from frappe.utils import add_days, add_months, getdate, nowdate
+from frappe.utils import (
+ add_days,
+ add_months,
+ get_first_day,
+ get_last_day,
+ get_year_ending,
+ get_year_start,
+ getdate,
+ nowdate,
+)
from erpnext.hr.doctype.employee.test_employee import make_employee
from erpnext.hr.doctype.leave_allocation.test_leave_allocation import create_leave_allocation
@@ -19,6 +28,10 @@ from erpnext.hr.doctype.leave_policy_assignment.leave_policy_assignment import (
create_assignment_for_multiple_employees,
)
from erpnext.hr.doctype.leave_type.test_leave_type import create_leave_type
+from erpnext.payroll.doctype.salary_slip.test_salary_slip import (
+ make_holiday_list,
+ make_leave_application,
+)
test_dependencies = ["Leave Allocation", "Leave Block List", "Employee"]
@@ -61,13 +74,15 @@ class TestLeaveApplication(unittest.TestCase):
for dt in ["Leave Application", "Leave Allocation", "Salary Slip", "Leave Ledger Entry"]:
frappe.db.sql("DELETE FROM `tab%s`" % dt) #nosec
+ frappe.set_user("Administrator")
+
@classmethod
def setUpClass(cls):
set_leave_approver()
frappe.db.sql("delete from tabAttendance where employee='_T-Employee-00001'")
def tearDown(self):
- frappe.set_user("Administrator")
+ frappe.db.rollback()
def _clear_roles(self):
frappe.db.sql("""delete from `tabHas Role` where parent in
@@ -106,6 +121,72 @@ class TestLeaveApplication(unittest.TestCase):
for d in ('2018-01-01', '2018-01-02', '2018-01-03'):
self.assertTrue(getdate(d) in dates)
+ def test_attendance_for_include_holidays(self):
+ # Case 1: leave type with 'Include holidays within leaves as leaves' enabled
+ frappe.delete_doc_if_exists("Leave Type", "Test Include Holidays", force=1)
+ leave_type = frappe.get_doc(dict(
+ leave_type_name="Test Include Holidays",
+ doctype="Leave Type",
+ include_holiday=True
+ )).insert()
+
+ date = getdate()
+ make_allocation_record(leave_type=leave_type.name, from_date=get_year_start(date), to_date=get_year_ending(date))
+
+ holiday_list = make_holiday_list()
+ frappe.db.set_value("Company", "_Test Company", "default_holiday_list", holiday_list)
+ first_sunday = get_first_sunday(holiday_list)
+
+ leave_application = make_leave_application("_T-Employee-00001", first_sunday, add_days(first_sunday, 3), leave_type.name)
+ leave_application.reload()
+ self.assertEqual(leave_application.total_leave_days, 4)
+ self.assertEqual(frappe.db.count('Attendance', {'leave_application': leave_application.name}), 4)
+
+ leave_application.cancel()
+
+ def test_attendance_update_for_exclude_holidays(self):
+ # Case 2: leave type with 'Include holidays within leaves as leaves' disabled
+ frappe.delete_doc_if_exists("Leave Type", "Test Do Not Include Holidays", force=1)
+ leave_type = frappe.get_doc(dict(
+ leave_type_name="Test Do Not Include Holidays",
+ doctype="Leave Type",
+ include_holiday=False
+ )).insert()
+
+ date = getdate()
+ make_allocation_record(leave_type=leave_type.name, from_date=get_year_start(date), to_date=get_year_ending(date))
+
+ holiday_list = make_holiday_list()
+ frappe.db.set_value("Company", "_Test Company", "default_holiday_list", holiday_list)
+ first_sunday = get_first_sunday(holiday_list)
+
+ # already marked attendance on a holiday should be deleted in this case
+ config = {
+ "doctype": "Attendance",
+ "employee": "_T-Employee-00001",
+ "status": "Present"
+ }
+ attendance_on_holiday = frappe.get_doc(config)
+ attendance_on_holiday.attendance_date = first_sunday
+ attendance_on_holiday.save()
+
+ # already marked attendance on a non-holiday should be updated
+ attendance = frappe.get_doc(config)
+ attendance.attendance_date = add_days(first_sunday, 3)
+ attendance.save()
+
+ leave_application = make_leave_application("_T-Employee-00001", first_sunday, add_days(first_sunday, 3), leave_type.name)
+ leave_application.reload()
+ # holiday should be excluded while marking attendance
+ self.assertEqual(leave_application.total_leave_days, 3)
+ self.assertEqual(frappe.db.count("Attendance", {"leave_application": leave_application.name}), 3)
+
+ # attendance on holiday deleted
+ self.assertFalse(frappe.db.exists("Attendance", attendance_on_holiday.name))
+
+ # attendance on non-holiday updated
+ self.assertEqual(frappe.db.get_value("Attendance", attendance.name, "status"), "On Leave")
+
def test_block_list(self):
self._clear_roles()
@@ -241,7 +322,13 @@ class TestLeaveApplication(unittest.TestCase):
leave_period = get_leave_period()
today = nowdate()
holiday_list = 'Test Holiday List for Optional Holiday'
- optional_leave_date = add_days(today, 7)
+ employee = get_employee()
+
+ default_holiday_list = make_holiday_list()
+ frappe.db.set_value("Company", "_Test Company", "default_holiday_list", default_holiday_list)
+ first_sunday = get_first_sunday(default_holiday_list)
+
+ optional_leave_date = add_days(first_sunday, 1)
if not frappe.db.exists('Holiday List', holiday_list):
frappe.get_doc(dict(
@@ -253,7 +340,6 @@ class TestLeaveApplication(unittest.TestCase):
dict(holiday_date = optional_leave_date, description = 'Test')
]
)).insert()
- employee = get_employee()
frappe.db.set_value('Leave Period', leave_period.name, 'optional_holiday_list', holiday_list)
leave_type = 'Test Optional Type'
@@ -266,7 +352,7 @@ class TestLeaveApplication(unittest.TestCase):
allocate_leaves(employee, leave_period, leave_type, 10)
- date = add_days(today, 6)
+ date = add_days(first_sunday, 2)
leave_application = frappe.get_doc(dict(
doctype = 'Leave Application',
@@ -636,13 +722,13 @@ def create_carry_forwarded_allocation(employee, leave_type):
carry_forward=1)
leave_allocation.submit()
-def make_allocation_record(employee=None, leave_type=None):
+def make_allocation_record(employee=None, leave_type=None, from_date=None, to_date=None):
allocation = frappe.get_doc({
"doctype": "Leave Allocation",
"employee": employee or "_T-Employee-00001",
"leave_type": leave_type or "_Test Leave Type",
- "from_date": "2013-01-01",
- "to_date": "2019-12-31",
+ "from_date": from_date or "2013-01-01",
+ "to_date": to_date or "2019-12-31",
"new_leaves_allocated": 30
})
@@ -691,3 +777,16 @@ def allocate_leaves(employee, leave_period, leave_type, new_leaves_allocated, el
}).insert()
allocate_leave.submit()
+
+
+def get_first_sunday(holiday_list):
+ month_start_date = get_first_day(nowdate())
+ month_end_date = get_last_day(nowdate())
+ first_sunday = frappe.db.sql("""
+ select holiday_date from `tabHoliday`
+ where parent = %s
+ and holiday_date between %s and %s
+ order by holiday_date
+ """, (holiday_list, month_start_date, month_end_date))[0][0]
+
+ return first_sunday
\ No newline at end of file
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 f75038eab34..0b5207ef859 100644
--- a/erpnext/manufacturing/doctype/bom/bom.py
+++ b/erpnext/manufacturing/doctype/bom/bom.py
@@ -149,6 +149,7 @@ class BOM(WebsiteGenerator):
self.set_bom_material_details()
self.set_bom_scrap_items_detail()
self.validate_materials()
+ self.validate_transfer_against()
self.set_routing_operations()
self.validate_operations()
self.calculate_cost()
@@ -682,6 +683,12 @@ class BOM(WebsiteGenerator):
if act_pbom and act_pbom[0][0]:
frappe.throw(_("Cannot deactivate or cancel BOM as it is linked with other BOMs"))
+ def validate_transfer_against(self):
+ if not self.with_operations:
+ self.transfer_material_against = "Work Order"
+ if not self.transfer_material_against and not self.is_new():
+ frappe.throw(_("Setting {} is required").format(self.meta.get_label("transfer_material_against")), title=_("Missing value"))
+
def set_routing_operations(self):
if self.routing and self.with_operations and not self.operations:
self.get_routing()
diff --git a/erpnext/manufacturing/doctype/bom/test_bom.py b/erpnext/manufacturing/doctype/bom/test_bom.py
index 2f9804d1d4a..bfafacdfb57 100644
--- a/erpnext/manufacturing/doctype/bom/test_bom.py
+++ b/erpnext/manufacturing/doctype/bom/test_bom.py
@@ -356,6 +356,36 @@ class TestBOM(ERPNextTestCase):
self.assertTrue(0 < len(filtered) <= 3, msg="Item filtering showing excessive results")
+ def test_valid_transfer_defaults(self):
+ bom_with_op = frappe.db.get_value("BOM", {"item": "_Test FG Item 2", "with_operations": 1, "is_active": 1})
+ bom = frappe.copy_doc(frappe.get_doc("BOM", bom_with_op), ignore_no_copy=False)
+
+ # test defaults
+ bom.docstatus = 0
+ bom.transfer_material_against = None
+ bom.insert()
+ self.assertEqual(bom.transfer_material_against, "Work Order")
+
+ bom.reload()
+ bom.transfer_material_against = None
+ with self.assertRaises(frappe.ValidationError):
+ bom.save()
+ bom.reload()
+
+ # test saner default
+ bom.transfer_material_against = "Job Card"
+ bom.with_operations = 0
+ bom.save()
+ self.assertEqual(bom.transfer_material_against, "Work Order")
+
+ # test no value on existing doc
+ bom.transfer_material_against = None
+ bom.with_operations = 0
+ bom.save()
+ self.assertEqual(bom.transfer_material_against, "Work Order")
+ bom.delete()
+
+
def get_default_bom(item_code="_Test FG Item 2"):
return frappe.db.get_value("BOM", {"item": item_code, "is_active": 1, "is_default": 1})
diff --git a/erpnext/manufacturing/doctype/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/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py
index 1c9cd0cbf4d..f3beabddcf9 100644
--- a/erpnext/manufacturing/doctype/work_order/test_work_order.py
+++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py
@@ -870,6 +870,57 @@ class TestWorkOrder(ERPNextTestCase):
close_work_order(wo_order, "Closed")
self.assertEqual(wo_order.get('status'), "Closed")
+ def test_partial_manufacture_entries(self):
+ cancel_stock_entry = []
+
+ frappe.db.set_value("Manufacturing Settings", None,
+ "backflush_raw_materials_based_on", "Material Transferred for Manufacture")
+
+ wo_order = make_wo_order_test_record(planned_start_date=now(), qty=100)
+ ste1 = test_stock_entry.make_stock_entry(item_code="_Test Item",
+ target="_Test Warehouse - _TC", qty=120, basic_rate=5000.0)
+
+ ste2 = test_stock_entry.make_stock_entry(item_code="_Test Item Home Desktop 100",
+ target="_Test Warehouse - _TC", qty=240, basic_rate=1000.0)
+
+ cancel_stock_entry.extend([ste1.name, ste2.name])
+
+ sm = frappe.get_doc(make_stock_entry(wo_order.name, "Material Transfer for Manufacture", 100))
+ for row in sm.get('items'):
+ if row.get('item_code') == '_Test Item':
+ row.qty = 110
+
+ sm.submit()
+ cancel_stock_entry.append(sm.name)
+
+ s = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 90))
+ for row in s.get('items'):
+ if row.get('item_code') == '_Test Item':
+ self.assertEqual(row.get('qty'), 100)
+
+ s.submit()
+ cancel_stock_entry.append(s.name)
+
+ s1 = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 5))
+ for row in s1.get('items'):
+ if row.get('item_code') == '_Test Item':
+ self.assertEqual(row.get('qty'), 5)
+ s1.submit()
+ cancel_stock_entry.append(s1.name)
+
+ s2 = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 5))
+ for row in s2.get('items'):
+ if row.get('item_code') == '_Test Item':
+ self.assertEqual(row.get('qty'), 5)
+
+ cancel_stock_entry.reverse()
+ for ste in cancel_stock_entry:
+ doc = frappe.get_doc("Stock Entry", ste)
+ doc.cancel()
+
+ frappe.db.set_value("Manufacturing Settings", None,
+ "backflush_raw_materials_based_on", "BOM")
+
def update_job_card(job_card, jc_qty=None):
employee = frappe.db.get_value('Employee', {'status': 'Active'}, 'name')
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/doctype/work_order/work_order.json b/erpnext/manufacturing/doctype/work_order/work_order.json
index 12cd58f418b..9452a63d70b 100644
--- a/erpnext/manufacturing/doctype/work_order/work_order.json
+++ b/erpnext/manufacturing/doctype/work_order/work_order.json
@@ -333,12 +333,13 @@
"options": "fa fa-wrench"
},
{
- "default": "Work Order",
"depends_on": "operations",
+ "fetch_from": "bom_no.transfer_material_against",
+ "fetch_if_empty": 1,
"fieldname": "transfer_material_against",
"fieldtype": "Select",
"label": "Transfer Material Against",
- "options": "Work Order\nJob Card"
+ "options": "\nWork Order\nJob Card"
},
{
"fieldname": "operations",
@@ -574,7 +575,7 @@
"image_field": "image",
"is_submittable": 1,
"links": [],
- "modified": "2021-11-08 17:36:07.016300",
+ "modified": "2022-01-24 21:18:12.160114",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Work Order",
@@ -607,6 +608,7 @@
],
"sort_field": "modified",
"sort_order": "ASC",
+ "states": [],
"title_field": "production_item",
"track_changes": 1,
"track_seen": 1
diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py
index 0090f4d04ee..23fb9697cdb 100644
--- a/erpnext/manufacturing/doctype/work_order/work_order.py
+++ b/erpnext/manufacturing/doctype/work_order/work_order.py
@@ -65,6 +65,7 @@ class WorkOrder(Document):
self.validate_warehouse_belongs_to_company()
self.calculate_operating_cost()
self.validate_qty()
+ self.validate_transfer_against()
self.validate_operation_time()
self.status = self.get_status()
@@ -72,6 +73,7 @@ class WorkOrder(Document):
self.set_required_items(reset_only_qty = len(self.get("required_items")))
+
def validate_sales_order(self):
if self.sales_order:
self.check_sales_order_on_hold_or_close()
@@ -621,6 +623,16 @@ class WorkOrder(Document):
if not self.qty > 0:
frappe.throw(_("Quantity to Manufacture must be greater than 0."))
+ def validate_transfer_against(self):
+ if not self.docstatus == 1:
+ # let user configure operations until they're ready to submit
+ return
+ if not self.operations:
+ self.transfer_material_against = "Work Order"
+ if not self.transfer_material_against:
+ frappe.throw(_("Setting {} is required").format(self.meta.get_label("transfer_material_against")), title=_("Missing value"))
+
+
def validate_operation_time(self):
for d in self.operations:
if not d.time_in_mins > 0:
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 e8619a00bb9..4d3b5f913ca 100644
--- a/erpnext/patches.txt
+++ b/erpnext/patches.txt
@@ -305,7 +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
+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")
@@ -337,8 +337,13 @@ 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.set_billed_amount_in_returned_dn
\ No newline at end of file
+erpnext.patches.v13_0.set_billed_amount_in_returned_dn
+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
+erpnext.patches.v13_0.update_sane_transfer_against
\ No newline at end of file
diff --git a/erpnext/patches/v12_0/update_is_cancelled_field.py b/erpnext/patches/v12_0/update_is_cancelled_field.py
index 18787848dfd..06b6673a5d2 100644
--- a/erpnext/patches/v12_0/update_is_cancelled_field.py
+++ b/erpnext/patches/v12_0/update_is_cancelled_field.py
@@ -3,14 +3,28 @@ import frappe
def execute():
- try:
- frappe.db.sql("UPDATE `tabStock Ledger Entry` SET is_cancelled = 0 where is_cancelled in ('', NULL, 'No')")
- frappe.db.sql("UPDATE `tabSerial No` SET is_cancelled = 0 where is_cancelled in ('', NULL, 'No')")
+ #handle type casting for is_cancelled field
+ module_doctypes = (
+ ('stock', 'Stock Ledger Entry'),
+ ('stock', 'Serial No'),
+ ('accounts', 'GL Entry')
+ )
- frappe.db.sql("UPDATE `tabStock Ledger Entry` SET is_cancelled = 1 where is_cancelled = 'Yes'")
- frappe.db.sql("UPDATE `tabSerial No` SET is_cancelled = 1 where is_cancelled = 'Yes'")
+ for module, doctype in module_doctypes:
+ if (not frappe.db.has_column(doctype, "is_cancelled")
+ or frappe.db.get_column_type(doctype, "is_cancelled").lower() == "int(1)"
+ ):
+ continue
- frappe.reload_doc("stock", "doctype", "stock_ledger_entry")
- frappe.reload_doc("stock", "doctype", "serial_no")
- except Exception:
- pass
+ frappe.db.sql("""
+ UPDATE `tab{doctype}`
+ SET is_cancelled = 0
+ where is_cancelled in ('', NULL, 'No')"""
+ .format(doctype=doctype))
+ frappe.db.sql("""
+ UPDATE `tab{doctype}`
+ SET is_cancelled = 1
+ where is_cancelled = 'Yes'"""
+ .format(doctype=doctype))
+
+ frappe.reload_doc(module, "doctype", frappe.scrub(doctype))
diff --git a/erpnext/patches/v13_0/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/delete_old_sales_reports.py b/erpnext/patches/v13_0/delete_old_sales_reports.py
index c597fe86457..e6eba0a6085 100644
--- a/erpnext/patches/v13_0/delete_old_sales_reports.py
+++ b/erpnext/patches/v13_0/delete_old_sales_reports.py
@@ -12,6 +12,7 @@ def execute():
for report in reports_to_delete:
if frappe.db.exists("Report", report):
+ delete_links_from_desktop_icons(report)
delete_auto_email_reports(report)
check_and_delete_linked_reports(report)
@@ -22,3 +23,9 @@ def delete_auto_email_reports(report):
auto_email_reports = frappe.db.get_values("Auto Email Report", {"report": report}, ["name"])
for auto_email_report in auto_email_reports:
frappe.delete_doc("Auto Email Report", auto_email_report[0])
+
+def delete_links_from_desktop_icons(report):
+ """ Check for one or multiple Desktop Icons and delete """
+ desktop_icons = frappe.db.get_values("Desktop Icon", {"_report": report}, ["name"])
+ for desktop_icon in desktop_icons:
+ frappe.delete_doc("Desktop Icon", desktop_icon[0])
\ No newline at end of file
diff --git a/erpnext/patches/v13_0/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/make_homepage_products_website_items.py b/erpnext/patches/v13_0/make_homepage_products_website_items.py
index bb0630aafdc..3ca20e2da86 100644
--- a/erpnext/patches/v13_0/make_homepage_products_website_items.py
+++ b/erpnext/patches/v13_0/make_homepage_products_website_items.py
@@ -13,4 +13,6 @@ def execute():
row.item_code = web_item
homepage.flags.ignore_mandatory = True
+ homepage.flags.ignore_links = True
+
homepage.save()
\ No newline at end of file
diff --git a/erpnext/patches/v13_0/setup_fields_for_80g_certificate_and_donation.py b/erpnext/patches/v13_0/setup_fields_for_80g_certificate_and_donation.py
index 7a2a2539670..2d35ea34587 100644
--- a/erpnext/patches/v13_0/setup_fields_for_80g_certificate_and_donation.py
+++ b/erpnext/patches/v13_0/setup_fields_for_80g_certificate_and_donation.py
@@ -5,6 +5,9 @@ from erpnext.regional.india.setup import make_custom_fields
def execute():
if frappe.get_all('Company', filters = {'country': 'India'}):
+ frappe.reload_doc('accounts', 'doctype', 'POS Invoice')
+ frappe.reload_doc('accounts', 'doctype', 'POS Invoice Item')
+
make_custom_fields()
if not frappe.db.exists('Party Type', 'Donor'):
diff --git a/erpnext/patches/v13_0/trim_whitespace_from_serial_nos.py b/erpnext/patches/v13_0/trim_whitespace_from_serial_nos.py
index 8a9633d8964..4ec22e9d0e1 100644
--- a/erpnext/patches/v13_0/trim_whitespace_from_serial_nos.py
+++ b/erpnext/patches/v13_0/trim_whitespace_from_serial_nos.py
@@ -9,13 +9,15 @@ def execute():
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)
+ 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,
)
diff --git a/erpnext/patches/v13_0/update_actual_start_and_end_date_in_wo.py b/erpnext/patches/v13_0/update_actual_start_and_end_date_in_wo.py
index 10ecd093069..9993063e485 100644
--- a/erpnext/patches/v13_0/update_actual_start_and_end_date_in_wo.py
+++ b/erpnext/patches/v13_0/update_actual_start_and_end_date_in_wo.py
@@ -38,4 +38,4 @@ def execute():
jc.production_item = wo.production_item, jc.item_name = wo.item_name
WHERE
jc.work_order = wo.name and IFNULL(jc.production_item, "") = ""
- """)
+ """)
\ No newline at end of file
diff --git a/erpnext/patches/v13_0/update_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/update_sane_transfer_against.py b/erpnext/patches/v13_0/update_sane_transfer_against.py
new file mode 100644
index 00000000000..a163d385843
--- /dev/null
+++ b/erpnext/patches/v13_0/update_sane_transfer_against.py
@@ -0,0 +1,11 @@
+import frappe
+
+
+def execute():
+ bom = frappe.qb.DocType("BOM")
+
+ (frappe.qb
+ .update(bom)
+ .set(bom.transfer_material_against, "Work Order")
+ .where(bom.with_operations == 0)
+ ).run()
diff --git a/erpnext/patches/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/salary_slip/test_salary_slip.py b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py
index 0977561c9e6..46c99517d66 100644
--- a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py
+++ b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py
@@ -999,6 +999,8 @@ def make_leave_application(employee, from_date, to_date, leave_type, company=Non
))
leave_application.submit()
+ return leave_application
+
def setup_test():
make_earning_salary_component(setup=True, company_list=["_Test Company"])
make_deduction_salary_component(setup=True, company_list=["_Test Company"])
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/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/selling/doctype/customer/customer.py b/erpnext/selling/doctype/customer/customer.py
index c86e18ab7aa..df871491422 100644
--- a/erpnext/selling/doctype/customer/customer.py
+++ b/erpnext/selling/doctype/customer/customer.py
@@ -142,7 +142,7 @@ class Customer(TransactionBase):
self.update_lead_status()
if self.flags.is_new_doc:
- self.create_lead_address_contact()
+ self.link_lead_address_and_contact()
self.update_customer_groups()
@@ -176,63 +176,25 @@ class Customer(TransactionBase):
if self.lead_name:
frappe.db.set_value("Lead", self.lead_name, "status", "Converted")
- def create_lead_address_contact(self):
+ def link_lead_address_and_contact(self):
if self.lead_name:
- # assign lead address to customer (if already not set)
- address_names = frappe.get_all('Dynamic Link', filters={
- "parenttype":"Address",
- "link_doctype":"Lead",
- "link_name":self.lead_name
- }, fields=["parent as name"])
+ # assign lead address and contact to customer (if already not set)
+ linked_contacts_and_addresses = frappe.get_all(
+ "Dynamic Link",
+ filters=[
+ ["parenttype", "in", ["Contact", "Address"]],
+ ["link_doctype", "=", "Lead"],
+ ["link_name", "=", self.lead_name],
+ ],
+ fields=["parent as name", "parenttype as doctype"],
+ )
- for address_name in address_names:
- address = frappe.get_doc('Address', address_name.get('name'))
- if not address.has_link('Customer', self.name):
- address.append('links', dict(link_doctype='Customer', link_name=self.name))
- address.save(ignore_permissions=self.flags.ignore_permissions)
+ for row in linked_contacts_and_addresses:
+ linked_doc = frappe.get_doc(row.doctype, row.name)
+ if not linked_doc.has_link('Customer', self.name):
+ linked_doc.append('links', dict(link_doctype='Customer', link_name=self.name))
+ linked_doc.save(ignore_permissions=self.flags.ignore_permissions)
- lead = frappe.db.get_value("Lead", self.lead_name, ["organization_lead", "lead_name", "email_id", "phone", "mobile_no", "gender", "salutation"], as_dict=True)
-
- if not lead.lead_name:
- frappe.throw(_("Please mention the Lead Name in Lead {0}").format(self.lead_name))
-
- if lead.organization_lead:
- contact_names = frappe.get_all('Dynamic Link', filters={
- "parenttype":"Contact",
- "link_doctype":"Lead",
- "link_name":self.lead_name
- }, fields=["parent as name"])
-
- for contact_name in contact_names:
- contact = frappe.get_doc('Contact', contact_name.get('name'))
- if not contact.has_link('Customer', self.name):
- contact.append('links', dict(link_doctype='Customer', link_name=self.name))
- contact.save(ignore_permissions=self.flags.ignore_permissions)
-
- else:
- lead.lead_name = lead.lead_name.lstrip().split(" ")
- lead.first_name = lead.lead_name[0]
- lead.last_name = " ".join(lead.lead_name[1:])
-
- # create contact from lead
- contact = frappe.new_doc('Contact')
- contact.first_name = lead.first_name
- contact.last_name = lead.last_name
- contact.gender = lead.gender
- contact.salutation = lead.salutation
- contact.email_id = lead.email_id
- contact.phone = lead.phone
- contact.mobile_no = lead.mobile_no
- contact.is_primary_contact = 1
- contact.append('links', dict(link_doctype='Customer', link_name=self.name))
- if lead.email_id:
- contact.append('email_ids', dict(email_id=lead.email_id, is_primary=1))
- if lead.mobile_no:
- contact.append('phone_nos', dict(phone=lead.mobile_no, is_primary_mobile_no=1))
- contact.flags.ignore_permissions = self.flags.ignore_permissions
- contact.autoname()
- if not frappe.db.exists("Contact", contact.name):
- contact.insert()
def validate_name_with_customer_group(self):
if frappe.db.exists("Customer Group", self.name):
diff --git a/erpnext/selling/report/customer_credit_balance/customer_credit_balance.py b/erpnext/selling/report/customer_credit_balance/customer_credit_balance.py
index 777b02ca66d..dd49f1355d2 100644
--- a/erpnext/selling/report/customer_credit_balance/customer_credit_balance.py
+++ b/erpnext/selling/report/customer_credit_balance/customer_credit_balance.py
@@ -23,19 +23,24 @@ def execute(filters=None):
row = []
outstanding_amt = get_customer_outstanding(d.name, filters.get("company"),
- ignore_outstanding_sales_order=d.bypass_credit_limit_check_at_sales_order)
+ ignore_outstanding_sales_order=d.bypass_credit_limit_check)
credit_limit = get_credit_limit(d.name, filters.get("company"))
bal = flt(credit_limit) - flt(outstanding_amt)
if customer_naming_type == "Naming Series":
- row = [d.name, d.customer_name, credit_limit, outstanding_amt, bal,
- d.bypass_credit_limit_check, d.is_frozen,
- d.disabled]
+ row = [
+ d.name, d.customer_name, credit_limit,
+ outstanding_amt, bal, d.bypass_credit_limit_check,
+ d.is_frozen, d.disabled
+ ]
else:
- row = [d.name, credit_limit, outstanding_amt, bal,
- d.bypass_credit_limit_check_at_sales_order, d.is_frozen, d.disabled]
+ row = [
+ d.name, credit_limit, outstanding_amt, bal,
+ d.bypass_credit_limit_check, d.is_frozen,
+ d.disabled
+ ]
if credit_limit:
data.append(row)
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 c530d30c0c2..001095588ba 100644
--- a/erpnext/selling/report/sales_order_analysis/sales_order_analysis.py
+++ b/erpnext/selling/report/sales_order_analysis/sales_order_analysis.py
@@ -80,7 +80,7 @@ def get_data(conditions, filters):
and so.docstatus = 1
{conditions}
GROUP BY soi.name
- ORDER BY so.transaction_date ASC
+ ORDER BY so.transaction_date ASC, soi.item_code ASC
""".format(conditions=conditions), filters, as_dict=1)
return data
diff --git a/erpnext/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 b9e1a420c1a..c3247fbe3e8 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/item/item.py b/erpnext/stock/doctype/item/item.py
index 9f3d9569f9f..557e07bf0a2 100644
--- a/erpnext/stock/doctype/item/item.py
+++ b/erpnext/stock/doctype/item/item.py
@@ -219,18 +219,20 @@ class Item(Document):
self.item_code))
def add_default_uom_in_conversion_factor_table(self):
- uom_conv_list = [d.uom for d in self.get("uoms")]
- if self.stock_uom not in uom_conv_list:
- ch = self.append('uoms', {})
- ch.uom = self.stock_uom
- ch.conversion_factor = 1
+ if not self.is_new() and self.has_value_changed("stock_uom"):
+ self.uoms = []
+ frappe.msgprint(
+ _("Successfully changed Stock UOM, please redefine conversion factors for new UOM."),
+ alert=True,
+ )
- to_remove = []
- for d in self.get("uoms"):
- if d.conversion_factor == 1 and d.uom != self.stock_uom:
- to_remove.append(d)
+ uoms_list = [d.uom for d in self.get("uoms")]
- [self.remove(d) for d in to_remove]
+ if self.stock_uom not in uoms_list:
+ self.append("uoms", {
+ "uom": self.stock_uom,
+ "conversion_factor": 1
+ })
def update_website_item(self):
"""Update Website Item if change in Item impacts it."""
@@ -347,14 +349,6 @@ class Item(Document):
frappe.throw(_("Barcode {0} is not a valid {1} code").format(
item_barcode.barcode, item_barcode.barcode_type), InvalidBarcode)
- if item_barcode.barcode != item_barcode.name:
- # if barcode is getting updated , the row name has to reset.
- # Delete previous old row doc and re-enter row as if new to reset name in db.
- item_barcode.set("__islocal", True)
- item_barcode_entry_name = item_barcode.name
- item_barcode.name = None
- frappe.delete_doc("Item Barcode", item_barcode_entry_name)
-
def validate_warehouse_for_reorder(self):
'''Validate Reorder level table for duplicate and conditional mandatory'''
warehouse = []
diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py
index e191f0a3293..150cead465b 100644
--- a/erpnext/stock/doctype/item/test_item.py
+++ b/erpnext/stock/doctype/item/test_item.py
@@ -573,6 +573,16 @@ class TestItem(ERPNextTestCase):
except frappe.ValidationError as e:
self.fail(f"UoM change not allowed even though no SLE / BIN with positive qty exists: {e}")
+ def test_erasure_of_old_conversions(self):
+ item = create_item("_item change uom")
+ item.stock_uom = "Gram"
+ item.append("uoms", frappe._dict(uom="Box", conversion_factor=2))
+ item.save()
+ item.reload()
+ item.stock_uom = "Nos"
+ item.save()
+ self.assertEqual(len(item.uoms), 1)
+
def test_validate_stock_item(self):
self.assertRaises(frappe.ValidationError, validate_is_stock_item, "_Test Non Stock Item")
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 7472c95ecf5..ab106e036f1 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry.py
+++ b/erpnext/stock/doctype/stock_entry/stock_entry.py
@@ -1446,14 +1446,15 @@ class StockEntry(StockController):
qty = req_qty_each * flt(self.fg_completed_qty)
elif backflushed_materials.get(item.item_code):
+ precision = frappe.get_precision("Stock Entry Detail", "qty")
for d in backflushed_materials.get(item.item_code):
- if d.get(item.warehouse):
+ if d.get(item.warehouse) > 0:
if (qty > req_qty):
- qty = (qty/trans_qty) * flt(self.fg_completed_qty)
+ qty = ((flt(qty, precision) - flt(d.get(item.warehouse), precision))
+ / (flt(trans_qty, precision) - flt(produced_qty, precision))
+ ) * flt(self.fg_completed_qty)
- if consumed_qty and frappe.db.get_single_value("Manufacturing Settings",
- "material_consumption"):
- qty -= consumed_qty
+ d[item.warehouse] -= qty
if cint(frappe.get_cached_value('UOM', item.stock_uom, 'must_be_whole_number')):
qty = frappe.utils.ceil(qty)
diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py
index af7acc36353..3d936c50f7a 100644
--- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py
+++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py
@@ -852,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/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/report/stock_ledger/stock_ledger.js b/erpnext/stock/report/stock_ledger/stock_ledger.js
index fe2417bba7e..ef7c2cc7d9e 100644
--- a/erpnext/stock/report/stock_ledger/stock_ledger.js
+++ b/erpnext/stock/report/stock_ledger/stock_ledger.js
@@ -86,10 +86,10 @@ frappe.query_reports["Stock Ledger"] = {
],
"formatter": function (value, row, column, data, default_formatter) {
value = default_formatter(value, row, column, data);
- if (column.fieldname == "out_qty" && data.out_qty < 0) {
+ if (column.fieldname == "out_qty" && data && data.out_qty < 0) {
value = "
" + value + "";
}
- else if (column.fieldname == "in_qty" && data.in_qty > 0) {
+ else if (column.fieldname == "in_qty" && data && data.in_qty > 0) {
value = "
" + value + "";
}
diff --git a/erpnext/stock/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 {
? `
${one_item}
- ${product_info && product_info.price && !$.isEmptyObject()
+ ${product_info && product_info.price && !$.isEmptyObject(product_info.price)
? '(' + product_info.price.formatted_price_sales_uom + ')'
: ''
}
diff --git a/erpnext/tests/utils.py b/erpnext/tests/utils.py
index fbf25948a79..2378a54311f 100644
--- a/erpnext/tests/utils.py
+++ b/erpnext/tests/utils.py
@@ -92,6 +92,8 @@ def change_settings(doctype, settings_dict):
for key, value in settings_dict.items():
setattr(settings, key, value)
settings.save()
+ # singles are cached by default, clear to avoid flake
+ frappe.db.value_cache[settings] = {}
yield # yield control to calling function
finally: