diff --git a/erpnext/accounts/doctype/gl_entry/gl_entry.py b/erpnext/accounts/doctype/gl_entry/gl_entry.py index f184b95a92d..36276023ecd 100644 --- a/erpnext/accounts/doctype/gl_entry/gl_entry.py +++ b/erpnext/accounts/doctype/gl_entry/gl_entry.py @@ -33,6 +33,8 @@ class GLEntry(Document): name will be changed using autoname options (in a scheduled job) """ self.name = frappe.generate_hash(txt="", length=10) + if self.meta.autoname == "hash": + self.to_rename = 0 def validate(self): self.flags.ignore_submit_comment = True @@ -135,7 +137,7 @@ class GLEntry(Document): def check_pl_account(self): if self.is_opening=='Yes' and \ - frappe.db.get_value("Account", self.account, "report_type")=="Profit and Loss": + frappe.db.get_value("Account", self.account, "report_type")=="Profit and Loss" and not self.is_cancelled: frappe.throw(_("{0} {1}: 'Profit and Loss' type account {2} not allowed in Opening Entry") .format(self.voucher_type, self.voucher_no, self.account)) diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index 5cf534faba1..f77b060ee29 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -376,8 +376,8 @@ def make_payment_request(**args): if args.order_type == "Shopping Cart" or args.mute_email: pr.flags.mute_email = True - pr.insert(ignore_permissions=True) if args.submit_doc: + pr.insert(ignore_permissions=True) pr.submit() if args.order_type == "Shopping Cart": diff --git a/erpnext/accounts/doctype/payment_request/test_payment_request.py b/erpnext/accounts/doctype/payment_request/test_payment_request.py index f679ccfe4ff..cc70a96e742 100644 --- a/erpnext/accounts/doctype/payment_request/test_payment_request.py +++ b/erpnext/accounts/doctype/payment_request/test_payment_request.py @@ -128,6 +128,7 @@ class TestPaymentRequest(unittest.TestCase): pr1 = make_payment_request(dt="Sales Order", dn=so.name, recipient_id="nabin@erpnext.com", return_doc=1) pr1.grand_total = 200 + pr1.insert() pr1.submit() # Make a 2nd Payment Request diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index 466b2833fe1..431036abc18 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -456,6 +456,7 @@ class POSInvoice(SalesInvoice): pay_req = self.get_existing_payment_request(pay) if not pay_req: pay_req = self.get_new_payment_request(pay) + pay_req.insert() pay_req.submit() else: pay_req.request_phone_payment() diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index d2de442a5a4..dcf95f60136 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -812,12 +812,37 @@ class TestSalesInvoice(unittest.TestCase): pos.append("payments", {'mode_of_payment': 'Bank Draft', 'account': '_Test Bank - TCP1', 'amount': 50}) pos.append("payments", {'mode_of_payment': 'Cash', 'account': 'Cash - TCP1', 'amount': 60}) - pos.change_amount = 5.0 + pos.write_off_outstanding_amount_automatically = 1 pos.insert() pos.submit() self.assertEqual(pos.grand_total, 100.0) - self.assertEqual(pos.write_off_amount, -5) + self.assertEqual(pos.write_off_amount, 0) + + def test_auto_write_off_amount(self): + make_pos_profile(company="_Test Company with perpetual inventory", income_account = "Sales - TCP1", + expense_account = "Cost of Goods Sold - TCP1", warehouse="Stores - TCP1", cost_center = "Main - TCP1", write_off_account="_Test Write Off - TCP1") + + make_purchase_receipt(company= "_Test Company with perpetual inventory", + item_code= "_Test FG Item",warehouse= "Stores - TCP1", cost_center= "Main - TCP1") + + pos = create_sales_invoice(company= "_Test Company with perpetual inventory", + debit_to="Debtors - TCP1", item_code= "_Test FG Item", warehouse="Stores - TCP1", + income_account = "Sales - TCP1", expense_account = "Cost of Goods Sold - TCP1", + cost_center = "Main - TCP1", do_not_save=True) + + pos.is_pos = 1 + pos.update_stock = 1 + + pos.append("payments", {'mode_of_payment': 'Bank Draft', 'account': '_Test Bank - TCP1', 'amount': 50}) + pos.append("payments", {'mode_of_payment': 'Cash', 'account': 'Cash - TCP1', 'amount': 40}) + + pos.write_off_outstanding_amount_automatically = 1 + pos.insert() + pos.submit() + + self.assertEqual(pos.grand_total, 100.0) + self.assertEqual(pos.write_off_amount, 10) def test_pos_with_no_gl_entry_for_change_amount(self): frappe.db.set_value('Accounts Settings', None, 'post_change_gl_entries', 0) diff --git a/erpnext/accounts/report/cash_flow/custom_cash_flow.py b/erpnext/accounts/report/cash_flow/custom_cash_flow.py index 45d147e7a21..e81e0d73cdb 100644 --- a/erpnext/accounts/report/cash_flow/custom_cash_flow.py +++ b/erpnext/accounts/report/cash_flow/custom_cash_flow.py @@ -4,7 +4,8 @@ import frappe from frappe import _ -from frappe.utils import add_to_date +from frappe.query_builder.functions import Sum +from frappe.utils import add_to_date, flt, get_date_str from erpnext.accounts.report.financial_statements import get_columns, get_data, get_period_list from erpnext.accounts.report.profit_and_loss_statement.profit_and_loss_statement import ( @@ -28,15 +29,22 @@ def get_mappers_from_db(): def get_accounts_in_mappers(mapping_names): - return frappe.db.sql(''' - select cfma.name, cfm.label, cfm.is_working_capital, cfm.is_income_tax_liability, - cfm.is_income_tax_expense, cfm.is_finance_cost, cfm.is_finance_cost_adjustment - from `tabCash Flow Mapping Accounts` cfma - join `tabCash Flow Mapping` cfm on cfma.parent=cfm.name - where cfma.parent in (%s) - order by cfm.is_working_capital - ''', (', '.join('"%s"' % d for d in mapping_names))) + cfm = frappe.qb.DocType('Cash Flow Mapping') + cfma = frappe.qb.DocType('Cash Flow Mapping Accounts') + result = ( + frappe.qb + .select( + cfma.name, cfm.label, cfm.is_working_capital, + cfm.is_income_tax_liability, cfm.is_income_tax_expense, + cfm.is_finance_cost, cfm.is_finance_cost_adjustment, cfma.account + ) + .from_(cfm) + .join(cfma) + .on(cfm.name == cfma.parent) + .where(cfma.parent.isin(mapping_names)) + ).run() + return result def setup_mappers(mappers): cash_flow_accounts = [] @@ -57,31 +65,31 @@ def setup_mappers(mappers): account_types = [ dict( - name=account[0], label=account[1], is_working_capital=account[2], + name=account[0], account_name=account[7], label=account[1], is_working_capital=account[2], is_income_tax_liability=account[3], is_income_tax_expense=account[4] ) for account in accounts if not account[3]] finance_costs_adjustments = [ dict( - name=account[0], label=account[1], is_finance_cost=account[5], + name=account[0], account_name=account[7], label=account[1], is_finance_cost=account[5], is_finance_cost_adjustment=account[6] ) for account in accounts if account[6]] tax_liabilities = [ dict( - name=account[0], label=account[1], is_income_tax_liability=account[3], + name=account[0], account_name=account[7], label=account[1], is_income_tax_liability=account[3], is_income_tax_expense=account[4] ) for account in accounts if account[3]] tax_expenses = [ dict( - name=account[0], label=account[1], is_income_tax_liability=account[3], + name=account[0], account_name=account[7], label=account[1], is_income_tax_liability=account[3], is_income_tax_expense=account[4] ) for account in accounts if account[4]] finance_costs = [ dict( - name=account[0], label=account[1], is_finance_cost=account[5]) + name=account[0], account_name=account[7], label=account[1], is_finance_cost=account[5]) for account in accounts if account[5]] account_types_labels = sorted( @@ -124,27 +132,27 @@ def setup_mappers(mappers): ) for label in account_types_labels: - names = [d['name'] for d in account_types if d['label'] == label[0]] + names = [d['account_name'] for d in account_types if d['label'] == label[0]] m = dict(label=label[0], names=names, is_working_capital=label[1]) mapping['account_types'].append(m) for label in fc_adjustment_labels: - names = [d['name'] for d in finance_costs_adjustments if d['label'] == label[0]] + names = [d['account_name'] for d in finance_costs_adjustments if d['label'] == label[0]] m = dict(label=label[0], names=names) mapping['finance_costs_adjustments'].append(m) for label in unique_liability_labels: - names = [d['name'] for d in tax_liabilities if d['label'] == label[0]] + names = [d['account_name'] for d in tax_liabilities if d['label'] == label[0]] m = dict(label=label[0], names=names, tax_liability=label[1], tax_expense=label[2]) mapping['tax_liabilities'].append(m) for label in unique_expense_labels: - names = [d['name'] for d in tax_expenses if d['label'] == label[0]] + names = [d['account_name'] for d in tax_expenses if d['label'] == label[0]] m = dict(label=label[0], names=names, tax_liability=label[1], tax_expense=label[2]) mapping['tax_expenses'].append(m) for label in unique_finance_costs_labels: - names = [d['name'] for d in finance_costs if d['label'] == label[0]] + names = [d['account_name'] for d in finance_costs if d['label'] == label[0]] m = dict(label=label[0], names=names, is_finance_cost=label[1]) mapping['finance_costs'].append(m) @@ -371,14 +379,30 @@ def execute(filters=None): def _get_account_type_based_data(filters, account_names, period_list, accumulated_values, opening_balances=0): + if not account_names or not account_names[0] or not type(account_names[0]) == str: + # only proceed if account_names is a list of account names + return {} + from erpnext.accounts.report.cash_flow.cash_flow import get_start_date company = filters.company data = {} total = 0 + GLEntry = frappe.qb.DocType('GL Entry') + Account = frappe.qb.DocType('Account') + for period in period_list: start_date = get_start_date(period, accumulated_values, company) - accounts = ', '.join('"%s"' % d for d in account_names) + + account_subquery = ( + frappe.qb.from_(Account) + .where( + (Account.name.isin(account_names)) | + (Account.parent_account.isin(account_names)) + ) + .select(Account.name) + .as_("account_subquery") + ) if opening_balances: date_info = dict(date=start_date) @@ -395,32 +419,31 @@ def _get_account_type_based_data(filters, account_names, period_list, accumulate else: start, end = add_to_date(**date_info), add_to_date(**date_info) - gl_sum = frappe.db.sql_list(""" - select sum(credit) - sum(debit) - from `tabGL Entry` - where company=%s and posting_date >= %s and posting_date <= %s - and voucher_type != 'Period Closing Voucher' - and account in ( SELECT name FROM tabAccount WHERE name IN (%s) - OR parent_account IN (%s)) - """, (company, start, end, accounts, accounts)) - else: - gl_sum = frappe.db.sql_list(""" - select sum(credit) - sum(debit) - from `tabGL Entry` - where company=%s and posting_date >= %s and posting_date <= %s - and voucher_type != 'Period Closing Voucher' - and account in ( SELECT name FROM tabAccount WHERE name IN (%s) - OR parent_account IN (%s)) - """, (company, start_date if accumulated_values else period['from_date'], - period['to_date'], accounts, accounts)) + start, end = get_date_str(start), get_date_str(end) - if gl_sum and gl_sum[0]: - amount = gl_sum[0] else: - amount = 0 + start, end = start_date if accumulated_values else period['from_date'], period['to_date'] + start, end = get_date_str(start), get_date_str(end) - total += amount - data.setdefault(period["key"], amount) + result = ( + frappe.qb.from_(GLEntry) + .select(Sum(GLEntry.credit) - Sum(GLEntry.debit)) + .where( + (GLEntry.company == company) & + (GLEntry.posting_date >= start) & + (GLEntry.posting_date <= end) & + (GLEntry.voucher_type != 'Period Closing Voucher') & + (GLEntry.account.isin(account_subquery)) + ) + ).run() + + if result and result[0]: + gl_sum = result[0][0] + else: + gl_sum = 0 + + total += flt(gl_sum) + data.setdefault(period["key"], flt(gl_sum)) data["total"] = total return data diff --git a/erpnext/controllers/queries.py b/erpnext/controllers/queries.py index f5c566023c6..ea1c90c250d 100644 --- a/erpnext/controllers/queries.py +++ b/erpnext/controllers/queries.py @@ -168,7 +168,7 @@ def tax_account_query(doctype, txt, searchfield, start, page_len, filters): {account_type_condition} AND is_group = 0 AND company = %(company)s - AND account_currency = %(currency)s + AND (account_currency = %(currency)s or ifnull(account_currency, '') = '') AND `{searchfield}` LIKE %(txt)s {mcond} ORDER BY idx DESC, name diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index 585577c99f8..f926a893147 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -580,7 +580,11 @@ class calculate_taxes_and_totals(object): .format(self.doc.party_account_currency, invoice_total)) if self.doc.docstatus == 0: + if self.doc.get('write_off_outstanding_amount_automatically'): + self.doc.write_off_amount = 0 + self.calculate_outstanding_amount() + self.calculate_write_off_amount() def is_internal_invoice(self): """ @@ -621,7 +625,6 @@ class calculate_taxes_and_totals(object): change_amount = 0 if self.doc.doctype == "Sales Invoice" and not self.doc.get('is_return'): - self.calculate_write_off_amount() self.calculate_change_amount() change_amount = self.doc.change_amount \ if self.doc.party_account_currency == self.doc.currency else self.doc.base_change_amount @@ -671,19 +674,20 @@ class calculate_taxes_and_totals(object): and self.doc.paid_amount > grand_total and not self.doc.is_return \ and any(d.type == "Cash" for d in self.doc.payments): - self.doc.change_amount = flt(self.doc.paid_amount - grand_total + - self.doc.write_off_amount, self.doc.precision("change_amount")) + self.doc.change_amount = flt(self.doc.paid_amount - grand_total, + self.doc.precision("change_amount")) - self.doc.base_change_amount = flt(self.doc.base_paid_amount - base_grand_total + - self.doc.base_write_off_amount, self.doc.precision("base_change_amount")) + self.doc.base_change_amount = flt(self.doc.base_paid_amount - base_grand_total, + self.doc.precision("base_change_amount")) def calculate_write_off_amount(self): - if flt(self.doc.change_amount) > 0: - self.doc.write_off_amount = flt(self.doc.grand_total - self.doc.paid_amount - + self.doc.change_amount, self.doc.precision("write_off_amount")) + if self.doc.get('write_off_outstanding_amount_automatically'): + self.doc.write_off_amount = flt(self.doc.outstanding_amount, self.doc.precision("write_off_amount")) self.doc.base_write_off_amount = flt(self.doc.write_off_amount * self.doc.conversion_rate, self.doc.precision("base_write_off_amount")) + self.calculate_outstanding_amount() + def calculate_margin(self, item): rate_with_margin = 0.0 base_rate_with_margin = 0.0 diff --git a/erpnext/e_commerce/doctype/website_item/website_item.js b/erpnext/e_commerce/doctype/website_item/website_item.js index 741e78f4a55..7108cabfb3f 100644 --- a/erpnext/e_commerce/doctype/website_item/website_item.js +++ b/erpnext/e_commerce/doctype/website_item/website_item.js @@ -5,6 +5,12 @@ frappe.ui.form.on('Website Item', { onload: function(frm) { // should never check Private frm.fields_dict["website_image"].df.is_private = 0; + + frm.set_query("website_warehouse", () => { + return { + filters: {"is_group": 0} + }; + }); }, image: function() { diff --git a/erpnext/e_commerce/product_ui/views.js b/erpnext/e_commerce/product_ui/views.js index cc51718c47f..eab1699538e 100644 --- a/erpnext/e_commerce/product_ui/views.js +++ b/erpnext/e_commerce/product_ui/views.js @@ -424,6 +424,22 @@ erpnext.ProductView = class { me.change_route_with_filters(); }); + + // bind filter lookup input box + $('.filter-lookup-input').on('keydown', frappe.utils.debounce((e) => { + const $input = $(e.target); + const keyword = ($input.val() || '').toLowerCase(); + const $filter_options = $input.next('.filter-options'); + + $filter_options.find('.filter-lookup-wrapper').show(); + $filter_options.find('.filter-lookup-wrapper').each((i, el) => { + const $el = $(el); + const value = $el.data('value').toLowerCase(); + if (!value.includes(keyword)) { + $el.hide(); + } + }); + }, 300)); } change_route_with_filters() { diff --git a/erpnext/healthcare/doctype/inpatient_record/inpatient_record.json b/erpnext/healthcare/doctype/inpatient_record/inpatient_record.json index 03ecf4fb018..e826ecf80bb 100644 --- a/erpnext/healthcare/doctype/inpatient_record/inpatient_record.json +++ b/erpnext/healthcare/doctype/inpatient_record/inpatient_record.json @@ -24,13 +24,12 @@ "expected_discharge", "references", "admission_encounter", - "admission_practitioner", + "primary_practitioner", "medical_department", "admission_ordered_for", "expected_length_of_stay", "admission_service_unit_type", "cb_admission", - "primary_practitioner", "secondary_practitioner", "admission_instruction", "encounter_details_section", @@ -134,11 +133,11 @@ "read_only": 1 }, { + "fetch_from": "primary_practitioner.department", "fieldname": "medical_department", "fieldtype": "Link", "label": "Medical Department", - "options": "Medical Department", - "set_only_once": 1 + "options": "Medical Department" }, { "fieldname": "primary_practitioner", @@ -211,13 +210,6 @@ "fieldname": "cb_admission", "fieldtype": "Column Break" }, - { - "fieldname": "admission_practitioner", - "fieldtype": "Link", - "label": "Healthcare Practitioner", - "options": "Healthcare Practitioner", - "read_only": 1 - }, { "fieldname": "admission_encounter", "fieldtype": "Link", @@ -412,7 +404,7 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2021-08-09 22:49:07.419692", + "modified": "2022-02-22 12:15:02.843426", "modified_by": "Administrator", "module": "Healthcare", "name": "Inpatient Record", diff --git a/erpnext/healthcare/doctype/vital_signs/vital_signs.json b/erpnext/healthcare/doctype/vital_signs/vital_signs.json index a945032c7e0..6d6c351ec35 100644 --- a/erpnext/healthcare/doctype/vital_signs/vital_signs.json +++ b/erpnext/healthcare/doctype/vital_signs/vital_signs.json @@ -72,7 +72,6 @@ "fieldtype": "Link", "in_filter": 1, "label": "Patient Appointment", - "no_copy": 1, "options": "Patient Appointment", "print_hide": 1, "read_only": 1 @@ -82,7 +81,6 @@ "fieldtype": "Link", "in_filter": 1, "label": "Patient Encounter", - "no_copy": 1, "options": "Patient Encounter", "print_hide": 1, "read_only": 1 @@ -258,7 +256,7 @@ ], "is_submittable": 1, "links": [], - "modified": "2022-01-20 12:30:07.515185", + "modified": "2022-02-19 11:48:16.347334", "modified_by": "Administrator", "module": "Healthcare", "name": "Vital Signs", diff --git a/erpnext/hr/doctype/employee_advance/test_employee_advance.py b/erpnext/hr/doctype/employee_advance/test_employee_advance.py index 5f2e720eb46..2744de96ec3 100644 --- a/erpnext/hr/doctype/employee_advance/test_employee_advance.py +++ b/erpnext/hr/doctype/employee_advance/test_employee_advance.py @@ -4,7 +4,7 @@ import unittest import frappe -from frappe.utils import nowdate +from frappe.utils import flt, nowdate import erpnext from erpnext.hr.doctype.employee.test_employee import make_employee @@ -13,6 +13,7 @@ from erpnext.hr.doctype.employee_advance.employee_advance import ( create_return_through_additional_salary, make_bank_entry, ) +from erpnext.hr.doctype.expense_claim.expense_claim import get_advances from erpnext.payroll.doctype.salary_component.test_salary_component import create_salary_component from erpnext.payroll.doctype.salary_structure.test_salary_structure import make_salary_structure @@ -118,3 +119,24 @@ def make_employee_advance(employee_name, args=None): doc.submit() return doc + + +def get_advances_for_claim(claim, advance_name, amount=None): + advances = get_advances(claim.employee, advance_name) + + for entry in advances: + if amount: + allocated_amount = amount + else: + allocated_amount = flt(entry.paid_amount) - flt(entry.claimed_amount) + + claim.append("advances", { + "employee_advance": entry.name, + "posting_date": entry.posting_date, + "advance_account": entry.advance_account, + "advance_paid": entry.paid_amount, + "unclaimed_amount": allocated_amount, + "allocated_amount": allocated_amount + }) + + return claim \ No newline at end of file diff --git a/erpnext/hr/doctype/expense_claim/expense_claim.py b/erpnext/hr/doctype/expense_claim/expense_claim.py index 7e3898b7d51..c1bf9c2d984 100644 --- a/erpnext/hr/doctype/expense_claim/expense_claim.py +++ b/erpnext/hr/doctype/expense_claim/expense_claim.py @@ -23,10 +23,10 @@ class ExpenseClaim(AccountsController): def validate(self): validate_active_employee(self.employee) - self.validate_advances() + set_employee_name(self) self.validate_sanctioned_amount() self.calculate_total_amount() - set_employee_name(self) + self.validate_advances() self.set_expense_account(validate=True) self.set_payable_account() self.set_cost_center() @@ -42,10 +42,18 @@ class ExpenseClaim(AccountsController): "2": "Cancelled" }[cstr(self.docstatus or 0)] - paid_amount = flt(self.total_amount_reimbursed) + flt(self.total_advance_amount) precision = self.precision("grand_total") - if (self.is_paid or (flt(self.total_sanctioned_amount) > 0 and self.docstatus == 1 - and flt(self.grand_total, precision) == flt(paid_amount, precision))) and self.approval_status == 'Approved': + + if ( + # set as paid + self.is_paid + or (flt(self.total_sanctioned_amount > 0) and ( + # grand total is reimbursed + (self.docstatus == 1 and flt(self.grand_total, precision) == flt(self.total_amount_reimbursed, precision)) + # grand total (to be paid) is 0 since linked advances already cover the claimed amount + or (flt(self.grand_total, precision) == 0) + )) + ) and self.approval_status == "Approved": status = "Paid" elif flt(self.total_sanctioned_amount) > 0 and self.docstatus == 1 and self.approval_status == 'Approved': status = "Unpaid" diff --git a/erpnext/hr/doctype/expense_claim/test_expense_claim.py b/erpnext/hr/doctype/expense_claim/test_expense_claim.py index 46958b1ec4c..01b74fb24b4 100644 --- a/erpnext/hr/doctype/expense_claim/test_expense_claim.py +++ b/erpnext/hr/doctype/expense_claim/test_expense_claim.py @@ -72,6 +72,72 @@ class TestExpenseClaim(unittest.TestCase): expense_claim = frappe.get_doc("Expense Claim", expense_claim.name) self.assertEqual(expense_claim.status, "Unpaid") + # expense claim without any sanctioned amount should not have status as Paid + claim = make_expense_claim(payable_account, 1000, 0, "_Test Company", "Travel Expenses - _TC") + self.assertEqual(claim.total_sanctioned_amount, 0) + self.assertEqual(claim.status, "Submitted") + + # no gl entries created + gl_entry = frappe.get_all('GL Entry', {'voucher_type': 'Expense Claim', 'voucher_no': claim.name}) + self.assertEqual(len(gl_entry), 0) + + def test_expense_claim_against_fully_paid_advances(self): + from erpnext.hr.doctype.employee_advance.test_employee_advance import ( + get_advances_for_claim, + make_employee_advance, + make_payment_entry, + ) + + frappe.db.delete("Employee Advance") + + payable_account = get_payable_account("_Test Company") + claim = make_expense_claim(payable_account, 1000, 1000, "_Test Company", "Travel Expenses - _TC", do_not_submit=True) + + advance = make_employee_advance(claim.employee) + pe = make_payment_entry(advance) + pe.submit() + + # claim for already paid out advances + claim = get_advances_for_claim(claim, advance.name) + claim.save() + claim.submit() + + self.assertEqual(claim.grand_total, 0) + self.assertEqual(claim.status, "Paid") + + def test_expense_claim_partially_paid_via_advance(self): + from erpnext.hr.doctype.employee_advance.test_employee_advance import ( + get_advances_for_claim, + make_employee_advance, + ) + from erpnext.hr.doctype.employee_advance.test_employee_advance import ( + make_payment_entry as make_advance_payment, + ) + + frappe.db.delete("Employee Advance") + + payable_account = get_payable_account("_Test Company") + claim = make_expense_claim(payable_account, 1000, 1000, "_Test Company", "Travel Expenses - _TC", do_not_submit=True) + + # link advance for partial amount + advance = make_employee_advance(claim.employee, {'advance_amount': 500}) + pe = make_advance_payment(advance) + pe.submit() + + claim = get_advances_for_claim(claim, advance.name) + claim.save() + claim.submit() + + self.assertEqual(claim.grand_total, 500) + self.assertEqual(claim.status, "Unpaid") + + # reimburse remaning amount + make_payment_entry(claim, payable_account, 500) + claim.reload() + + self.assertEqual(claim.total_amount_reimbursed, 500) + self.assertEqual(claim.status, "Paid") + def test_expense_claim_gl_entry(self): payable_account = get_payable_account(company_name) taxes = generate_taxes() diff --git a/erpnext/hr/doctype/leave_application/test_leave_application.py b/erpnext/hr/doctype/leave_application/test_leave_application.py index af633e776cd..4f7fcee30fe 100644 --- a/erpnext/hr/doctype/leave_application/test_leave_application.py +++ b/erpnext/hr/doctype/leave_application/test_leave_application.py @@ -25,6 +25,7 @@ from erpnext.hr.doctype.leave_application.leave_application import ( LeaveDayBlockedError, NotAnOptionalHoliday, OverlapError, + get_leave_allocation_records, get_leave_balance_on, get_leave_details, ) @@ -881,6 +882,27 @@ class TestLeaveApplication(unittest.TestCase): self.assertEqual(leave_allocation['leaves_pending_approval'], 1) self.assertEqual(leave_allocation['remaining_leaves'], 26) + @set_holiday_list('Salary Slip Test Holiday List', '_Test Company') + def test_get_leave_allocation_records(self): + employee = get_employee() + leave_type = create_leave_type( + leave_type_name="_Test_CF_leave_expiry", + is_carry_forward=1, + expire_carry_forwarded_leaves_after_days=90) + leave_type.insert() + + leave_alloc = create_carry_forwarded_allocation(employee, leave_type) + details = get_leave_allocation_records(employee.name, getdate(), leave_type.name) + expected_data = { + "from_date": getdate(leave_alloc.from_date), + "to_date": getdate(leave_alloc.to_date), + "total_leaves_allocated": 30.0, + "unused_leaves": 15.0, + "new_leaves_allocated": 15.0, + "leave_type": leave_type.name + } + self.assertEqual(details.get(leave_type.name), expected_data) + def create_carry_forwarded_allocation(employee, leave_type): # initial leave allocation @@ -902,6 +924,8 @@ def create_carry_forwarded_allocation(employee, leave_type): carry_forward=1) leave_allocation.submit() + return leave_allocation + def make_allocation_record(employee=None, leave_type=None, from_date=None, to_date=None, carry_forward=False, leaves=None): allocation = frappe.get_doc({ "doctype": "Leave Allocation", diff --git a/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py b/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py index 6e6943f71aa..ff1668003e7 100644 --- a/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py +++ b/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py @@ -8,14 +8,15 @@ from math import ceil import frappe from frappe import _, bold from frappe.model.document import Document -from frappe.utils import date_diff, flt, formatdate, get_last_day, getdate +from frappe.utils import date_diff, flt, formatdate, get_last_day, get_link_to_form, getdate from six import string_types class LeavePolicyAssignment(Document): def validate(self): - self.validate_policy_assignment_overlap() self.set_dates() + self.validate_policy_assignment_overlap() + self.warn_about_carry_forwarding() def on_submit(self): self.grant_leave_alloc_for_employee() @@ -39,6 +40,20 @@ class LeavePolicyAssignment(Document): frappe.throw(_("Leave Policy: {0} already assigned for Employee {1} for period {2} to {3}") .format(bold(self.leave_policy), bold(self.employee), bold(formatdate(self.effective_from)), bold(formatdate(self.effective_to)))) + def warn_about_carry_forwarding(self): + if not self.carry_forward: + return + + leave_types = get_leave_type_details() + leave_policy = frappe.get_doc("Leave Policy", self.leave_policy) + + for policy in leave_policy.leave_policy_details: + leave_type = leave_types.get(policy.leave_type) + if not leave_type.is_carry_forward: + msg = _("Leaves for the Leave Type {0} won't be carry-forwarded since carry-forwarding is disabled.").format( + frappe.bold(get_link_to_form("Leave Type", leave_type.name))) + frappe.msgprint(msg, indicator="orange", alert=True) + @frappe.whitelist() def grant_leave_alloc_for_employee(self): if self.leaves_allocated: diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index fae09b320a4..b5e16dd3c69 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -500,6 +500,9 @@ class JobCard(Document): 2: "Cancelled" }[self.docstatus or 0] + if self.for_quantity <= self.transferred_qty: + self.status = 'Material Transferred' + if self.time_logs: self.status = 'Work In Progress' @@ -507,10 +510,6 @@ class JobCard(Document): (self.for_quantity <= self.total_completed_qty or not self.items)): self.status = 'Completed' - if self.status != 'Completed': - if self.for_quantity <= self.transferred_qty: - self.status = 'Material Transferred' - if update_status: self.db_set('status', self.status) diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index 33425d23142..c5841c16f2d 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -169,6 +169,7 @@ class TestJobCard(FrappeTestCase): job_card_name = frappe.db.get_value("Job Card", {'work_order': self.work_order.name}) job_card = frappe.get_doc("Job Card", job_card_name) + self.assertEqual(job_card.status, "Open") # fully transfer both RMs transfer_entry_1 = make_stock_entry_from_jc(job_card_name) diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.js b/erpnext/manufacturing/doctype/production_plan/production_plan.js index f3ded994814..5653e1be75d 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.js +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.js @@ -2,6 +2,13 @@ // For license information, please see license.txt frappe.ui.form.on('Production Plan', { + + before_save: function(frm) { + // preserve temporary names on production plan item to re-link sub-assembly items + frm.doc.po_items.forEach(item => { + item.temporary_name = item.name; + }); + }, setup: function(frm) { frm.custom_make_buttons = { 'Work Order': 'Work Order / Subcontract PO', diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.json b/erpnext/manufacturing/doctype/production_plan/production_plan.json index acc1b0017d0..23b32379413 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.json +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.json @@ -351,7 +351,6 @@ "hide_border": 1 }, { - "depends_on": "get_items_from", "fieldname": "sub_assembly_items", "fieldtype": "Table", "label": "Sub Assembly Items", @@ -379,7 +378,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2022-03-14 03:56:23.209247", + "modified": "2022-03-25 09:15:25.017664", "modified_by": "Administrator", "module": "Manufacturing", "name": "Production Plan", diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index 60771592da5..a262b91cb5f 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -32,6 +32,7 @@ class ProductionPlan(Document): self.set_pending_qty_in_row_without_reference() self.calculate_total_planned_qty() self.set_status() + self._rename_temporary_references() def set_pending_qty_in_row_without_reference(self): "Set Pending Qty in independent rows (not from SO or MR)." @@ -57,6 +58,18 @@ class ProductionPlan(Document): if not flt(d.planned_qty): frappe.throw(_("Please enter Planned Qty for Item {0} at row {1}").format(d.item_code, d.idx)) + def _rename_temporary_references(self): + """ po_items and sub_assembly_items items are both constructed client side without saving. + + Attempt to fix linkages by using temporary names to map final row names. + """ + new_name_map = {d.temporary_name: d.name for d in self.po_items if d.temporary_name} + actual_names = {d.name for d in self.po_items} + + for sub_assy in self.sub_assembly_items: + if sub_assy.production_plan_item not in actual_names: + sub_assy.production_plan_item = new_name_map.get(sub_assy.production_plan_item) + @frappe.whitelist() def get_open_sales_orders(self): """ Pull sales orders which are pending to deliver based on criteria selected""" @@ -982,21 +995,21 @@ def get_materials_from_other_locations(item, warehouses, new_mr_items, company): required_qty = item.get("quantity") # get available material by transferring to production warehouse for d in locations: - if required_qty <=0: return + if required_qty <= 0: + return new_dict = copy.deepcopy(item) quantity = required_qty if d.get("qty") > required_qty else d.get("qty") - if required_qty > 0: - 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") - }) + 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) + required_qty -= quantity + new_mr_items.append(new_dict) # raise purchase request for remaining qty if required_qty: diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index d8e43db62cf..dae16e4bd30 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -617,6 +617,39 @@ class TestProductionPlan(FrappeTestCase): wo_doc.submit() self.assertEqual(wo_doc.qty, 0.55) + def test_temporary_name_relinking(self): + + pp = frappe.new_doc("Production Plan") + + # this can not be unittested so mocking data that would be expected + # from client side. + for _ in range(10): + po_item = pp.append("po_items", { + "name": frappe.generate_hash(length=10), + "temporary_name": frappe.generate_hash(length=10), + }) + pp.append("sub_assembly_items", { + "production_plan_item": po_item.temporary_name + }) + pp._rename_temporary_references() + + for po_item, subassy_item in zip(pp.po_items, pp.sub_assembly_items): + self.assertEqual(po_item.name, subassy_item.production_plan_item) + + # bad links should be erased + pp.append("sub_assembly_items", { + "production_plan_item": frappe.generate_hash(length=16) + }) + pp._rename_temporary_references() + self.assertIsNone(pp.sub_assembly_items[-1].production_plan_item) + pp.sub_assembly_items.pop() + + # reattempting on same doc shouldn't change anything + pp._rename_temporary_references() + for po_item, subassy_item in zip(pp.po_items, pp.sub_assembly_items): + self.assertEqual(po_item.name, subassy_item.production_plan_item) + + def create_production_plan(**args): """ sales_order (obj): Sales Order Doc Object diff --git a/erpnext/manufacturing/doctype/production_plan_item/production_plan_item.json b/erpnext/manufacturing/doctype/production_plan_item/production_plan_item.json index f829d57475a..df5862fcac8 100644 --- a/erpnext/manufacturing/doctype/production_plan_item/production_plan_item.json +++ b/erpnext/manufacturing/doctype/production_plan_item/production_plan_item.json @@ -27,7 +27,8 @@ "material_request", "material_request_item", "product_bundle_item", - "item_reference" + "item_reference", + "temporary_name" ], "fields": [ { @@ -204,17 +205,25 @@ "fieldtype": "Data", "hidden": 1, "label": "Item Reference" + }, + { + "fieldname": "temporary_name", + "fieldtype": "Data", + "hidden": 1, + "label": "temporary name" } ], "idx": 1, "istable": 1, "links": [], - "modified": "2021-06-28 18:31:06.822168", + "modified": "2022-03-24 04:54:09.940224", "modified_by": "Administrator", "module": "Manufacturing", "name": "Production Plan Item", + "naming_rule": "Random", "owner": "Administrator", "permissions": [], "sort_field": "modified", - "sort_order": "ASC" + "sort_order": "ASC", + "states": [] } \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 28226290e2f..ed7f843271b 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -352,7 +352,6 @@ class TestWorkOrder(FrappeTestCase): wo_order = make_wo_order_test_record(planned_start_date=now(), sales_order=so.name, qty=3) - wo_order.submit() self.assertEqual(wo_order.docstatus, 1) allow_overproduction("overproduction_percentage_for_sales_order", 0) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 542fc489b25..ee12597d24f 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -457,7 +457,8 @@ class WorkOrder(Document): mr_obj.update_requested_qty([self.material_request_item]) def update_ordered_qty(self): - if self.production_plan and self.production_plan_item: + if self.production_plan and self.production_plan_item \ + and not self.production_plan_sub_assembly_item: qty = frappe.get_value("Production Plan Item", self.production_plan_item, "ordered_qty") or 0.0 if self.docstatus == 1: @@ -640,9 +641,13 @@ class WorkOrder(Document): if not self.qty > 0: frappe.throw(_("Quantity to Manufacture must be greater than 0.")) - if self.production_plan and self.production_plan_item: + if self.production_plan and self.production_plan_item \ + and not self.production_plan_sub_assembly_item: qty_dict = frappe.db.get_value("Production Plan Item", self.production_plan_item, ["planned_qty", "ordered_qty"], as_dict=1) + if not qty_dict: + return + allowance_qty = flt(frappe.db.get_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order"))/100 * qty_dict.get("planned_qty", 0) @@ -1146,6 +1151,10 @@ def create_job_card(work_order, row, enable_capacity_planning=False, auto_create doc.insert() frappe.msgprint(_("Job card {0} created").format(get_link_to_form("Job Card", doc.name)), alert=True) + if enable_capacity_planning: + # automatically added scheduling rows shouldn't change status to WIP + doc.db_set("status", "Open") + return doc def get_work_order_operation_data(work_order, operation, workstation): diff --git a/erpnext/patches.txt b/erpnext/patches.txt index ccafb6bb564..70f642b7fdb 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -1,6 +1,6 @@ erpnext.patches.v12_0.update_is_cancelled_field -erpnext.patches.v13_0.add_bin_unique_constraint erpnext.patches.v11_0.rename_production_order_to_work_order +erpnext.patches.v13_0.add_bin_unique_constraint erpnext.patches.v11_0.refactor_naming_series erpnext.patches.v11_0.refactor_autoname_naming execute:frappe.reload_doc("accounts", "doctype", "POS Payment Method") #2020-05-28 @@ -352,5 +352,7 @@ erpnext.patches.v13_0.update_reserved_qty_closed_wo erpnext.patches.v13_0.amazon_mws_deprecation_warning erpnext.patches.v13_0.set_work_order_qty_in_so_from_mr erpnext.patches.v13_0.update_accounts_in_loan_docs -erpnext.patches.v13_0.remove_unknown_links_to_prod_plan_items -erpnext.patches.v13_0.rename_non_profit_fields \ No newline at end of file +erpnext.patches.v13_0.remove_unknown_links_to_prod_plan_items # 24-03-2022 +erpnext.patches.v13_0.rename_non_profit_fields +erpnext.patches.v13_0.enable_ksa_vat_docs #1 +erpnext.patches.v13_0.update_expense_claim_status_for_paid_advances \ No newline at end of file diff --git a/erpnext/patches/v13_0/enable_ksa_vat_docs.py b/erpnext/patches/v13_0/enable_ksa_vat_docs.py new file mode 100644 index 00000000000..3f482620e16 --- /dev/null +++ b/erpnext/patches/v13_0/enable_ksa_vat_docs.py @@ -0,0 +1,12 @@ +import frappe + +from erpnext.regional.saudi_arabia.setup import add_permissions, add_print_formats + + +def execute(): + company = frappe.get_all('Company', filters = {'country': 'Saudi Arabia'}) + if not company: + return + + add_print_formats() + add_permissions() \ No newline at end of file diff --git a/erpnext/patches/v13_0/rename_issue_doctype_fields.py b/erpnext/patches/v13_0/rename_issue_doctype_fields.py index bf5438c4d2e..80d51652abe 100644 --- a/erpnext/patches/v13_0/rename_issue_doctype_fields.py +++ b/erpnext/patches/v13_0/rename_issue_doctype_fields.py @@ -60,7 +60,7 @@ def execute(): def convert_to_seconds(value, unit): seconds = 0 - if value == 0: + if not value: return seconds if unit == 'Hours': seconds = value * 3600 diff --git a/erpnext/patches/v13_0/update_expense_claim_status_for_paid_advances.py b/erpnext/patches/v13_0/update_expense_claim_status_for_paid_advances.py new file mode 100644 index 00000000000..063de1637d0 --- /dev/null +++ b/erpnext/patches/v13_0/update_expense_claim_status_for_paid_advances.py @@ -0,0 +1,22 @@ +import frappe + + +def execute(): + """ + Update Expense Claim status to Paid if: + - the entire required amount is already covered via linked advances + - the claim is partially paid via advances and the rest is reimbursed + """ + + ExpenseClaim = frappe.qb.DocType('Expense Claim') + + (frappe.qb + .update(ExpenseClaim) + .set(ExpenseClaim.status, 'Paid') + .where( + ((ExpenseClaim.grand_total == 0) | (ExpenseClaim.grand_total == ExpenseClaim.total_amount_reimbursed)) + & (ExpenseClaim.approval_status == 'Approved') + & (ExpenseClaim.docstatus == 1) + & (ExpenseClaim.total_sanctioned_amount > 0) + ) + ).run() diff --git a/erpnext/payroll/doctype/payroll_entry/payroll_entry.py b/erpnext/payroll/doctype/payroll_entry/payroll_entry.py index 0dc694f5a8b..579c7b2f504 100644 --- a/erpnext/payroll/doctype/payroll_entry/payroll_entry.py +++ b/erpnext/payroll/doctype/payroll_entry/payroll_entry.py @@ -660,6 +660,8 @@ def submit_salary_slips_for_employees(payroll_entry, salary_slips, publish_progr if not_submitted_ss: frappe.msgprint(_("Could not submit some Salary Slips")) + frappe.flags.via_payroll_entry = False + @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs def get_payroll_entries_for_jv(doctype, txt, searchfield, start, page_len, filters): diff --git a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py index 4e5f00de600..c989965ac59 100644 --- a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py +++ b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py @@ -38,6 +38,8 @@ from erpnext.payroll.doctype.salary_structure.salary_structure import make_salar class TestSalarySlip(unittest.TestCase): def setUp(self): setup_test() + frappe.flags.pop("via_payroll_entry", None) + def tearDown(self): frappe.db.rollback() @@ -413,15 +415,17 @@ class TestSalarySlip(unittest.TestCase): "email_salary_slip_to_employee": 1 }) def test_email_salary_slip(self): - frappe.db.sql("delete from `tabEmail Queue`") + frappe.db.delete("Email Queue") - make_employee("test_email_salary_slip@salary.com", company="_Test Company") - ss = make_employee_salary_slip("test_email_salary_slip@salary.com", "Monthly", "Test Salary Slip Email") + user_id = "test_email_salary_slip@salary.com" + + make_employee(user_id, company="_Test Company") + ss = make_employee_salary_slip(user_id, "Monthly", "Test Salary Slip Email") ss.company = "_Test Company" ss.save() ss.submit() - email_queue = frappe.db.sql("""select name from `tabEmail Queue`""") + email_queue = frappe.db.a_row_exists("Email Queue") self.assertTrue(email_queue) def test_loan_repayment_salary_slip(self): diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js index 8068879810f..308f0a4871f 100644 --- a/erpnext/public/js/controllers/taxes_and_totals.js +++ b/erpnext/public/js/controllers/taxes_and_totals.js @@ -34,12 +34,12 @@ erpnext.taxes_and_totals = erpnext.payments.extend({ frappe.model.set_value(item.doctype, item.name, "rate", item_rate); }, - calculate_taxes_and_totals: function(update_paid_amount) { + calculate_taxes_and_totals: async function(update_paid_amount) { this.discount_amount_applied = false; this._calculate_taxes_and_totals(); this.calculate_discount_amount(); - this.calculate_shipping_charges(); + await this.calculate_shipping_charges(); // Advance calculation applicable to Sales /Purchase Invoice if(in_list(["Sales Invoice", "POS Invoice", "Purchase Invoice"], this.frm.doc.doctype) @@ -273,28 +273,7 @@ erpnext.taxes_and_totals = erpnext.payments.extend({ calculate_shipping_charges: function() { frappe.model.round_floats_in(this.frm.doc, ["total", "base_total", "net_total", "base_net_total"]); if (frappe.meta.get_docfield(this.frm.doc.doctype, "shipping_rule", this.frm.doc.name)) { - this.shipping_rule(); - this._calculate_taxes_and_totals(); - } - }, - - add_taxes_from_item_tax_template: function(item_tax_map) { - let me = this; - - if (item_tax_map && cint(frappe.defaults.get_default("add_taxes_from_item_tax_template"))) { - if (typeof (item_tax_map) == "string") { - item_tax_map = JSON.parse(item_tax_map); - } - - $.each(item_tax_map, function(tax, rate) { - let found = (me.frm.doc.taxes || []).find(d => d.account_head === tax); - if (!found) { - let child = frappe.model.add_child(me.frm.doc, "taxes"); - child.charge_type = "On Net Total"; - child.account_head = tax; - child.rate = 0; - } - }); + return this.shipping_rule(); } }, @@ -687,7 +666,12 @@ erpnext.taxes_and_totals = erpnext.payments.extend({ })); this.frm.doc.total_advance = flt(total_allocated_amount, precision("total_advance")); + if (this.frm.doc.write_off_outstanding_amount_automatically) { + this.frm.doc.write_off_amount = 0; + } + this.calculate_outstanding_amount(update_paid_amount); + this.calculate_write_off_amount(); }, is_internal_invoice: function() { @@ -812,7 +796,7 @@ erpnext.taxes_and_totals = erpnext.payments.extend({ this.frm.set_value('base_paid_amount', flt(base_paid_amount, precision("base_paid_amount"))); }, - calculate_change_amount: function(){ + calculate_change_amount: function() { this.frm.doc.change_amount = 0.0; this.frm.doc.base_change_amount = 0.0; if(in_list(["Sales Invoice", "POS Invoice"], this.frm.doc.doctype) @@ -823,26 +807,23 @@ erpnext.taxes_and_totals = erpnext.payments.extend({ var grand_total = this.frm.doc.rounded_total || this.frm.doc.grand_total; var base_grand_total = this.frm.doc.base_rounded_total || this.frm.doc.base_grand_total; - this.frm.doc.change_amount = flt(this.frm.doc.paid_amount - grand_total + - this.frm.doc.write_off_amount, precision("change_amount")); + this.frm.doc.change_amount = flt(this.frm.doc.paid_amount - grand_total, + precision("change_amount")); this.frm.doc.base_change_amount = flt(this.frm.doc.base_paid_amount - - base_grand_total + this.frm.doc.base_write_off_amount, - precision("base_change_amount")); + base_grand_total, precision("base_change_amount")); } } }, - calculate_write_off_amount: function(){ - if(this.frm.doc.paid_amount > this.frm.doc.grand_total){ - this.frm.doc.write_off_amount = flt(this.frm.doc.grand_total - this.frm.doc.paid_amount - + this.frm.doc.change_amount, precision("write_off_amount")); - + calculate_write_off_amount: function() { + if (this.frm.doc.write_off_outstanding_amount_automatically) { + this.frm.doc.write_off_amount = flt(this.frm.doc.outstanding_amount, precision("write_off_amount")); this.frm.doc.base_write_off_amount = flt(this.frm.doc.write_off_amount * this.frm.doc.conversion_rate, precision("base_write_off_amount")); - }else{ - this.frm.doc.paid_amount = 0.0; + + this.calculate_outstanding_amount(false); } - this.calculate_outstanding_amount(false); + } }); diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index b16ca29c21a..9ed78a7fb69 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -736,6 +736,26 @@ erpnext.TransactionController = erpnext.taxes_and_totals.extend({ }); }, + add_taxes_from_item_tax_template: function(item_tax_map) { + let me = this; + + if (item_tax_map && cint(frappe.defaults.get_default("add_taxes_from_item_tax_template"))) { + if (typeof (item_tax_map) == "string") { + item_tax_map = JSON.parse(item_tax_map); + } + + $.each(item_tax_map, function(tax, rate) { + let found = (me.frm.doc.taxes || []).find(d => d.account_head === tax); + if (!found) { + let child = frappe.model.add_child(me.frm.doc, "taxes"); + child.charge_type = "On Net Total"; + child.account_head = tax; + child.rate = 0; + } + }); + } + }, + serial_no: function(doc, cdt, cdn) { var me = this; var item = frappe.get_doc(cdt, cdn); @@ -1066,6 +1086,9 @@ erpnext.TransactionController = erpnext.taxes_and_totals.extend({ return this.frm.call({ doc: this.frm.doc, method: "apply_shipping_rule", + callback: function(r) { + me._calculate_taxes_and_totals(); + } }).fail(() => this.frm.set_value('shipping_rule', '')); } }, diff --git a/erpnext/public/js/utils/serial_no_batch_selector.js b/erpnext/public/js/utils/serial_no_batch_selector.js index 16e3fa0abd1..cbecb11949b 100644 --- a/erpnext/public/js/utils/serial_no_batch_selector.js +++ b/erpnext/public/js/utils/serial_no_batch_selector.js @@ -75,7 +75,7 @@ erpnext.SerialNoBatchSelector = Class.extend({ fieldtype:'Float', read_only: me.has_batch && !me.has_serial_no, label: __(me.has_batch && !me.has_serial_no ? 'Selected Qty' : 'Qty'), - default: flt(me.item.stock_qty), + default: flt(me.item.stock_qty) || flt(me.item.transfer_qty), }, ...get_pending_qty_fields(me), { @@ -94,14 +94,16 @@ erpnext.SerialNoBatchSelector = Class.extend({ description: __('Fetch Serial Numbers based on FIFO'), click: () => { let qty = this.dialog.fields_dict.qty.get_value(); + let already_selected_serial_nos = get_selected_serial_nos(me); let numbers = frappe.call({ method: "erpnext.stock.doctype.serial_no.serial_no.auto_fetch_serial_number", args: { qty: qty, item_code: me.item_code, warehouse: typeof me.warehouse_details.name == "string" ? me.warehouse_details.name : '', - batch_no: me.item.batch_no || null, - posting_date: me.frm.doc.posting_date || me.frm.doc.transaction_date + batch_nos: me.item.batch_no || null, + posting_date: me.frm.doc.posting_date || me.frm.doc.transaction_date, + exclude_sr_nos: already_selected_serial_nos } }); @@ -575,21 +577,40 @@ function get_pending_qty_fields(me) { return pending_qty_fields; } -function calc_total_selected_qty(me) { +// get all items with same item code except row for which selector is open. +function get_rows_with_same_item_code(me) { const { frm: { doc: { items }}, item: { name, item_code }} = me; - const totalSelectedQty = items - .filter( item => ( item.name !== name ) && ( item.item_code === item_code ) ) - .map( item => flt(item.qty) ) - .reduce( (i, j) => i + j, 0); + return items.filter(item => (item.name !== name) && (item.item_code === item_code)) +} + +function calc_total_selected_qty(me) { + const totalSelectedQty = get_rows_with_same_item_code(me) + .map(item => flt(item.qty)) + .reduce((i, j) => i + j, 0); return totalSelectedQty; } +function get_selected_serial_nos(me) { + const selected_serial_nos = get_rows_with_same_item_code(me) + .map(item => item.serial_no) + .filter(serial => serial) + .map(sr_no_string => sr_no_string.split('\n')) + .reduce((acc, arr) => acc.concat(arr), []) + .filter(serial => serial); + return selected_serial_nos; +}; + function check_can_calculate_pending_qty(me) { const { frm: { doc }, item } = me; const docChecks = doc.bom_no && doc.fg_completed_qty && erpnext.stock.bom && erpnext.stock.bom.name === doc.bom_no; - const itemChecks = !!item && !item.allow_alternative_item; + const itemChecks = !!item + && !item.allow_alternative_item + && erpnext.stock.bom && erpnext.stock.items + && (item.item_code in erpnext.stock.bom.items); return docChecks && itemChecks; } + +//# sourceURL=serial_no_batch_selector.js diff --git a/erpnext/public/scss/shopping_cart.scss b/erpnext/public/scss/shopping_cart.scss index ff2482d3f64..843bd86baac 100644 --- a/erpnext/public/scss/shopping_cart.scss +++ b/erpnext/public/scss/shopping_cart.scss @@ -4,6 +4,7 @@ --green-info: #38A160; --product-bg-color: white; --body-bg-color: var(--gray-50); + --text-md: 13px; // variables are in desk folder in frappe for v13, this is a temporary fix } body.product-page { @@ -264,6 +265,15 @@ body.product-page { font-size: 13px; } + .filter-lookup-input { + background-color: white; + border: 1px solid var(--gray-300); + + &:focus { + border: 1px solid var(--primary); + } + } + .filter-label { font-size: 11px; font-weight: 600; diff --git a/erpnext/regional/india/utils.py b/erpnext/regional/india/utils.py index 2287714a008..9b2423a6b57 100644 --- a/erpnext/regional/india/utils.py +++ b/erpnext/regional/india/utils.py @@ -21,8 +21,9 @@ PAN_NUMBER_FORMAT = re.compile("[A-Z]{5}[0-9]{4}[A-Z]{1}") def validate_gstin_for_india(doc, method): - if hasattr(doc, 'gst_state') and doc.gst_state: - doc.gst_state_number = state_numbers[doc.gst_state] + if hasattr(doc, 'gst_state'): + set_gst_state_and_state_number(doc) + if not hasattr(doc, 'gstin') or not doc.gstin: return @@ -52,7 +53,6 @@ def validate_gstin_for_india(doc, method): frappe.throw(_("The input you've entered doesn't match the format of GSTIN."), title=_("Invalid GSTIN")) validate_gstin_check_digit(doc.gstin) - set_gst_state_and_state_number(doc) if not doc.gst_state: frappe.throw(_("Please enter GST state"), title=_("Invalid State")) @@ -84,17 +84,14 @@ def update_gst_category(doc, method): frappe.db.set_value(link.link_doctype, {'name': link.link_name, 'gst_category': 'Unregistered'}, 'gst_category', 'Registered Regular') def set_gst_state_and_state_number(doc): - if not doc.gst_state: - if not doc.state: - return + if not doc.gst_state and doc.state: state = doc.state.lower() states_lowercase = {s.lower():s for s in states} if state in states_lowercase: doc.gst_state = states_lowercase[state] else: return - - doc.gst_state_number = state_numbers[doc.gst_state] + doc.gst_state_number = state_numbers.get(doc.gst_state) def validate_gstin_check_digit(gstin, label='GSTIN'): ''' Function to validate the check digit of the GSTIN.''' diff --git a/erpnext/selling/doctype/sales_order/sales_order.json b/erpnext/selling/doctype/sales_order/sales_order.json index 7e99a062439..fe2f14e19a6 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.json +++ b/erpnext/selling/doctype/sales_order/sales_order.json @@ -130,6 +130,7 @@ "per_delivered", "column_break_81", "per_billed", + "per_picked", "billing_status", "sales_team_section_break", "sales_partner", @@ -1514,13 +1515,19 @@ "fieldtype": "Currency", "label": "Amount Eligible for Commission", "read_only": 1 + }, + { + "fieldname": "per_picked", + "fieldtype": "Percent", + "label": "% Picked", + "read_only": 1 } ], "icon": "fa fa-file-text", "idx": 105, "is_submittable": 1, "links": [], - "modified": "2021-10-05 12:16:40.775704", + "modified": "2022-03-15 21:38:31.437586", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order", @@ -1594,6 +1601,7 @@ "show_name_in_global_search": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "timeline_field": "customer", "title_field": "customer_name", "track_changes": 1, diff --git a/erpnext/selling/doctype/sales_order_item/sales_order_item.json b/erpnext/selling/doctype/sales_order_item/sales_order_item.json index edfde899323..f13cc7e5c2a 100644 --- a/erpnext/selling/doctype/sales_order_item/sales_order_item.json +++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.json @@ -23,6 +23,7 @@ "quantity_and_rate", "qty", "stock_uom", + "picked_qty", "col_break2", "uom", "conversion_factor", @@ -796,12 +797,17 @@ "fieldtype": "Check", "label": "Grant Commission", "read_only": 1 + }, + { + "fieldname": "picked_qty", + "fieldtype": "Float", + "label": "Picked Qty" } ], "idx": 1, "istable": 1, "links": [], - "modified": "2022-02-24 14:41:57.325799", + "modified": "2022-03-15 20:17:33.984799", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order Item", diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py index efcaa90198c..c7835930021 100644 --- a/erpnext/stock/doctype/item/item.py +++ b/erpnext/stock/doctype/item/item.py @@ -108,6 +108,7 @@ class Item(Document): self.validate_variant_attributes() self.validate_variant_based_on_change() self.validate_fixed_asset() + self.clear_retain_sample() self.validate_retain_sample() self.validate_uom_conversion_factor() self.validate_customer_provided_part() @@ -210,6 +211,13 @@ class Item(Document): frappe.throw(_("{0} Retain Sample is based on batch, please check Has Batch No to retain sample of item").format( self.item_code)) + def clear_retain_sample(self): + if not self.has_batch_no: + self.retain_sample = None + + if not self.retain_sample: + self.sample_quantity = None + def add_default_uom_in_conversion_factor_table(self): if not self.is_new() and self.has_value_changed("stock_uom"): self.uoms = [] diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py index a50ced006cc..9e8bf02a707 100644 --- a/erpnext/stock/doctype/item/test_item.py +++ b/erpnext/stock/doctype/item/test_item.py @@ -622,6 +622,20 @@ class TestItem(FrappeTestCase): item.item_group = "All Item Groups" item.save() # if item code saved without item_code then series worked + @change_settings("Stock Settings", {"sample_retention_warehouse": "_Test Warehouse - _TC"}) + def test_retain_sample(self): + item = make_item("_TestRetainSample", {'has_batch_no': 1, 'retain_sample': 1, 'sample_quantity': 1}) + + self.assertEqual(item.has_batch_no, 1) + self.assertEqual(item.retain_sample, 1) + self.assertEqual(item.sample_quantity, 1) + + item.has_batch_no = None + item.save() + self.assertEqual(item.retain_sample, None) + self.assertEqual(item.sample_quantity, None) + item.delete() + def set_item_variant_settings(fields): doc = frappe.get_doc('Item Variant Settings') diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py index 50d43171f80..341c83023a3 100644 --- a/erpnext/stock/doctype/material_request/material_request.py +++ b/erpnext/stock/doctype/material_request/material_request.py @@ -83,6 +83,9 @@ class MaterialRequest(BuyingController): self.reset_default_field_value("set_warehouse", "items", "warehouse") self.reset_default_field_value("set_from_warehouse", "items", "from_warehouse") + def before_update_after_submit(self): + self.validate_schedule_date() + def validate_material_request_type(self): """ Validate fields in accordance with selected type """ diff --git a/erpnext/stock/doctype/material_request_item/material_request_item.json b/erpnext/stock/doctype/material_request_item/material_request_item.json index 2bad42a0ebb..dd66cfff8be 100644 --- a/erpnext/stock/doctype/material_request_item/material_request_item.json +++ b/erpnext/stock/doctype/material_request_item/material_request_item.json @@ -177,6 +177,7 @@ "fieldtype": "Column Break" }, { + "allow_on_submit": 1, "bold": 1, "columns": 2, "fieldname": "schedule_date", @@ -459,7 +460,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2021-11-03 14:40:24.409826", + "modified": "2022-03-10 18:42:42.705190", "modified_by": "Administrator", "module": "Stock", "name": "Material Request Item", diff --git a/erpnext/stock/doctype/pick_list/pick_list.js b/erpnext/stock/doctype/pick_list/pick_list.js index 730fd7a829c..13b74b5eb16 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.js +++ b/erpnext/stock/doctype/pick_list/pick_list.js @@ -146,10 +146,6 @@ frappe.ui.form.on('Pick List', { customer: frm.doc.customer }; frm.get_items_btn = frm.add_custom_button(__('Get Items'), () => { - if (!frm.doc.customer) { - frappe.msgprint(__('Please select Customer first')); - return; - } erpnext.utils.map_current_doc({ method: 'erpnext.selling.doctype.sales_order.sales_order.create_pick_list', source_doctype: 'Sales Order', diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index b2eaecb5868..35cbc2fd858 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -3,6 +3,8 @@ import json from collections import OrderedDict, defaultdict +from itertools import groupby +from operator import itemgetter import frappe from frappe import _ @@ -24,8 +26,21 @@ class PickList(Document): def before_save(self): self.set_item_locations() + # set percentage picked in SO + for location in self.get('locations'): + if location.sales_order and frappe.db.get_value("Sales Order",location.sales_order,"per_picked") == 100: + frappe.throw("Row " + str(location.idx) + " has been picked already!") + def before_submit(self): for item in self.locations: + # if the user has not entered any picked qty, set it to stock_qty, before submit + if item.picked_qty == 0: + item.picked_qty = item.stock_qty + + if item.sales_order_item: + # update the picked_qty in SO Item + self.update_so(item.sales_order_item,item.picked_qty,item.item_code) + if not frappe.get_cached_value('Item', item.item_code, 'has_serial_no'): continue if not item.serial_no: @@ -37,6 +52,32 @@ class PickList(Document): frappe.throw(_('For item {0} at row {1}, count of serial numbers does not match with the picked quantity') .format(frappe.bold(item.item_code), frappe.bold(item.idx)), title=_("Quantity Mismatch")) + def before_cancel(self): + #update picked_qty in SO Item on cancel of PL + for item in self.get('locations'): + if item.sales_order_item: + self.update_so(item.sales_order_item, -1 * item.picked_qty, item.item_code) + + def update_so(self,so_item,picked_qty,item_code): + so_doc = frappe.get_doc("Sales Order",frappe.db.get_value("Sales Order Item",so_item,"parent")) + already_picked,actual_qty = frappe.db.get_value("Sales Order Item",so_item,["picked_qty","qty"]) + + if self.docstatus == 1: + if (((already_picked + picked_qty)/ actual_qty)*100) > (100 + flt(frappe.db.get_single_value('Stock Settings', 'over_delivery_receipt_allowance'))): + frappe.throw('You are picking more than required quantity for ' + item_code + '. Check if there is any other pick list created for '+so_doc.name) + + frappe.db.set_value("Sales Order Item",so_item,"picked_qty",already_picked+picked_qty) + + total_picked_qty = 0 + total_so_qty = 0 + for item in so_doc.get('items'): + total_picked_qty += flt(item.picked_qty) + total_so_qty += flt(item.stock_qty) + total_picked_qty=total_picked_qty + picked_qty + per_picked = total_picked_qty/total_so_qty * 100 + + so_doc.db_set("per_picked", flt(per_picked) ,update_modified=False) + @frappe.whitelist() def set_item_locations(self, save=False): self.validate_for_qty() @@ -64,10 +105,6 @@ class PickList(Document): item_doc.name = None for row in locations: - row.update({ - 'picked_qty': row.stock_qty - }) - location = item_doc.as_dict() location.update(row) self.append('locations', location) @@ -340,63 +377,102 @@ def get_available_item_locations_for_other_item(item_code, from_warehouses, requ def create_delivery_note(source_name, target_doc=None): pick_list = frappe.get_doc('Pick List', source_name) validate_item_locations(pick_list) - - sales_orders = [d.sales_order for d in pick_list.locations if d.sales_order] - sales_orders = set(sales_orders) - + sales_dict = dict() + sales_orders = [] delivery_note = None - for sales_order in sales_orders: - delivery_note = create_delivery_note_from_sales_order(sales_order, - delivery_note, skip_item_mapping=True) + for location in pick_list.locations: + if location.sales_order: + sales_orders.append([frappe.db.get_value("Sales Order",location.sales_order,'customer'),location.sales_order]) + # Group sales orders by customer + for key,keydata in groupby(sales_orders,key=itemgetter(0)): + sales_dict[key] = set([d[1] for d in keydata]) - # map rows without sales orders as well - if not delivery_note: + if sales_dict: + delivery_note = create_dn_with_so(sales_dict,pick_list) + + is_item_wo_so = 0 + for location in pick_list.locations : + if not location.sales_order: + is_item_wo_so = 1 + break + if is_item_wo_so == 1: + # Create a DN for items without sales orders as well + delivery_note = create_dn_wo_so(pick_list) + + frappe.msgprint(_('Delivery Note(s) created for the Pick List')) + return delivery_note + +def create_dn_wo_so(pick_list): delivery_note = frappe.new_doc("Delivery Note") - item_table_mapper = { - 'doctype': 'Delivery Note Item', - 'field_map': { - 'rate': 'rate', - 'name': 'so_detail', - 'parent': 'against_sales_order', - }, - 'condition': lambda doc: abs(doc.delivered_qty) < abs(doc.qty) and doc.delivered_by_supplier!=1 - } - - item_table_mapper_without_so = { - 'doctype': 'Delivery Note Item', - 'field_map': { - 'rate': 'rate', - 'name': 'name', - 'parent': '', + item_table_mapper_without_so = { + 'doctype': 'Delivery Note Item', + 'field_map': { + 'rate': 'rate', + 'name': 'name', + 'parent': '', + } } - } + map_pl_locations(pick_list,item_table_mapper_without_so,delivery_note) + delivery_note.insert(ignore_mandatory = True) + + return delivery_note + + +def create_dn_with_so(sales_dict,pick_list): + delivery_note = None + + for customer in sales_dict: + for so in sales_dict[customer]: + delivery_note = None + delivery_note = create_delivery_note_from_sales_order(so, + delivery_note, skip_item_mapping=True) + + item_table_mapper = { + 'doctype': 'Delivery Note Item', + 'field_map': { + 'rate': 'rate', + 'name': 'so_detail', + 'parent': 'against_sales_order', + }, + 'condition': lambda doc: abs(doc.delivered_qty) < abs(doc.qty) and doc.delivered_by_supplier!=1 + } + break + if delivery_note: + # map all items of all sales orders of that customer + for so in sales_dict[customer]: + map_pl_locations(pick_list,item_table_mapper,delivery_note,so) + delivery_note.insert(ignore_mandatory = True) + + return delivery_note + +def map_pl_locations(pick_list,item_mapper,delivery_note,sales_order = None): for location in pick_list.locations: - if location.sales_order_item: - sales_order_item = frappe.get_cached_doc('Sales Order Item', {'name':location.sales_order_item}) - else: - sales_order_item = None + if location.sales_order == sales_order: + if location.sales_order_item: + sales_order_item = frappe.get_cached_doc('Sales Order Item', {'name':location.sales_order_item}) + else: + sales_order_item = None - source_doc, table_mapper = [sales_order_item, item_table_mapper] if sales_order_item \ - else [location, item_table_mapper_without_so] + source_doc, table_mapper = [sales_order_item, item_mapper] if sales_order_item \ + else [location, item_mapper] - dn_item = map_child_doc(source_doc, delivery_note, table_mapper) + dn_item = map_child_doc(source_doc, delivery_note, table_mapper) - if dn_item: - dn_item.warehouse = location.warehouse - dn_item.qty = flt(location.picked_qty) / (flt(location.conversion_factor) or 1) - dn_item.batch_no = location.batch_no - dn_item.serial_no = location.serial_no - - update_delivery_note_item(source_doc, dn_item, delivery_note) + if dn_item: + dn_item.warehouse = location.warehouse + dn_item.qty = flt(location.picked_qty) / (flt(location.conversion_factor) or 1) + dn_item.batch_no = location.batch_no + dn_item.serial_no = location.serial_no + update_delivery_note_item(source_doc, dn_item, delivery_note) set_delivery_note_missing_values(delivery_note) delivery_note.pick_list = pick_list.name - delivery_note.customer = pick_list.customer if pick_list.customer else None + delivery_note.company = pick_list.company + delivery_note.customer = frappe.get_value("Sales Order",sales_order,"customer") - return delivery_note @frappe.whitelist() def create_stock_entry(pick_list): diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py index f3b6b89784a..f60104c09ac 100644 --- a/erpnext/stock/doctype/pick_list/test_pick_list.py +++ b/erpnext/stock/doctype/pick_list/test_pick_list.py @@ -17,7 +17,6 @@ from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import ( class TestPickList(FrappeTestCase): - def test_pick_list_picks_warehouse_for_each_item(self): try: frappe.get_doc({ @@ -188,7 +187,6 @@ class TestPickList(FrappeTestCase): }] }) pick_list.set_item_locations() - self.assertEqual(pick_list.locations[0].batch_no, oldest_batch_no) pr1.cancel() @@ -311,6 +309,7 @@ class TestPickList(FrappeTestCase): 'item_code': '_Test Item', 'qty': 1, 'conversion_factor': 5, + 'stock_qty':5, 'delivery_date': frappe.utils.today() }, { 'item_code': '_Test Item', @@ -329,9 +328,9 @@ class TestPickList(FrappeTestCase): 'purpose': 'Delivery', 'locations': [{ 'item_code': '_Test Item', - 'qty': 1, - 'stock_qty': 5, - 'conversion_factor': 5, + 'qty': 2, + 'stock_qty': 1, + 'conversion_factor': 0.5, 'sales_order': sales_order.name, 'sales_order_item': sales_order.items[0].name , }, { @@ -389,6 +388,95 @@ class TestPickList(FrappeTestCase): for expected_item, created_item in zip(expected_items, pl.locations): _compare_dicts(expected_item, created_item) + def test_multiple_dn_creation(self): + sales_order_1 = frappe.get_doc({ + 'doctype': 'Sales Order', + 'customer': '_Test Customer', + 'company': '_Test Company', + 'items': [{ + 'item_code': '_Test Item', + 'qty': 1, + 'conversion_factor': 1, + 'delivery_date': frappe.utils.today() + }], + }).insert() + sales_order_1.submit() + sales_order_2 = frappe.get_doc({ + 'doctype': 'Sales Order', + 'customer': '_Test Customer 1', + 'company': '_Test Company', + 'items': [{ + 'item_code': '_Test Item 2', + 'qty': 1, + 'conversion_factor': 1, + 'delivery_date': frappe.utils.today() + }, + ], + }).insert() + sales_order_2.submit() + pick_list = frappe.get_doc({ + 'doctype': 'Pick List', + 'company': '_Test Company', + 'items_based_on': 'Sales Order', + 'purpose': 'Delivery', + 'picker':'P001', + 'locations': [{ + 'item_code': '_Test Item ', + 'qty': 1, + 'stock_qty': 1, + 'conversion_factor': 1, + 'sales_order': sales_order_1.name, + 'sales_order_item': sales_order_1.items[0].name , + }, { + 'item_code': '_Test Item 2', + 'qty': 1, + 'stock_qty': 1, + 'conversion_factor': 1, + 'sales_order': sales_order_2.name, + 'sales_order_item': sales_order_2.items[0].name , + } + ] + }) + pick_list.set_item_locations() + pick_list.submit() + create_delivery_note(pick_list.name) + for dn in frappe.get_all("Delivery Note",filters={"pick_list":pick_list.name,"customer":"_Test Customer"},fields={"name"}): + for dn_item in frappe.get_doc("Delivery Note",dn.name).get("items"): + self.assertEqual(dn_item.item_code, '_Test Item') + self.assertEqual(dn_item.against_sales_order,sales_order_1.name) + for dn in frappe.get_all("Delivery Note",filters={"pick_list":pick_list.name,"customer":"_Test Customer 1"},fields={"name"}): + for dn_item in frappe.get_doc("Delivery Note",dn.name).get("items"): + self.assertEqual(dn_item.item_code, '_Test Item 2') + self.assertEqual(dn_item.against_sales_order,sales_order_2.name) + #test DN creation without so + pick_list_1 = frappe.get_doc({ + 'doctype': 'Pick List', + 'company': '_Test Company', + 'purpose': 'Delivery', + 'picker':'P001', + 'locations': [{ + 'item_code': '_Test Item ', + 'qty': 1, + 'stock_qty': 1, + 'conversion_factor': 1, + }, { + 'item_code': '_Test Item 2', + 'qty': 2, + 'stock_qty': 2, + 'conversion_factor': 1, + } + ] + }) + pick_list_1.set_item_locations() + pick_list_1.submit() + create_delivery_note(pick_list_1.name) + for dn in frappe.get_all("Delivery Note",filters={"pick_list":pick_list_1.name},fields={"name"}): + for dn_item in frappe.get_doc("Delivery Note",dn.name).get("items"): + if dn_item.item_code == '_Test Item': + self.assertEqual(dn_item.qty,1) + if dn_item.item_code == '_Test Item 2': + self.assertEqual(dn_item.qty,2) + # def test_pick_list_skips_items_in_expired_batch(self): # pass diff --git a/erpnext/stock/doctype/serial_no/serial_no.py b/erpnext/stock/doctype/serial_no/serial_no.py index 350d5fec50f..22d84e9cb7d 100644 --- a/erpnext/stock/doctype/serial_no/serial_no.py +++ b/erpnext/stock/doctype/serial_no/serial_no.py @@ -3,13 +3,22 @@ import json +from typing import List, Optional, Union import frappe from frappe import ValidationError, _ from frappe.model.naming import make_autoname -from frappe.utils import add_days, cint, cstr, flt, get_link_to_form, getdate, nowdate -from six import string_types -from six.moves import map +from frappe.query_builder.functions import Coalesce +from frappe.utils import ( + add_days, + cint, + cstr, + flt, + get_link_to_form, + getdate, + nowdate, + safe_json_loads, +) from erpnext.controllers.stock_controller import StockController from erpnext.stock.get_item_details import get_reserved_qty_for_so @@ -583,30 +592,45 @@ def get_delivery_note_serial_no(item_code, qty, delivery_note): return serial_nos @frappe.whitelist() -def auto_fetch_serial_number(qty, item_code, warehouse, posting_date=None, batch_nos=None, for_doctype=None): - filters = { "item_code": item_code, "warehouse": warehouse } +def auto_fetch_serial_number( + qty: float, + item_code: str, + warehouse: str, + posting_date: Optional[str] = None, + batch_nos: Optional[Union[str, List[str]]] = None, + for_doctype: Optional[str] = None, + exclude_sr_nos: Optional[List[str]] = None + ) -> List[str]: + + filters = frappe._dict({"item_code": item_code, "warehouse": warehouse}) + + if exclude_sr_nos is None: + exclude_sr_nos = [] + else: + exclude_sr_nos = safe_json_loads(exclude_sr_nos) + exclude_sr_nos = get_serial_nos(clean_serial_no_string("\n".join(exclude_sr_nos))) if batch_nos: - try: - filters["batch_no"] = json.loads(batch_nos) if (type(json.loads(batch_nos)) == list) else [json.loads(batch_nos)] - except Exception: - filters["batch_no"] = [batch_nos] + batch_nos = safe_json_loads(batch_nos) + if isinstance(batch_nos, list): + filters.batch_no = batch_nos + else: + filters.batch_no = [str(batch_nos)] if posting_date: - filters["expiry_date"] = posting_date + filters.expiry_date = posting_date serial_numbers = [] if for_doctype == 'POS Invoice': - reserved_sr_nos = get_pos_reserved_serial_nos(filters) - serial_numbers = fetch_serial_numbers(filters, qty, do_not_include=reserved_sr_nos) - else: - serial_numbers = fetch_serial_numbers(filters, qty) + exclude_sr_nos.extend(get_pos_reserved_serial_nos(filters)) - return [d.get('name') for d in serial_numbers] + serial_numbers = fetch_serial_numbers(filters, qty, do_not_include=exclude_sr_nos) + + return sorted([d.get('name') for d in serial_numbers]) @frappe.whitelist() def get_pos_reserved_serial_nos(filters): - if isinstance(filters, string_types): + if isinstance(filters, str): filters = json.loads(filters) pos_transacted_sr_nos = frappe.db.sql("""select item.serial_no as serial_no @@ -629,37 +653,37 @@ def get_pos_reserved_serial_nos(filters): def fetch_serial_numbers(filters, qty, do_not_include=None): if do_not_include is None: do_not_include = [] - batch_join_selection = "" - batch_no_condition = "" + batch_nos = filters.get("batch_no") expiry_date = filters.get("expiry_date") + serial_no = frappe.qb.DocType("Serial No") + + query = ( + frappe.qb + .from_(serial_no) + .select(serial_no.name) + .where( + (serial_no.item_code == filters["item_code"]) + & (serial_no.warehouse == filters["warehouse"]) + & (Coalesce(serial_no.sales_invoice, "") == "") + & (Coalesce(serial_no.delivery_document_no, "") == "") + ) + .orderby(serial_no.creation) + .limit(qty or 1) + ) + + if do_not_include: + query = query.where(serial_no.name.notin(do_not_include)) + if batch_nos: - batch_no_condition = """and sr.batch_no in ({}) """.format(', '.join("'%s'" % d for d in batch_nos)) + query = query.where(serial_no.batch_no.isin(batch_nos)) if expiry_date: - batch_join_selection = "LEFT JOIN `tabBatch` batch on sr.batch_no = batch.name " - expiry_date_cond = "AND ifnull(batch.expiry_date, '2500-12-31') >= %(expiry_date)s " - batch_no_condition += expiry_date_cond - - excluded_sr_nos = ", ".join(["" + frappe.db.escape(sr) + "" for sr in do_not_include]) or "''" - serial_numbers = frappe.db.sql(""" - SELECT sr.name FROM `tabSerial No` sr {batch_join_selection} - WHERE - sr.name not in ({excluded_sr_nos}) AND - sr.item_code = %(item_code)s AND - sr.warehouse = %(warehouse)s AND - ifnull(sr.sales_invoice,'') = '' AND - ifnull(sr.delivery_document_no, '') = '' - {batch_no_condition} - ORDER BY - sr.creation - LIMIT - {qty} - """.format( - excluded_sr_nos=excluded_sr_nos, - qty=qty or 1, - batch_join_selection=batch_join_selection, - batch_no_condition=batch_no_condition - ), filters, as_dict=1) + batch = frappe.qb.DocType("Batch") + query = (query + .left_join(batch).on(serial_no.batch_no == batch.name) + .where(Coalesce(batch.expiry_date, "4000-12-31") >= expiry_date) + ) + serial_numbers = query.run(as_dict=True) return serial_numbers diff --git a/erpnext/stock/doctype/serial_no/test_serial_no.py b/erpnext/stock/doctype/serial_no/test_serial_no.py index 057a7d4c01f..7df0a56b7f3 100644 --- a/erpnext/stock/doctype/serial_no/test_serial_no.py +++ b/erpnext/stock/doctype/serial_no/test_serial_no.py @@ -6,10 +6,12 @@ import frappe +from frappe.tests.utils import FrappeTestCase 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 * 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 @@ -18,9 +20,6 @@ from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse test_dependencies = ["Item"] test_records = frappe.get_test_records('Serial No') -from frappe.tests.utils import FrappeTestCase - -from erpnext.stock.doctype.serial_no.serial_no import * class TestSerialNo(FrappeTestCase): @@ -242,3 +241,57 @@ class TestSerialNo(FrappeTestCase): ) self.assertEqual(value_diff, -113) + def test_auto_fetch(self): + item_code = make_item(properties={ + "has_serial_no": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "serial_no_series": "TEST.#######" + }).name + warehouse = "_Test Warehouse - _TC" + + in1 = make_stock_entry(item_code=item_code, to_warehouse=warehouse, qty=5) + in2 = make_stock_entry(item_code=item_code, to_warehouse=warehouse, qty=5) + + in1.reload() + in2.reload() + + batch1 = in1.items[0].batch_no + batch2 = in2.items[0].batch_no + + batch_wise_serials = { + batch1 : get_serial_nos(in1.items[0].serial_no), + batch2: get_serial_nos(in2.items[0].serial_no) + } + + # Test FIFO + first_fetch = auto_fetch_serial_number(5, item_code, warehouse) + self.assertEqual(first_fetch, batch_wise_serials[batch1]) + + # partial FIFO + partial_fetch = auto_fetch_serial_number(2, item_code, warehouse) + self.assertTrue(set(partial_fetch).issubset(set(first_fetch)), + msg=f"{partial_fetch} should be subset of {first_fetch}") + + # exclusion + remaining = auto_fetch_serial_number(3, item_code, warehouse, + exclude_sr_nos=json.dumps(partial_fetch)) + self.assertEqual(sorted(remaining + partial_fetch), first_fetch) + + # batchwise + for batch, expected_serials in batch_wise_serials.items(): + fetched_sr = auto_fetch_serial_number(5, item_code, warehouse, batch_nos=batch) + self.assertEqual(fetched_sr, sorted(expected_serials)) + + # non existing warehouse + self.assertEqual(auto_fetch_serial_number(10, item_code, warehouse="Nonexisting"), []) + + # multi batch + all_serials = [sr for sr_list in batch_wise_serials.values() for sr in sr_list] + fetched_serials = auto_fetch_serial_number(10, item_code, warehouse, batch_nos=list(batch_wise_serials.keys())) + self.assertEqual(sorted(all_serials), fetched_serials) + + # expiry date + frappe.db.set_value("Batch", batch1, "expiry_date", "1980-01-01") + non_expired_serials = auto_fetch_serial_number(5, item_code, warehouse, posting_date="2021-01-01", batch_nos=batch1) + self.assertEqual(non_expired_serials, []) diff --git a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py index 39ca97a47bc..29a0d078692 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py @@ -27,6 +27,8 @@ class StockLedgerEntry(Document): name will be changed using autoname options (in a scheduled job) """ self.name = frappe.generate_hash(txt="", length=10) + if self.meta.autoname == "hash": + self.to_rename = 0 def validate(self): self.flags.ignore_submit_comment = True 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 ad78fba347b..1c3e0bfc229 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,9 +5,11 @@ import json import frappe from frappe.core.page.permission_manager.permission_manager import reset +from frappe.custom.doctype.property_setter.property_setter import make_property_setter from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import add_days, today +from erpnext.accounts.doctype.gl_entry.gl_entry import rename_gle_sle_docs 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.landed_cost_voucher.test_landed_cost_voucher import ( @@ -529,3 +531,62 @@ def create_items(): make_item(d, properties=properties) return items + + +class TestDeferredNaming(FrappeTestCase): + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.gle_autoname = frappe.get_meta("GL Entry").autoname + cls.sle_autoname = frappe.get_meta("Stock Ledger Entry").autoname + + def setUp(self) -> None: + self.item = make_item().name + self.warehouse = "Stores - TCP1" + self.company = "_Test Company with perpetual inventory" + + def tearDown(self) -> None: + make_property_setter(doctype="GL Entry", for_doctype=True, + property="autoname", value=self.gle_autoname, property_type="Data", fieldname=None) + make_property_setter(doctype="Stock Ledger Entry", for_doctype=True, + property="autoname", value=self.sle_autoname, property_type="Data", fieldname=None) + + # since deferred naming autocommits, commit all changes to avoid flake + frappe.db.commit() # nosemgrep + + @staticmethod + def get_gle_sles(se): + filters = {"voucher_type": se.doctype, "voucher_no": se.name} + gle = set(frappe.get_list("GL Entry", filters, pluck="name")) + sle = set(frappe.get_list("Stock Ledger Entry", filters, pluck="name")) + return gle, sle + + def test_deferred_naming(self): + se = make_stock_entry(item_code=self.item, to_warehouse=self.warehouse, + qty=10, rate=100, company=self.company) + + gle, sle = self.get_gle_sles(se) + rename_gle_sle_docs() + renamed_gle, renamed_sle = self.get_gle_sles(se) + + self.assertFalse(gle & renamed_gle, msg="GLEs not renamed") + self.assertFalse(sle & renamed_sle, msg="SLEs not renamed") + se.cancel() + + def test_hash_naming(self): + # disable naming series + for doctype in ("GL Entry", "Stock Ledger Entry"): + make_property_setter(doctype=doctype, for_doctype=True, + property="autoname", value="hash", property_type="Data", fieldname=None) + + se = make_stock_entry(item_code=self.item, to_warehouse=self.warehouse, + qty=10, rate=100, company=self.company) + + gle, sle = self.get_gle_sles(se) + rename_gle_sle_docs() + renamed_gle, renamed_sle = self.get_gle_sles(se) + + self.assertEqual(gle, renamed_gle, msg="GLEs are renamed while using hash naming") + self.assertEqual(sle, renamed_sle, msg="SLEs are renamed while using hash naming") + se.cancel() diff --git a/erpnext/stock/doctype/warehouse/test_warehouse.py b/erpnext/stock/doctype/warehouse/test_warehouse.py index cdb771935b0..08d7c993521 100644 --- a/erpnext/stock/doctype/warehouse/test_warehouse.py +++ b/erpnext/stock/doctype/warehouse/test_warehouse.py @@ -4,12 +4,12 @@ import frappe from frappe.test_runner import make_test_records from frappe.tests.utils import FrappeTestCase -from frappe.utils import cint import erpnext -from erpnext.accounts.doctype.account.test_account import create_account, get_inventory_account +from erpnext.accounts.doctype.account.test_account import create_account from erpnext.stock.doctype.item.test_item import create_item from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry +from erpnext.stock.doctype.warehouse.warehouse import convert_to_group_or_ledger, get_children test_records = frappe.get_test_records('Warehouse') @@ -65,6 +65,33 @@ class TestWarehouse(FrappeTestCase): f"{item} linked to {item_default.default_warehouse} in {warehouse_ids}." ) + def test_group_non_group_conversion(self): + + warehouse = frappe.get_doc("Warehouse", create_warehouse("TestGroupConversion")) + + convert_to_group_or_ledger(warehouse.name) + warehouse.reload() + self.assertEqual(warehouse.is_group, 1) + + child = create_warehouse("GroupWHChild", {"parent_warehouse": warehouse.name}) + # chid exists + self.assertRaises(frappe.ValidationError, convert_to_group_or_ledger, warehouse.name) + frappe.delete_doc("Warehouse", child) + + convert_to_group_or_ledger(warehouse.name) + warehouse.reload() + self.assertEqual(warehouse.is_group, 0) + + make_stock_entry(item_code="_Test Item", target=warehouse.name, qty=1) + # SLE exists + self.assertRaises(frappe.ValidationError, convert_to_group_or_ledger, warehouse.name) + + def test_get_children(self): + company = "_Test Company" + + children = get_children("Warehouse", parent=company, company=company, is_root=True) + self.assertTrue(any(wh['value'] == "_Test Warehouse - _TC" for wh in children)) + def create_warehouse(warehouse_name, properties=None, company=None): if not company: diff --git a/erpnext/stock/doctype/warehouse/warehouse.py b/erpnext/stock/doctype/warehouse/warehouse.py index 9cfad86f142..4c7f41dcb5e 100644 --- a/erpnext/stock/doctype/warehouse/warehouse.py +++ b/erpnext/stock/doctype/warehouse/warehouse.py @@ -41,14 +41,11 @@ class Warehouse(NestedSet): def on_trash(self): # delete bin - bins = frappe.db.sql("select * from `tabBin` where warehouse = %s", - self.name, as_dict=1) + bins = frappe.get_all("Bin", fields="*", filters={"warehouse": self.name}) for d in bins: if d['actual_qty'] or d['reserved_qty'] or d['ordered_qty'] or \ d['indented_qty'] or d['projected_qty'] or d['planned_qty']: throw(_("Warehouse {0} can not be deleted as quantity exists for Item {1}").format(self.name, d['item_code'])) - else: - frappe.db.sql("delete from `tabBin` where name = %s", d['name']) if self.check_if_sle_exists(): throw(_("Warehouse can not be deleted as stock ledger entry exists for this warehouse.")) @@ -56,16 +53,15 @@ class Warehouse(NestedSet): if self.check_if_child_exists(): throw(_("Child warehouse exists for this warehouse. You can not delete this warehouse.")) + frappe.db.delete("Bin", filters={"warehouse": self.name}) self.update_nsm_model() self.unlink_from_items() def check_if_sle_exists(self): - return frappe.db.sql("""select name from `tabStock Ledger Entry` - where warehouse = %s limit 1""", self.name) + return frappe.db.exists("Stock Ledger Entry", {"warehouse": self.name}) def check_if_child_exists(self): - return frappe.db.sql("""select name from `tabWarehouse` - where parent_warehouse = %s limit 1""", self.name) + return frappe.db.exists("Warehouse", {"parent_warehouse": self.name}) def convert_to_group_or_ledger(self): if self.is_group: @@ -92,10 +88,7 @@ class Warehouse(NestedSet): return 1 def unlink_from_items(self): - frappe.db.sql(""" - update `tabItem Default` - set default_warehouse=NULL - where default_warehouse=%s""", self.name) + frappe.db.set_value("Item Default", {"default_warehouse": self.name}, "default_warehouse", None) @frappe.whitelist() def get_children(doctype, parent=None, company=None, is_root=False): @@ -164,15 +157,16 @@ def add_node(): frappe.get_doc(args).insert() @frappe.whitelist() -def convert_to_group_or_ledger(): - args = frappe.form_dict - return frappe.get_doc("Warehouse", args.docname).convert_to_group_or_ledger() +def convert_to_group_or_ledger(docname=None): + if not docname: + docname = frappe.form_dict.docname + return frappe.get_doc("Warehouse", docname).convert_to_group_or_ledger() def get_child_warehouses(warehouse): - lft, rgt = frappe.get_cached_value("Warehouse", warehouse, ["lft", "rgt"]) + from frappe.utils.nestedset import get_descendants_of - return frappe.db.sql_list("""select name from `tabWarehouse` - where lft >= %s and rgt <= %s""", (lft, rgt)) + children = get_descendants_of("Warehouse", warehouse, ignore_permissions=True, order_by="lft") + return children + [warehouse] # append self for backward compatibility def get_warehouses_based_on_account(account, company=None): warehouses = [] diff --git a/erpnext/templates/generators/item_group.html b/erpnext/templates/generators/item_group.html index 218481581c5..3926f77b1fa 100644 --- a/erpnext/templates/generators/item_group.html +++ b/erpnext/templates/generators/item_group.html @@ -52,24 +52,6 @@ - diff --git a/erpnext/templates/includes/macros.html b/erpnext/templates/includes/macros.html index 892d62513e2..fb4cecf8266 100644 --- a/erpnext/templates/includes/macros.html +++ b/erpnext/templates/includes/macros.html @@ -300,13 +300,13 @@ {% if values | len > 20 %} - + {% endif %} {% if values %}
{% for value in values %} -
+