Merge pull request #30040 from frappe/mergify/bp/develop/pr-30025
fix: Exchange rate not getting set in payment entry (backport #30025)
diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py
index 1b5f35e..2e7d306 100644
--- a/erpnext/buying/doctype/purchase_order/purchase_order.py
+++ b/erpnext/buying/doctype/purchase_order/purchase_order.py
@@ -316,6 +316,16 @@
'target_ref_field': 'stock_qty',
'source_field': 'stock_qty'
})
+ self.status_updater.append({
+ 'source_dt': 'Purchase Order Item',
+ 'target_dt': 'Packed Item',
+ 'target_field': 'ordered_qty',
+ 'target_parent_dt': 'Sales Order',
+ 'target_parent_field': '',
+ 'join_field': 'sales_order_packed_item',
+ 'target_ref_field': 'qty',
+ 'source_field': 'stock_qty'
+ })
def update_delivered_qty_in_sales_order(self):
"""Update delivered qty in Sales Order for drop ship"""
diff --git a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json
index 87cd575..a18c527 100644
--- a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json
+++ b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json
@@ -63,6 +63,7 @@
"material_request_item",
"sales_order",
"sales_order_item",
+ "sales_order_packed_item",
"supplier_quotation",
"supplier_quotation_item",
"col_break5",
@@ -837,21 +838,30 @@
"label": "Product Bundle",
"options": "Product Bundle",
"read_only": 1
+ },
+ {
+ "fieldname": "sales_order_packed_item",
+ "fieldtype": "Data",
+ "label": "Sales Order Packed Item",
+ "no_copy": 1,
+ "print_hide": 1
}
],
"idx": 1,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2021-08-30 20:06:26.712097",
+ "modified": "2022-02-02 13:10:18.398976",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Order Item",
+ "naming_rule": "Random",
"owner": "Administrator",
"permissions": [],
"quick_entry": 1,
"search_fields": "item_name",
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py
index c8e5edd..8972c32 100644
--- a/erpnext/controllers/stock_controller.py
+++ b/erpnext/controllers/stock_controller.py
@@ -507,13 +507,41 @@
"voucher_no": self.name,
"company": self.company
})
- if future_sle_exists(args):
+
+ if future_sle_exists(args) or repost_required_for_queue(self):
item_based_reposting = cint(frappe.db.get_single_value("Stock Reposting Settings", "item_based_reposting"))
if item_based_reposting:
create_item_wise_repost_entries(voucher_type=self.doctype, voucher_no=self.name)
else:
create_repost_item_valuation_entry(args)
+def repost_required_for_queue(doc: StockController) -> bool:
+ """check if stock document contains repeated item-warehouse with queue based valuation.
+
+ if queue exists for repeated items then SLEs need to reprocessed in background again.
+ """
+
+ consuming_sles = frappe.db.get_all("Stock Ledger Entry",
+ filters={
+ "voucher_type": doc.doctype,
+ "voucher_no": doc.name,
+ "actual_qty": ("<", 0),
+ "is_cancelled": 0
+ },
+ fields=["item_code", "warehouse", "stock_queue"]
+ )
+ item_warehouses = [(sle.item_code, sle.warehouse) for sle in consuming_sles]
+
+ unique_item_warehouses = set(item_warehouses)
+
+ if len(unique_item_warehouses) == len(item_warehouses):
+ return False
+
+ for sle in consuming_sles:
+ if sle.stock_queue != "[]": # using FIFO/LIFO valuation
+ return True
+ return False
+
@frappe.whitelist()
def make_quality_inspections(doctype, docname, items):
diff --git a/erpnext/hr/doctype/employee_advance/employee_advance.json b/erpnext/hr/doctype/employee_advance/employee_advance.json
index 0475453..b050183 100644
--- a/erpnext/hr/doctype/employee_advance/employee_advance.json
+++ b/erpnext/hr/doctype/employee_advance/employee_advance.json
@@ -2,7 +2,7 @@
"actions": [],
"allow_import": 1,
"autoname": "naming_series:",
- "creation": "2017-10-09 14:26:29.612365",
+ "creation": "2022-01-17 18:36:51.450395",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
@@ -121,7 +121,7 @@
"fieldtype": "Select",
"label": "Status",
"no_copy": 1,
- "options": "Draft\nPaid\nUnpaid\nClaimed\nCancelled",
+ "options": "Draft\nPaid\nUnpaid\nClaimed\nReturned\nPartly Claimed and Returned\nCancelled",
"read_only": 1
},
{
@@ -200,7 +200,7 @@
],
"is_submittable": 1,
"links": [],
- "modified": "2021-09-11 18:38:38.617478",
+ "modified": "2022-01-17 19:33:52.345823",
"modified_by": "Administrator",
"module": "HR",
"name": "Employee Advance",
@@ -237,5 +237,41 @@
"search_fields": "employee,employee_name",
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [
+ {
+ "color": "Red",
+ "custom": 1,
+ "title": "Draft"
+ },
+ {
+ "color": "Green",
+ "custom": 1,
+ "title": "Paid"
+ },
+ {
+ "color": "Orange",
+ "custom": 1,
+ "title": "Unpaid"
+ },
+ {
+ "color": "Blue",
+ "custom": 1,
+ "title": "Claimed"
+ },
+ {
+ "color": "Gray",
+ "title": "Returned"
+ },
+ {
+ "color": "Yellow",
+ "title": "Partly Claimed and Returned"
+ },
+ {
+ "color": "Red",
+ "custom": 1,
+ "title": "Cancelled"
+ }
+ ],
+ "title_field": "employee_name",
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/hr/doctype/employee_advance/employee_advance.py b/erpnext/hr/doctype/employee_advance/employee_advance.py
index 7aac2b6..79d389d 100644
--- a/erpnext/hr/doctype/employee_advance/employee_advance.py
+++ b/erpnext/hr/doctype/employee_advance/employee_advance.py
@@ -27,19 +27,33 @@
def on_cancel(self):
self.ignore_linked_doctypes = ('GL Entry')
+ self.set_status(update=True)
- def set_status(self):
+ def set_status(self, update=False):
+ precision = self.precision("paid_amount")
+ total_amount = flt(flt(self.claimed_amount) + flt(self.return_amount), precision)
+ status = None
+
if self.docstatus == 0:
- self.status = "Draft"
- if self.docstatus == 1:
- if self.claimed_amount and flt(self.claimed_amount) == flt(self.paid_amount):
- self.status = "Claimed"
- elif self.paid_amount and self.advance_amount == flt(self.paid_amount):
- self.status = "Paid"
+ status = "Draft"
+ elif self.docstatus == 1:
+ if flt(self.claimed_amount) > 0 and flt(self.claimed_amount, precision) == flt(self.paid_amount, precision):
+ status = "Claimed"
+ elif flt(self.return_amount) > 0 and flt(self.return_amount, precision) == flt(self.paid_amount, precision):
+ status = "Returned"
+ elif flt(self.claimed_amount) > 0 and (flt(self.return_amount) > 0) and total_amount == flt(self.paid_amount, precision):
+ status = "Partly Claimed and Returned"
+ elif flt(self.paid_amount) > 0 and flt(self.advance_amount, precision) == flt(self.paid_amount, precision):
+ status = "Paid"
else:
- self.status = "Unpaid"
+ status = "Unpaid"
elif self.docstatus == 2:
- self.status = "Cancelled"
+ status = "Cancelled"
+
+ if update:
+ self.db_set("status", status)
+ else:
+ self.status = status
def set_total_advance_paid(self):
gle = frappe.qb.DocType("GL Entry")
@@ -85,9 +99,7 @@
self.db_set("paid_amount", paid_amount)
self.db_set("return_amount", return_amount)
- self.set_status()
- frappe.db.set_value("Employee Advance", self.name , "status", self.status)
-
+ self.set_status(update=True)
def update_claimed_amount(self):
claimed_amount = frappe.db.sql("""
@@ -103,8 +115,8 @@
frappe.db.set_value("Employee Advance", self.name, "claimed_amount", flt(claimed_amount))
self.reload()
- self.set_status()
- frappe.db.set_value("Employee Advance", self.name, "status", self.status)
+ self.set_status(update=True)
+
@frappe.whitelist()
def get_pending_amount(employee, posting_date):
@@ -222,7 +234,8 @@
'reference_name': employee_advance_name,
'party_type': 'Employee',
'party': employee,
- 'is_advance': 'Yes'
+ 'is_advance': 'Yes',
+ 'cost_center': erpnext.get_default_cost_center(company)
})
bank_amount = flt(return_amount) if bank_cash_account.account_currency==currency \
@@ -233,7 +246,8 @@
"debit_in_account_currency": bank_amount,
"account_currency": bank_cash_account.account_currency,
"account_type": bank_cash_account.account_type,
- "exchange_rate": flt(exchange_rate) if bank_cash_account.account_currency == currency else 1
+ "exchange_rate": flt(exchange_rate) if bank_cash_account.account_currency == currency else 1,
+ "cost_center": erpnext.get_default_cost_center(company)
})
return je.as_dict()
diff --git a/erpnext/hr/doctype/employee_advance/test_employee_advance.py b/erpnext/hr/doctype/employee_advance/test_employee_advance.py
index 5f2e720..e3c1487 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
@@ -12,12 +12,21 @@
EmployeeAdvanceOverPayment,
create_return_through_additional_salary,
make_bank_entry,
+ make_return_entry,
+)
+from erpnext.hr.doctype.expense_claim.expense_claim import get_advances
+from erpnext.hr.doctype.expense_claim.test_expense_claim import (
+ get_payable_account,
+ make_expense_claim,
)
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
class TestEmployeeAdvance(unittest.TestCase):
+ def setUp(self):
+ frappe.db.delete("Employee Advance")
+
def test_paid_amount_and_status(self):
employee_name = make_employee("_T@employe.advance")
advance = make_employee_advance(employee_name)
@@ -52,9 +61,102 @@
self.assertEqual(advance.paid_amount, 0)
self.assertEqual(advance.status, "Unpaid")
+ advance.cancel()
+ advance.reload()
+ self.assertEqual(advance.status, "Cancelled")
+
+ def test_claimed_status(self):
+ # CLAIMED Status check, full amount claimed
+ 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 = get_advances_for_claim(claim, advance.name)
+ claim.save()
+ claim.submit()
+
+ advance.reload()
+ self.assertEqual(advance.claimed_amount, 1000)
+ self.assertEqual(advance.status, "Claimed")
+
+ # advance should not be shown in claims
+ advances = get_advances(claim.employee)
+ advances = [entry.name for entry in advances]
+ self.assertTrue(advance.name not in advances)
+
+ # cancel claim; status should be Paid
+ claim.cancel()
+ advance.reload()
+ self.assertEqual(advance.claimed_amount, 0)
+ self.assertEqual(advance.status, "Paid")
+
+ def test_partly_claimed_and_returned_status(self):
+ 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()
+
+ # PARTLY CLAIMED AND RETURNED status check
+ # 500 Claimed, 500 Returned
+ claim = make_expense_claim(payable_account, 500, 500, "_Test Company", "Travel Expenses - _TC", do_not_submit=True)
+
+ advance = make_employee_advance(claim.employee)
+ pe = make_payment_entry(advance)
+ pe.submit()
+
+ claim = get_advances_for_claim(claim, advance.name, amount=500)
+ claim.save()
+ claim.submit()
+
+ advance.reload()
+ self.assertEqual(advance.claimed_amount, 500)
+ self.assertEqual(advance.status, "Paid")
+
+ entry = make_return_entry(
+ employee=advance.employee,
+ company=advance.company,
+ employee_advance_name=advance.name,
+ return_amount=flt(advance.paid_amount - advance.claimed_amount),
+ advance_account=advance.advance_account,
+ mode_of_payment=advance.mode_of_payment,
+ currency=advance.currency,
+ exchange_rate=advance.exchange_rate
+ )
+
+ entry = frappe.get_doc(entry)
+ entry.insert()
+ entry.submit()
+
+ advance.reload()
+ self.assertEqual(advance.return_amount, 500)
+ self.assertEqual(advance.status, "Partly Claimed and Returned")
+
+ # advance should not be shown in claims
+ advances = get_advances(claim.employee)
+ advances = [entry.name for entry in advances]
+ self.assertTrue(advance.name not in advances)
+
+ # Cancel return entry; status should change to PAID
+ entry.cancel()
+ advance.reload()
+ self.assertEqual(advance.return_amount, 0)
+ self.assertEqual(advance.status, "Paid")
+
+ # advance should be shown in claims
+ advances = get_advances(claim.employee)
+ advances = [entry.name for entry in advances]
+ self.assertTrue(advance.name in advances)
+
def test_repay_unclaimed_amount_from_salary(self):
employee_name = make_employee("_T@employe.advance")
advance = make_employee_advance(employee_name, {"repay_unclaimed_amount_from_salary": 1})
+ pe = make_payment_entry(advance)
+ pe.submit()
args = {"type": "Deduction"}
create_salary_component("Advance Salary - Deduction", **args)
@@ -82,11 +184,13 @@
advance.reload()
self.assertEqual(advance.return_amount, 1000)
+ self.assertEqual(advance.status, "Returned")
# update advance return amount on additional salary cancellation
additional_salary.cancel()
advance.reload()
self.assertEqual(advance.return_amount, 700)
+ self.assertEqual(advance.status, "Paid")
def tearDown(self):
frappe.db.rollback()
@@ -118,3 +222,24 @@
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.js b/erpnext/hr/doctype/expense_claim/expense_claim.js
index 0479457..af80b63 100644
--- a/erpnext/hr/doctype/expense_claim/expense_claim.js
+++ b/erpnext/hr/doctype/expense_claim/expense_claim.js
@@ -171,7 +171,7 @@
['docstatus', '=', 1],
['employee', '=', frm.doc.employee],
['paid_amount', '>', 0],
- ['status', '!=', 'Claimed']
+ ['status', 'not in', ['Claimed', 'Returned', 'Partly Claimed and Returned']]
]
};
});
diff --git a/erpnext/hr/doctype/expense_claim/expense_claim.py b/erpnext/hr/doctype/expense_claim/expense_claim.py
index 7e3898b..fe04efb 100644
--- a/erpnext/hr/doctype/expense_claim/expense_claim.py
+++ b/erpnext/hr/doctype/expense_claim/expense_claim.py
@@ -23,10 +23,10 @@
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()
@@ -341,18 +341,27 @@
@frappe.whitelist()
def get_advances(employee, advance_id=None):
- if not advance_id:
- condition = 'docstatus=1 and employee={0} and paid_amount > 0 and paid_amount > claimed_amount + return_amount'.format(frappe.db.escape(employee))
- else:
- condition = 'name={0}'.format(frappe.db.escape(advance_id))
+ advance = frappe.qb.DocType("Employee Advance")
- return frappe.db.sql("""
- select
- name, posting_date, paid_amount, claimed_amount, advance_account
- from
- `tabEmployee Advance`
- where {0}
- """.format(condition), as_dict=1)
+ query = (
+ frappe.qb.from_(advance)
+ .select(
+ advance.name, advance.posting_date, advance.paid_amount,
+ advance.claimed_amount, advance.advance_account
+ )
+ )
+
+ if not advance_id:
+ query = query.where(
+ (advance.docstatus == 1)
+ & (advance.employee == employee)
+ & (advance.paid_amount > 0)
+ & (advance.status.notin(["Claimed", "Returned", "Partly Claimed and Returned"]))
+ )
+ else:
+ query = query.where(advance.name == advance_id)
+
+ return query.run(as_dict=True)
@frappe.whitelist()
diff --git a/erpnext/manufacturing/doctype/operation/operation_dashboard.py b/erpnext/manufacturing/doctype/operation/operation_dashboard.py
index 4fbcf49..9f7efa2 100644
--- a/erpnext/manufacturing/doctype/operation/operation_dashboard.py
+++ b/erpnext/manufacturing/doctype/operation/operation_dashboard.py
@@ -7,7 +7,7 @@
'transactions': [
{
'label': _('Manufacture'),
- 'items': ['BOM', 'Work Order', 'Job Card', 'Timesheet']
+ 'items': ['BOM', 'Work Order', 'Job Card']
}
]
}
diff --git a/erpnext/manufacturing/doctype/routing/routing.js b/erpnext/manufacturing/doctype/routing/routing.js
index 33a313e..b480c70 100644
--- a/erpnext/manufacturing/doctype/routing/routing.js
+++ b/erpnext/manufacturing/doctype/routing/routing.js
@@ -17,7 +17,7 @@
},
calculate_operating_cost: function(frm, child) {
- const operating_cost = flt(flt(child.hour_rate) * flt(child.time_in_mins) / 60, 2);
+ const operating_cost = flt(flt(child.hour_rate) * flt(child.time_in_mins) / 60, precision("operating_cost", child));
frappe.model.set_value(child.doctype, child.name, "operating_cost", operating_cost);
}
});
diff --git a/erpnext/manufacturing/doctype/routing/routing.py b/erpnext/manufacturing/doctype/routing/routing.py
index 1c76634..b207906 100644
--- a/erpnext/manufacturing/doctype/routing/routing.py
+++ b/erpnext/manufacturing/doctype/routing/routing.py
@@ -20,7 +20,8 @@
for operation in self.operations:
if not operation.hour_rate:
operation.hour_rate = frappe.db.get_value("Workstation", operation.workstation, 'hour_rate')
- operation.operating_cost = flt(flt(operation.hour_rate) * flt(operation.time_in_mins) / 60, 2)
+ operation.operating_cost = flt(flt(operation.hour_rate) * flt(operation.time_in_mins) / 60,
+ operation.precision("operating_cost"))
def set_routing_id(self):
sequence_id = 0
diff --git a/erpnext/manufacturing/doctype/workstation/workstation_dashboard.py b/erpnext/manufacturing/doctype/workstation/workstation_dashboard.py
index bc481ca..1fa1494 100644
--- a/erpnext/manufacturing/doctype/workstation/workstation_dashboard.py
+++ b/erpnext/manufacturing/doctype/workstation/workstation_dashboard.py
@@ -11,9 +11,9 @@
},
{
'label': _('Transaction'),
- 'items': ['Work Order', 'Job Card', 'Timesheet']
+ 'items': ['Work Order', 'Job Card',]
}
],
'disable_create_buttons': ['BOM', 'Routing', 'Operation',
- 'Work Order', 'Job Card', 'Timesheet']
+ 'Work Order', 'Job Card',]
}
diff --git a/erpnext/patches.txt b/erpnext/patches.txt
index 12af8f8..13f0e7b 100644
--- a/erpnext/patches.txt
+++ b/erpnext/patches.txt
@@ -356,4 +356,5 @@
erpnext.patches.v13_0.set_work_order_qty_in_so_from_mr
erpnext.patches.v13_0.update_accounts_in_loan_docs
erpnext.patches.v14_0.update_batch_valuation_flag
-erpnext.patches.v14_0.delete_non_profit_doctypes
\ No newline at end of file
+erpnext.patches.v14_0.delete_non_profit_doctypes
+erpnext.patches.v14_0.update_employee_advance_status
\ No newline at end of file
diff --git a/erpnext/patches/v14_0/update_employee_advance_status.py b/erpnext/patches/v14_0/update_employee_advance_status.py
new file mode 100644
index 0000000..a20e35a
--- /dev/null
+++ b/erpnext/patches/v14_0/update_employee_advance_status.py
@@ -0,0 +1,26 @@
+import frappe
+
+
+def execute():
+ frappe.reload_doc('hr', 'doctype', 'employee_advance')
+
+ advance = frappe.qb.DocType('Employee Advance')
+ (frappe.qb
+ .update(advance)
+ .set(advance.status, 'Returned')
+ .where(
+ (advance.docstatus == 1)
+ & ((advance.return_amount) & (advance.paid_amount == advance.return_amount))
+ & (advance.status == 'Paid')
+ )
+ ).run()
+
+ (frappe.qb
+ .update(advance)
+ .set(advance.status, 'Partly Claimed and Returned')
+ .where(
+ (advance.docstatus == 1)
+ & ((advance.claimed_amount & advance.return_amount) & (advance.paid_amount == (advance.return_amount + advance.claimed_amount)))
+ & (advance.status == 'Paid')
+ )
+ ).run()
\ No newline at end of file
diff --git a/erpnext/payroll/doctype/additional_salary/additional_salary.py b/erpnext/payroll/doctype/additional_salary/additional_salary.py
index bf8bd05..d618568 100644
--- a/erpnext/payroll/doctype/additional_salary/additional_salary.py
+++ b/erpnext/payroll/doctype/additional_salary/additional_salary.py
@@ -105,6 +105,8 @@
return_amount += self.amount
frappe.db.set_value("Employee Advance", self.ref_docname, "return_amount", return_amount)
+ advance = frappe.get_doc("Employee Advance", self.ref_docname)
+ advance.set_status(update=True)
def update_employee_referral(self, cancel=False):
if self.ref_doctype == "Employee Referral":
diff --git a/erpnext/projects/doctype/timesheet/timesheet.js b/erpnext/projects/doctype/timesheet/timesheet.js
index f615f05..453d46c 100644
--- a/erpnext/projects/doctype/timesheet/timesheet.js
+++ b/erpnext/projects/doctype/timesheet/timesheet.js
@@ -116,7 +116,7 @@
currency: function(frm) {
let base_currency = frappe.defaults.get_global_default('currency');
- if (base_currency != frm.doc.currency) {
+ if (frm.doc.currency && (base_currency != frm.doc.currency)) {
frappe.call({
method: "erpnext.setup.utils.get_exchange_rate",
args: {
diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js
index eb98e6c..f80eaf2 100644
--- a/erpnext/selling/doctype/sales_order/sales_order.js
+++ b/erpnext/selling/doctype/sales_order/sales_order.js
@@ -562,6 +562,7 @@
var me = this;
var dialog = new frappe.ui.Dialog({
title: __("Select Items"),
+ size: "large",
fields: [
{
"fieldtype": "Check",
@@ -663,7 +664,8 @@
} else {
let po_items = [];
me.frm.doc.items.forEach(d => {
- let pending_qty = (flt(d.stock_qty) - flt(d.ordered_qty)) / flt(d.conversion_factor);
+ let ordered_qty = me.get_ordered_qty(d, me.frm.doc);
+ let pending_qty = (flt(d.stock_qty) - ordered_qty) / flt(d.conversion_factor);
if (pending_qty > 0) {
po_items.push({
"doctype": "Sales Order Item",
@@ -689,6 +691,24 @@
dialog.show();
}
+ get_ordered_qty(item, so) {
+ let ordered_qty = item.ordered_qty;
+ if (so.packed_items) {
+ // calculate ordered qty based on packed items in case of product bundle
+ let packed_items = so.packed_items.filter(
+ (pi) => pi.parent_detail_docname == item.name
+ );
+ if (packed_items) {
+ ordered_qty = packed_items.reduce(
+ (sum, pi) => sum + flt(pi.ordered_qty),
+ 0
+ );
+ ordered_qty = ordered_qty / packed_items.length;
+ }
+ }
+ return ordered_qty;
+ }
+
hold_sales_order(){
var me = this;
var d = new frappe.ui.Dialog({
diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py
index 0f5b1e3..abbb3c9 100755
--- a/erpnext/selling/doctype/sales_order/sales_order.py
+++ b/erpnext/selling/doctype/sales_order/sales_order.py
@@ -877,6 +877,9 @@
target.stock_qty = (flt(source.stock_qty) - flt(source.ordered_qty))
target.project = source_parent.project
+ def update_item_for_packed_item(source, target, source_parent):
+ target.qty = flt(source.qty) - flt(source.ordered_qty)
+
# po = frappe.get_list("Purchase Order", filters={"sales_order":source_name, "supplier":supplier, "docstatus": ("<", "2")})
doc = get_mapped_doc("Sales Order", source_name, {
"Sales Order": {
@@ -920,6 +923,7 @@
"Packed Item": {
"doctype": "Purchase Order Item",
"field_map": [
+ ["name", "sales_order_packed_item"],
["parent", "sales_order"],
["uom", "uom"],
["conversion_factor", "conversion_factor"],
@@ -934,6 +938,7 @@
"supplier",
"pricing_rules"
],
+ "postprocess": update_item_for_packed_item,
"condition": lambda doc: doc.parent_item in items_to_map
}
}, target_doc, set_missing_values)
diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py
index f5a34c0..b528479 100644
--- a/erpnext/selling/doctype/sales_order/test_sales_order.py
+++ b/erpnext/selling/doctype/sales_order/test_sales_order.py
@@ -959,6 +959,42 @@
self.assertEqual(purchase_order.items[0].item_code, "_Test Bundle Item 1")
self.assertEqual(purchase_order.items[1].item_code, "_Test Bundle Item 2")
+ def test_purchase_order_updates_packed_item_ordered_qty(self):
+ """
+ Tests if the packed item's `ordered_qty` is updated with the quantity of the Purchase Order
+ """
+ from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order
+
+ product_bundle = make_item("_Test Product Bundle", {"is_stock_item": 0})
+ make_item("_Test Bundle Item 1", {"is_stock_item": 1})
+ make_item("_Test Bundle Item 2", {"is_stock_item": 1})
+
+ make_product_bundle("_Test Product Bundle",
+ ["_Test Bundle Item 1", "_Test Bundle Item 2"])
+
+ so_items = [
+ {
+ "item_code": product_bundle.item_code,
+ "warehouse": "",
+ "qty": 2,
+ "rate": 400,
+ "delivered_by_supplier": 1,
+ "supplier": '_Test Supplier'
+ }
+ ]
+
+ so = make_sales_order(item_list=so_items)
+
+ purchase_order = make_purchase_order(so.name, selected_items=so_items)
+ purchase_order.supplier = "_Test Supplier"
+ purchase_order.set_warehouse = "_Test Warehouse - _TC"
+ purchase_order.save()
+ purchase_order.submit()
+
+ so.reload()
+ self.assertEqual(so.packed_items[0].ordered_qty, 2)
+ self.assertEqual(so.packed_items[1].ordered_qty, 2)
+
def test_reserved_qty_for_closing_so(self):
bin = frappe.get_all("Bin", filters={"item_code": "_Test Item", "warehouse": "_Test Warehouse - _TC"},
fields=["reserved_qty"])
diff --git a/erpnext/setup/doctype/company/company.py b/erpnext/setup/doctype/company/company.py
index 95b1e8b..36ad8fe 100644
--- a/erpnext/setup/doctype/company/company.py
+++ b/erpnext/setup/doctype/company/company.py
@@ -3,7 +3,6 @@
import json
-import os
import frappe
import frappe.defaults
@@ -422,14 +421,14 @@
return " - ".join(parts)
def install_country_fixtures(company, country):
- path = frappe.get_app_path('erpnext', 'regional', frappe.scrub(country))
- if os.path.exists(path.encode("utf-8")):
- try:
- module_name = "erpnext.regional.{0}.setup.setup".format(frappe.scrub(country))
- frappe.get_attr(module_name)(company, False)
- except Exception as e:
- frappe.log_error()
- frappe.throw(_("Failed to setup defaults for country {0}. Please contact support.").format(frappe.bold(country)))
+ try:
+ module_name = f"erpnext.regional.{frappe.scrub(country)}.setup.setup"
+ frappe.get_attr(module_name)(company, False)
+ except ImportError:
+ pass
+ except Exception:
+ frappe.log_error()
+ frappe.throw(_("Failed to setup defaults for country {0}. Please contact support.").format(frappe.bold(country)))
def update_company_current_month_sales(company):
diff --git a/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py b/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py
index dbaefc1..6dc4fee 100644
--- a/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py
+++ b/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py
@@ -11,6 +11,7 @@
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
from erpnext.accounts.utils import update_gl_entries_after
from erpnext.assets.doctype.asset.test_asset import create_asset_category, create_fixed_asset_item
+from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import (
get_gl_entries,
make_purchase_receipt,
@@ -177,6 +178,53 @@
self.assertEqual(serial_no.purchase_rate - serial_no_rate, 5.0)
self.assertEqual(serial_no.warehouse, "Stores - TCP1")
+ def test_serialized_lcv_delivered(self):
+ """In some cases you'd want to deliver before you can know all the
+ landed costs, this should be allowed for serial nos too.
+
+ Case:
+ - receipt a serial no @ X rate
+ - delivery the serial no @ X rate
+ - add LCV to receipt X + Y
+ - LCV should be successful
+ - delivery should reflect X+Y valuation.
+ """
+ serial_no = "LCV_TEST_SR_NO"
+ item_code = "_Test Serialized Item"
+ warehouse = "Stores - TCP1"
+
+ pr = make_purchase_receipt(company="_Test Company with perpetual inventory",
+ warehouse=warehouse, qty=1, rate=200,
+ item_code=item_code, serial_no=serial_no)
+
+ serial_no_rate = frappe.db.get_value("Serial No", serial_no, "purchase_rate")
+
+ # deliver it before creating LCV
+ dn = create_delivery_note(item_code=item_code,
+ company='_Test Company with perpetual inventory', warehouse='Stores - TCP1',
+ serial_no=serial_no, qty=1, rate=500,
+ cost_center = 'Main - TCP1', expense_account = "Cost of Goods Sold - TCP1")
+
+ charges = 10
+ create_landed_cost_voucher("Purchase Receipt", pr.name, pr.company, charges=charges)
+
+ new_purchase_rate = serial_no_rate + charges
+
+ serial_no = frappe.db.get_value("Serial No", serial_no,
+ ["warehouse", "purchase_rate"], as_dict=1)
+
+ self.assertEqual(serial_no.purchase_rate, new_purchase_rate)
+
+ stock_value_difference = frappe.db.get_value("Stock Ledger Entry",
+ filters={
+ "voucher_no": dn.name,
+ "voucher_type": dn.doctype,
+ "is_cancelled": 0 # LCV cancels with same name.
+ },
+ fieldname="stock_value_difference")
+
+ # reposting should update the purchase rate in future delivery
+ self.assertEqual(stock_value_difference, -new_purchase_rate)
def test_landed_cost_voucher_for_odd_numbers (self):
pr = make_purchase_receipt(company="_Test Company with perpetual inventory", warehouse = "Stores - TCP1", supplier_warehouse = "Work in Progress - TCP1", do_not_save=True)
diff --git a/erpnext/stock/doctype/packed_item/packed_item.json b/erpnext/stock/doctype/packed_item/packed_item.json
index d2d4789..d6e2e9c 100644
--- a/erpnext/stock/doctype/packed_item/packed_item.json
+++ b/erpnext/stock/doctype/packed_item/packed_item.json
@@ -26,6 +26,7 @@
"section_break_13",
"actual_qty",
"projected_qty",
+ "ordered_qty",
"column_break_16",
"incoming_rate",
"page_break",
@@ -224,13 +225,21 @@
"label": "Rate",
"print_hide": 1,
"read_only": 1
+ },
+ {
+ "allow_on_submit": 1,
+ "fieldname": "ordered_qty",
+ "fieldtype": "Float",
+ "label": "Ordered Qty",
+ "no_copy": 1,
+ "read_only": 1
}
],
"idx": 1,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2022-01-28 16:03:30.780111",
+ "modified": "2022-02-22 12:57:45.325488",
"modified_by": "Administrator",
"module": "Stock",
"name": "Packed Item",
diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
index a24acb1..fa28f22 100644
--- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
+++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
@@ -161,6 +161,15 @@
qty=abs(existing_bin_qty)
)
+ existing_bin_qty, existing_bin_stock_value = frappe.db.get_value(
+ "Bin",
+ {
+ "item_code": "_Test Item",
+ "warehouse": "_Test Warehouse - _TC"
+ },
+ ["actual_qty", "stock_value"]
+ )
+
pr = make_purchase_receipt()
stock_value_difference = frappe.db.get_value(
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 01d25b2..684a8d4 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
@@ -389,10 +389,13 @@
)
- def assertSLEs(self, doc, expected_sles):
+ def assertSLEs(self, doc, expected_sles, sle_filters=None):
""" Compare sorted SLEs, useful for vouchers that create multiple SLEs for same line"""
- sles = frappe.get_all("Stock Ledger Entry", fields=["*"],
- filters={"voucher_no": doc.name, "voucher_type": doc.doctype, "is_cancelled":0},
+
+ filters = {"voucher_no": doc.name, "voucher_type": doc.doctype, "is_cancelled": 0}
+ if sle_filters:
+ filters.update(sle_filters)
+ sles = frappe.get_all("Stock Ledger Entry", fields=["*"], filters=filters,
order_by="timestamp(posting_date, posting_time), creation")
for exp_sle, act_sle in zip(expected_sles, sles):
@@ -665,6 +668,78 @@
{"actual_qty": -10, "stock_value_difference": -10*40, "stock_queue": []},
]))
+ def test_fifo_dependent_consumption(self):
+ item = make_item("_TestFifoTransferRates")
+ source = "_Test Warehouse - _TC"
+ target = "Stores - _TC"
+
+ rates = [10 * i for i in range(1, 20)]
+
+ receipt = make_stock_entry(item_code=item.name, target=source, qty=10, do_not_save=True, rate=10)
+ for rate in rates[1:]:
+ row = frappe.copy_doc(receipt.items[0], ignore_no_copy=False)
+ row.basic_rate = rate
+ receipt.append("items", row)
+
+ receipt.save()
+ receipt.submit()
+
+ expected_queues = []
+ for idx, rate in enumerate(rates, start=1):
+ expected_queues.append(
+ {"stock_queue": [[10, 10 * i] for i in range(1, idx + 1)]}
+ )
+ self.assertSLEs(receipt, expected_queues)
+
+ transfer = make_stock_entry(item_code=item.name, source=source, target=target, qty=10, do_not_save=True, rate=10)
+ for rate in rates[1:]:
+ row = frappe.copy_doc(transfer.items[0], ignore_no_copy=False)
+ transfer.append("items", row)
+
+ transfer.save()
+ transfer.submit()
+
+ # same exact queue should be transferred
+ self.assertSLEs(transfer, expected_queues, sle_filters={"warehouse": target})
+
+ def test_fifo_multi_item_repack_consumption(self):
+ rm = make_item("_TestFifoRepackRM")
+ packed = make_item("_TestFifoRepackFinished")
+ warehouse = "_Test Warehouse - _TC"
+
+ rates = [10 * i for i in range(1, 5)]
+
+ receipt = make_stock_entry(item_code=rm.name, target=warehouse, qty=10, do_not_save=True, rate=10)
+ for rate in rates[1:]:
+ row = frappe.copy_doc(receipt.items[0], ignore_no_copy=False)
+ row.basic_rate = rate
+ receipt.append("items", row)
+
+ receipt.save()
+ receipt.submit()
+
+ repack = make_stock_entry(item_code=rm.name, source=warehouse, qty=10,
+ do_not_save=True, rate=10, purpose="Repack")
+ for rate in rates[1:]:
+ row = frappe.copy_doc(repack.items[0], ignore_no_copy=False)
+ repack.append("items", row)
+
+ repack.append("items", {
+ "item_code": packed.name,
+ "t_warehouse": warehouse,
+ "qty": 1,
+ "transfer_qty": 1,
+ })
+
+ repack.save()
+ repack.submit()
+
+ # same exact queue should be transferred
+ self.assertSLEs(repack, [
+ {"incoming_rate": sum(rates) * 10}
+ ], sle_filters={"item_code": packed.name})
+
+
def create_repack_entry(**args):
args = frappe._dict(args)
repack = frappe.new_doc("Stock Entry")
diff --git a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py
index 7826d34..1ba2482 100644
--- a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py
+++ b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py
@@ -21,6 +21,7 @@
"stock_value",
"stock_value_difference",
"valuation_rate",
+ "voucher_detail_no",
)
@@ -66,7 +67,9 @@
balance_qty += sle.actual_qty
balance_stock_value += sle.stock_value_difference
if sle.voucher_type == "Stock Reconciliation" and not sle.batch_no:
- balance_qty = sle.qty_after_transaction
+ balance_qty = frappe.db.get_value("Stock Reconciliation Item", sle.voucher_detail_no, "qty")
+ if balance_qty is None:
+ balance_qty = sle.qty_after_transaction
sle.fifo_queue_qty = fifo_qty
sle.fifo_stock_value = fifo_value
diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py
index 1b90086..6975552 100644
--- a/erpnext/stock/stock_ledger.py
+++ b/erpnext/stock/stock_ledger.py
@@ -28,6 +28,16 @@
def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_voucher=False):
+ """ Create SL entries from SL entry dicts
+
+ args:
+ - allow_negative_stock: disable negative stock valiations if true
+ - via_landed_cost_voucher: landed cost voucher cancels and reposts
+ entries of purchase document. This flag is used to identify if
+ cancellation and repost is happening via landed cost voucher, in
+ such cases certain validations need to be ignored (like negative
+ stock)
+ """
from erpnext.controllers.stock_controller import future_sle_exists
if sl_entries:
cancel = sl_entries[0].get("is_cancelled")
@@ -39,7 +49,7 @@
future_sle_exists(args, sl_entries)
for sle in sl_entries:
- if sle.serial_no:
+ if sle.serial_no and not via_landed_cost_voucher:
validate_serial_no(sle)
if cancel: