Merge pull request #30968 from nextchamp-saqib/validate-on-hold-invs
fix(accounts): minor fixes & validations
diff --git a/.eslintrc b/.eslintrc
index 46fb354..276d6ff 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -5,7 +5,7 @@
"es6": true
},
"parserOptions": {
- "ecmaVersion": 9,
+ "ecmaVersion": 11,
"sourceType": "module"
},
"extends": "eslint:recommended",
diff --git a/.github/stale.yml b/.github/stale.yml
index 1c2dcf3..fbf6447 100644
--- a/.github/stale.yml
+++ b/.github/stale.yml
@@ -25,7 +25,7 @@
ready. Thank you for contributing.
issues:
- daysUntilStale: 60
+ daysUntilStale: 90
daysUntilClose: 7
exemptLabels:
- valid
diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
index 8617686..b8154dd 100644
--- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
+++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
@@ -2648,6 +2648,7 @@
# reset
einvoice_settings = frappe.get_doc("E Invoice Settings")
einvoice_settings.enable = 0
+ einvoice_settings.save()
frappe.flags.country = country
def test_einvoice_json(self):
diff --git a/erpnext/accounts/doctype/tax_rule/tax_rule.py b/erpnext/accounts/doctype/tax_rule/tax_rule.py
index 5bfca96..4d20129 100644
--- a/erpnext/accounts/doctype/tax_rule/tax_rule.py
+++ b/erpnext/accounts/doctype/tax_rule/tax_rule.py
@@ -163,17 +163,15 @@
def get_tax_template(posting_date, args):
"""Get matching tax rule"""
args = frappe._dict(args)
- from_date = to_date = posting_date
- if not posting_date:
- from_date = "1900-01-01"
- to_date = "4000-01-01"
+ conditions = []
- conditions = [
- """(from_date is null or from_date <= '{0}')
- and (to_date is null or to_date >= '{1}')""".format(
- from_date, to_date
+ if posting_date:
+ conditions.append(
+ f"""(from_date is null or from_date <= '{posting_date}')
+ and (to_date is null or to_date >= '{posting_date}')"""
)
- ]
+ else:
+ conditions.append("(from_date is null) and (to_date is null)")
conditions.append(
"ifnull(tax_category, '') = {0}".format(frappe.db.escape(cstr(args.get("tax_category"))))
diff --git a/erpnext/accounts/report/pos_register/pos_register.py b/erpnext/accounts/report/pos_register/pos_register.py
index 1bda0d8..9c0aba3 100644
--- a/erpnext/accounts/report/pos_register/pos_register.py
+++ b/erpnext/accounts/report/pos_register/pos_register.py
@@ -62,7 +62,7 @@
"""
SELECT
p.posting_date, p.name as pos_invoice, p.pos_profile,
- p.owner, p.base_grand_total as grand_total, p.base_paid_amount as paid_amount,
+ p.owner, p.base_grand_total as grand_total, p.base_paid_amount - p.change_amount as paid_amount,
p.customer, p.is_return {select_mop_field}
FROM
`tabPOS Invoice` p {from_sales_invoice_payment}
diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py
index 5860c4c..9189f18 100644
--- a/erpnext/buying/doctype/purchase_order/purchase_order.py
+++ b/erpnext/buying/doctype/purchase_order/purchase_order.py
@@ -637,6 +637,8 @@
}
}
stock_entry.add_to_stock_entry_detail(items_dict)
+
+ stock_entry.set_missing_values()
return stock_entry.as_dict()
else:
frappe.throw(_("No Items selected for transfer"))
@@ -724,7 +726,7 @@
add_items_in_ste(ste_doc, value, value.qty, po_details)
ste_doc.set_stock_entry_type()
- ste_doc.calculate_rate_and_amount()
+ ste_doc.set_missing_values()
return ste_doc
diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py
index 78645e0..46013bb 100644
--- a/erpnext/controllers/accounts_controller.py
+++ b/erpnext/controllers/accounts_controller.py
@@ -2451,11 +2451,21 @@
parent_doctype, parent_doctype_name, child_doctype, child_docname, item_row
)
- def validate_quantity(child_item, d):
- if parent_doctype == "Sales Order" and flt(d.get("qty")) < flt(child_item.delivered_qty):
+ def validate_quantity(child_item, new_data):
+ if not flt(new_data.get("qty")):
+ frappe.throw(
+ _("Row # {0}: Quantity for Item {1} cannot be zero").format(
+ new_data.get("idx"), frappe.bold(new_data.get("item_code"))
+ ),
+ title=_("Invalid Qty"),
+ )
+
+ if parent_doctype == "Sales Order" and flt(new_data.get("qty")) < flt(child_item.delivered_qty):
frappe.throw(_("Cannot set quantity less than delivered quantity"))
- if parent_doctype == "Purchase Order" and flt(d.get("qty")) < flt(child_item.received_qty):
+ if parent_doctype == "Purchase Order" and flt(new_data.get("qty")) < flt(
+ child_item.received_qty
+ ):
frappe.throw(_("Cannot set quantity less than received quantity"))
data = json.loads(trans_items)
diff --git a/erpnext/crm/doctype/opportunity/opportunity.py b/erpnext/crm/doctype/opportunity/opportunity.py
index 19b4d68..b590177 100644
--- a/erpnext/crm/doctype/opportunity/opportunity.py
+++ b/erpnext/crm/doctype/opportunity/opportunity.py
@@ -9,7 +9,7 @@
from frappe.email.inbox import link_communication_to_document
from frappe.model.mapper import get_mapped_doc
from frappe.query_builder import DocType
-from frappe.utils import cint, cstr, flt, get_fullname
+from frappe.utils import cint, flt, get_fullname
from erpnext.crm.utils import add_link_in_communication, copy_comments
from erpnext.setup.utils import get_exchange_rate
@@ -215,20 +215,20 @@
if self.party_name and self.opportunity_from == "Customer":
if self.contact_person:
- opts.description = "Contact " + cstr(self.contact_person)
+ opts.description = f"Contact {self.contact_person}"
else:
- opts.description = "Contact customer " + cstr(self.party_name)
+ opts.description = f"Contact customer {self.party_name}"
elif self.party_name and self.opportunity_from == "Lead":
if self.contact_display:
- opts.description = "Contact " + cstr(self.contact_display)
+ opts.description = f"Contact {self.contact_display}"
else:
- opts.description = "Contact lead " + cstr(self.party_name)
+ opts.description = f"Contact lead {self.party_name}"
opts.subject = opts.description
- opts.description += ". By : " + cstr(self.contact_by)
+ opts.description += f". By : {self.contact_by}"
if self.to_discuss:
- opts.description += " To Discuss : " + cstr(self.to_discuss)
+ opts.description += f" To Discuss : {frappe.render_template(self.to_discuss, {'doc': self})}"
super(Opportunity, self).add_calendar_event(opts, force)
diff --git a/erpnext/crm/doctype/opportunity/test_opportunity.py b/erpnext/crm/doctype/opportunity/test_opportunity.py
index 481c7f1..4a18e94 100644
--- a/erpnext/crm/doctype/opportunity/test_opportunity.py
+++ b/erpnext/crm/doctype/opportunity/test_opportunity.py
@@ -4,7 +4,7 @@
import unittest
import frappe
-from frappe.utils import now_datetime, random_string, today
+from frappe.utils import add_days, now_datetime, random_string, today
from erpnext.crm.doctype.lead.lead import make_customer
from erpnext.crm.doctype.lead.test_lead import make_lead
@@ -97,6 +97,22 @@
self.assertEqual(quotation_comment_count, 4)
self.assertEqual(quotation_communication_count, 4)
+ def test_render_template_for_to_discuss(self):
+ doc = make_opportunity(with_items=0, opportunity_from="Lead")
+ doc.contact_by = "test@example.com"
+ doc.contact_date = add_days(today(), days=2)
+ doc.to_discuss = "{{ doc.name }} test data"
+ doc.save()
+
+ event = frappe.get_all(
+ "Event Participants",
+ fields=["parent"],
+ filters={"reference_doctype": doc.doctype, "reference_docname": doc.name},
+ )
+
+ event_description = frappe.db.get_value("Event", event[0].parent, "description")
+ self.assertTrue(doc.name in event_description)
+
def make_opportunity_from_lead():
new_lead_email_id = "new{}@example.com".format(random_string(5))
diff --git a/erpnext/e_commerce/shopping_cart/test_shopping_cart.py b/erpnext/e_commerce/shopping_cart/test_shopping_cart.py
index 437ebea..30d9ffc 100644
--- a/erpnext/e_commerce/shopping_cart/test_shopping_cart.py
+++ b/erpnext/e_commerce/shopping_cart/test_shopping_cart.py
@@ -139,7 +139,7 @@
tax_rule_master = set_taxes(
quotation.party_name,
"Customer",
- quotation.transaction_date,
+ None,
quotation.company,
customer_group=None,
supplier_group=None,
diff --git a/erpnext/hooks.py b/erpnext/hooks.py
index 066249b..d1b5113 100644
--- a/erpnext/hooks.py
+++ b/erpnext/hooks.py
@@ -12,7 +12,7 @@
app_logo_url = "/assets/erpnext/images/erpnext-logo.svg"
-develop_version = "13.x.x-develop"
+develop_version = "14.x.x-develop"
app_include_js = "erpnext.bundle.js"
app_include_css = "erpnext.bundle.css"
diff --git a/erpnext/hr/doctype/leave_allocation/leave_allocation.js b/erpnext/hr/doctype/leave_allocation/leave_allocation.js
index aef4412..9742387 100755
--- a/erpnext/hr/doctype/leave_allocation/leave_allocation.js
+++ b/erpnext/hr/doctype/leave_allocation/leave_allocation.js
@@ -34,15 +34,6 @@
});
}
}
-
- // make new leaves allocated field read only if allocation is created via leave policy assignment
- // and leave type is earned leave, since these leaves would be allocated via the scheduler
- if (frm.doc.leave_policy_assignment) {
- frappe.db.get_value("Leave Type", frm.doc.leave_type, "is_earned_leave", (r) => {
- if (r && cint(r.is_earned_leave))
- frm.set_df_property("new_leaves_allocated", "read_only", 1);
- });
- }
},
expire_allocation: function(frm) {
diff --git a/erpnext/hr/doctype/leave_allocation/leave_allocation.py b/erpnext/hr/doctype/leave_allocation/leave_allocation.py
index 27479a5..8fae2a9 100755
--- a/erpnext/hr/doctype/leave_allocation/leave_allocation.py
+++ b/erpnext/hr/doctype/leave_allocation/leave_allocation.py
@@ -254,7 +254,18 @@
# Adding a day to include To Date in the difference
date_difference = date_diff(self.to_date, self.from_date) + 1
if date_difference < self.total_leaves_allocated:
- frappe.throw(_("Total allocated leaves are more than days in the period"), OverAllocationError)
+ if frappe.db.get_value("Leave Type", self.leave_type, "allow_over_allocation"):
+ frappe.msgprint(
+ _("<b>Total Leaves Allocated</b> are more than the number of days in the allocation period"),
+ indicator="orange",
+ alert=True,
+ )
+ else:
+ frappe.throw(
+ _("<b>Total Leaves Allocated</b> are more than the number of days in the allocation period"),
+ exc=OverAllocationError,
+ title=_("Over Allocation"),
+ )
def create_leave_ledger_entry(self, submit=True):
if self.unused_leaves:
diff --git a/erpnext/hr/doctype/leave_allocation/test_leave_allocation.py b/erpnext/hr/doctype/leave_allocation/test_leave_allocation.py
index a1d39d4..b4a42d3 100644
--- a/erpnext/hr/doctype/leave_allocation/test_leave_allocation.py
+++ b/erpnext/hr/doctype/leave_allocation/test_leave_allocation.py
@@ -69,22 +69,44 @@
self.assertRaises(frappe.ValidationError, doc.save)
def test_validation_for_over_allocation(self):
+ leave_type = create_leave_type(leave_type_name="Test Over Allocation", is_carry_forward=1)
+ leave_type.save()
+
doc = frappe.get_doc(
{
"doctype": "Leave Allocation",
"__islocal": 1,
"employee": self.employee.name,
"employee_name": self.employee.employee_name,
- "leave_type": "_Test Leave Type",
+ "leave_type": leave_type.name,
"from_date": getdate("2015-09-1"),
"to_date": getdate("2015-09-30"),
"new_leaves_allocated": 35,
+ "carry_forward": 1,
}
)
# allocated leave more than period
self.assertRaises(OverAllocationError, doc.save)
+ leave_type.allow_over_allocation = 1
+ leave_type.save()
+
+ # allows creating a leave allocation with more leave days than period days
+ doc = frappe.get_doc(
+ {
+ "doctype": "Leave Allocation",
+ "__islocal": 1,
+ "employee": self.employee.name,
+ "employee_name": self.employee.employee_name,
+ "leave_type": leave_type.name,
+ "from_date": getdate("2015-09-1"),
+ "to_date": getdate("2015-09-30"),
+ "new_leaves_allocated": 35,
+ "carry_forward": 1,
+ }
+ ).insert()
+
def test_validation_for_over_allocation_post_submission(self):
allocation = frappe.get_doc(
{
diff --git a/erpnext/hr/doctype/leave_application/test_leave_application.py b/erpnext/hr/doctype/leave_application/test_leave_application.py
index 4c39e15..7506c61 100644
--- a/erpnext/hr/doctype/leave_application/test_leave_application.py
+++ b/erpnext/hr/doctype/leave_application/test_leave_application.py
@@ -745,7 +745,7 @@
i = 0
while i < 14:
- allocate_earned_leaves(ignore_duplicates=True)
+ allocate_earned_leaves()
i += 1
self.assertEqual(get_leave_balance_on(employee.name, leave_type, nowdate()), 6)
@@ -753,7 +753,7 @@
frappe.db.set_value("Leave Type", leave_type, "max_leaves_allowed", 0)
i = 0
while i < 6:
- allocate_earned_leaves(ignore_duplicates=True)
+ allocate_earned_leaves()
i += 1
self.assertEqual(get_leave_balance_on(employee.name, leave_type, nowdate()), 9)
diff --git a/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py b/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py
index 9780828..031ed0e 100644
--- a/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py
+++ b/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py
@@ -4,6 +4,7 @@
import unittest
import frappe
+from frappe.tests.utils import FrappeTestCase
from frappe.utils import add_days, add_months, get_first_day, get_last_day, getdate
from erpnext.hr.doctype.leave_application.test_leave_application import (
@@ -18,7 +19,7 @@
test_dependencies = ["Employee"]
-class TestLeavePolicyAssignment(unittest.TestCase):
+class TestLeavePolicyAssignment(FrappeTestCase):
def setUp(self):
for doctype in [
"Leave Period",
@@ -39,6 +40,9 @@
leave_policy = create_leave_policy()
leave_policy.submit()
+ self.employee.date_of_joining = get_first_day(leave_period.from_date)
+ self.employee.save()
+
data = {
"assignment_based_on": "Leave Period",
"leave_policy": leave_policy.name,
@@ -188,19 +192,6 @@
)
self.assertEqual(leaves_allocated, 3)
- # if the daily job is not completed yet, there is another check present
- # to ensure leave is not already allocated to avoid duplication
- from erpnext.hr.utils import allocate_earned_leaves
-
- allocate_earned_leaves()
-
- leaves_allocated = frappe.db.get_value(
- "Leave Allocation",
- {"leave_policy_assignment": leave_policy_assignments[0]},
- "total_leaves_allocated",
- )
- self.assertEqual(leaves_allocated, 3)
-
def test_earned_leave_alloc_for_passed_months_with_cf_leaves_based_on_leave_period(self):
from erpnext.hr.doctype.leave_allocation.test_leave_allocation import create_leave_allocation
@@ -242,20 +233,6 @@
self.assertEqual(details.unused_leaves, 5)
self.assertEqual(details.total_leaves_allocated, 7)
- # if the daily job is not completed yet, there is another check present
- # to ensure leave is not already allocated to avoid duplication
- from erpnext.hr.utils import is_earned_leave_already_allocated
-
- frappe.flags.current_date = get_last_day(getdate())
-
- allocation = frappe.get_doc("Leave Allocation", details.name)
- # 1 leave is still pending to be allocated, irrespective of carry forwarded leaves
- self.assertFalse(
- is_earned_leave_already_allocated(
- allocation, leave_policy.leave_policy_details[0].annual_allocation
- )
- )
-
def test_earned_leave_alloc_for_passed_months_based_on_joining_date(self):
# tests leave alloc for earned leaves for assignment based on joining date in policy assignment
leave_type = create_earned_leave_type("Test Earned Leave")
@@ -288,19 +265,6 @@
self.assertEqual(effective_from, self.employee.date_of_joining)
self.assertEqual(leaves_allocated, 3)
- # to ensure leave is not already allocated to avoid duplication
- from erpnext.hr.utils import allocate_earned_leaves
-
- frappe.flags.current_date = get_last_day(getdate())
- allocate_earned_leaves()
-
- leaves_allocated = frappe.db.get_value(
- "Leave Allocation",
- {"leave_policy_assignment": leave_policy_assignments[0]},
- "total_leaves_allocated",
- )
- self.assertEqual(leaves_allocated, 3)
-
def test_grant_leaves_on_doj_for_earned_leaves_based_on_leave_period(self):
# tests leave alloc based on leave period for earned leaves with "based on doj" configuration in leave type
leave_period, leave_policy = setup_leave_period_and_policy(
@@ -330,20 +294,6 @@
)
self.assertEqual(leaves_allocated, 3)
- # if the daily job is not completed yet, there is another check present
- # to ensure leave is not already allocated to avoid duplication
- from erpnext.hr.utils import allocate_earned_leaves
-
- frappe.flags.current_date = get_first_day(getdate())
- allocate_earned_leaves()
-
- leaves_allocated = frappe.db.get_value(
- "Leave Allocation",
- {"leave_policy_assignment": leave_policy_assignments[0]},
- "total_leaves_allocated",
- )
- self.assertEqual(leaves_allocated, 3)
-
def test_grant_leaves_on_doj_for_earned_leaves_based_on_joining_date(self):
# tests leave alloc based on joining date for earned leaves with "based on doj" configuration in leave type
leave_type = create_earned_leave_type("Test Earned Leave", based_on_doj=True)
@@ -377,21 +327,7 @@
self.assertEqual(effective_from, self.employee.date_of_joining)
self.assertEqual(leaves_allocated, 3)
- # to ensure leave is not already allocated to avoid duplication
- from erpnext.hr.utils import allocate_earned_leaves
-
- frappe.flags.current_date = get_first_day(getdate())
- allocate_earned_leaves()
-
- leaves_allocated = frappe.db.get_value(
- "Leave Allocation",
- {"leave_policy_assignment": leave_policy_assignments[0]},
- "total_leaves_allocated",
- )
- self.assertEqual(leaves_allocated, 3)
-
def tearDown(self):
- frappe.db.rollback()
frappe.db.set_value("Employee", self.employee.name, "date_of_joining", self.original_doj)
frappe.flags.current_date = None
diff --git a/erpnext/hr/doctype/leave_type/leave_type.json b/erpnext/hr/doctype/leave_type/leave_type.json
index 06ca4cd..d40ff09 100644
--- a/erpnext/hr/doctype/leave_type/leave_type.json
+++ b/erpnext/hr/doctype/leave_type/leave_type.json
@@ -19,6 +19,7 @@
"fraction_of_daily_salary_per_leave",
"is_optional_leave",
"allow_negative",
+ "allow_over_allocation",
"include_holiday",
"is_compensatory",
"carry_forward_section",
@@ -211,15 +212,23 @@
"fieldtype": "Float",
"label": "Fraction of Daily Salary per Leave",
"mandatory_depends_on": "eval:doc.is_ppl == 1"
+ },
+ {
+ "default": "0",
+ "description": "Allows allocating more leaves than the number of days in the allocation period.",
+ "fieldname": "allow_over_allocation",
+ "fieldtype": "Check",
+ "label": "Allow Over Allocation"
}
],
"icon": "fa fa-flag",
"idx": 1,
"links": [],
- "modified": "2021-10-02 11:59:40.503359",
+ "modified": "2022-05-09 05:01:38.957545",
"modified_by": "Administrator",
"module": "HR",
"name": "Leave Type",
+ "naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
@@ -251,5 +260,6 @@
],
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/hr/utils.py b/erpnext/hr/utils.py
index 4e30b0f..269e4aa 100644
--- a/erpnext/hr/utils.py
+++ b/erpnext/hr/utils.py
@@ -269,7 +269,7 @@
create_leave_encashment(leave_allocation=leave_allocation)
-def allocate_earned_leaves(ignore_duplicates=False):
+def allocate_earned_leaves():
"""Allocate earned leaves to Employees"""
e_leave_types = get_earned_leaves()
today = getdate()
@@ -305,14 +305,10 @@
if check_effective_date(
from_date, today, e_leave_type.earned_leave_frequency, e_leave_type.based_on_date_of_joining
):
- update_previous_leave_allocation(
- allocation, annual_allocation, e_leave_type, ignore_duplicates
- )
+ update_previous_leave_allocation(allocation, annual_allocation, e_leave_type)
-def update_previous_leave_allocation(
- allocation, annual_allocation, e_leave_type, ignore_duplicates=False
-):
+def update_previous_leave_allocation(allocation, annual_allocation, e_leave_type):
earned_leaves = get_monthly_earned_leave(
annual_allocation, e_leave_type.earned_leave_frequency, e_leave_type.rounding
)
@@ -326,20 +322,19 @@
if new_allocation != allocation.total_leaves_allocated:
today_date = today()
- if ignore_duplicates or not is_earned_leave_already_allocated(allocation, annual_allocation):
- allocation.db_set("total_leaves_allocated", new_allocation, update_modified=False)
- create_additional_leave_ledger_entry(allocation, earned_leaves, today_date)
+ allocation.db_set("total_leaves_allocated", new_allocation, update_modified=False)
+ create_additional_leave_ledger_entry(allocation, earned_leaves, today_date)
- if e_leave_type.based_on_date_of_joining:
- text = _("allocated {0} leave(s) via scheduler on {1} based on the date of joining").format(
- frappe.bold(earned_leaves), frappe.bold(formatdate(today_date))
- )
- else:
- text = _("allocated {0} leave(s) via scheduler on {1}").format(
- frappe.bold(earned_leaves), frappe.bold(formatdate(today_date))
- )
+ if e_leave_type.based_on_date_of_joining:
+ text = _("allocated {0} leave(s) via scheduler on {1} based on the date of joining").format(
+ frappe.bold(earned_leaves), frappe.bold(formatdate(today_date))
+ )
+ else:
+ text = _("allocated {0} leave(s) via scheduler on {1}").format(
+ frappe.bold(earned_leaves), frappe.bold(formatdate(today_date))
+ )
- allocation.add_comment(comment_type="Info", text=text)
+ allocation.add_comment(comment_type="Info", text=text)
def get_monthly_earned_leave(annual_leaves, frequency, rounding):
diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py
index bf4f82f..a98fc94 100644
--- a/erpnext/manufacturing/doctype/job_card/job_card.py
+++ b/erpnext/manufacturing/doctype/job_card/job_card.py
@@ -764,8 +764,6 @@
pending_fg_qty = flt(source.get("for_quantity", 0)) - flt(source.get("transferred_qty", 0))
target.fg_completed_qty = pending_fg_qty if pending_fg_qty > 0 else 0
- target.set_transfer_qty()
- target.calculate_rate_and_amount()
target.set_missing_values()
target.set_stock_entry_type()
diff --git a/erpnext/patches.txt b/erpnext/patches.txt
index d5b1592..1fef240 100644
--- a/erpnext/patches.txt
+++ b/erpnext/patches.txt
@@ -369,4 +369,5 @@
erpnext.patches.v13_0.change_default_item_manufacturer_fieldtype
erpnext.patches.v14_0.discount_accounting_separation
erpnext.patches.v14_0.delete_employee_transfer_property_doctype
-erpnext.patches.v13_0.create_accounting_dimensions_in_orders
\ No newline at end of file
+erpnext.patches.v13_0.create_accounting_dimensions_in_orders
+erpnext.patches.v13_0.set_per_billed_in_return_delivery_note
\ No newline at end of file
diff --git a/erpnext/patches/v13_0/create_accounting_dimensions_in_orders.py b/erpnext/patches/v13_0/create_accounting_dimensions_in_orders.py
index 8a3f1d0..7e6e820 100644
--- a/erpnext/patches/v13_0/create_accounting_dimensions_in_orders.py
+++ b/erpnext/patches/v13_0/create_accounting_dimensions_in_orders.py
@@ -33,7 +33,7 @@
"insert_after": insert_after_field,
}
- create_custom_field(doctype, df, ignore_validate=False)
+ create_custom_field(doctype, df, ignore_validate=True)
frappe.clear_cache(doctype=doctype)
count += 1
diff --git a/erpnext/patches/v13_0/set_per_billed_in_return_delivery_note.py b/erpnext/patches/v13_0/set_per_billed_in_return_delivery_note.py
new file mode 100644
index 0000000..a4d7012
--- /dev/null
+++ b/erpnext/patches/v13_0/set_per_billed_in_return_delivery_note.py
@@ -0,0 +1,29 @@
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+import frappe
+
+
+def execute():
+ dn = frappe.qb.DocType("Delivery Note")
+ dn_item = frappe.qb.DocType("Delivery Note Item")
+
+ dn_list = (
+ frappe.qb.from_(dn)
+ .inner_join(dn_item)
+ .on(dn.name == dn_item.parent)
+ .select(dn.name)
+ .where(dn.docstatus == 1)
+ .where(dn.is_return == 1)
+ .where(dn.per_billed < 100)
+ .where(dn_item.returned_qty > 0)
+ .run(as_dict=True)
+ )
+
+ frappe.qb.update(dn_item).inner_join(dn).on(dn.name == dn_item.parent).set(
+ dn_item.returned_qty, 0
+ ).where(dn.is_return == 1).where(dn_item.returned_qty > 0).run()
+
+ for d in dn_list:
+ dn_doc = frappe.get_doc("Delivery Note", d.get("name"))
+ dn_doc.run_method("update_billing_status")
diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js
index 767221e..c3812f3 100644
--- a/erpnext/public/js/controllers/transaction.js
+++ b/erpnext/public/js/controllers/transaction.js
@@ -1384,12 +1384,15 @@
var me = this;
var args = this._get_args(item);
if (!(args.items && args.items.length)) {
- if(calculate_taxes_and_totals) me.calculate_taxes_and_totals();
+ if (calculate_taxes_and_totals) me.calculate_taxes_and_totals();
return;
}
// Target doc created from a mapped doc
if (this.frm.doc.__onload && this.frm.doc.__onload.ignore_price_list) {
+ // Calculate totals even though pricing rule is not applied.
+ // `apply_pricing_rule` is triggered due to change in data which most likely contributes to Total.
+ if (calculate_taxes_and_totals) me.calculate_taxes_and_totals();
return;
}
diff --git a/erpnext/public/js/utils/barcode_scanner.js b/erpnext/public/js/utils/barcode_scanner.js
index f72b85c..3ae1234 100644
--- a/erpnext/public/js/utils/barcode_scanner.js
+++ b/erpnext/public/js/utils/barcode_scanner.js
@@ -10,6 +10,12 @@
this.serial_no_field = opts.serial_no_field || "serial_no";
this.batch_no_field = opts.batch_no_field || "batch_no";
this.qty_field = opts.qty_field || "qty";
+ // field name on row which defines max quantity to be scanned e.g. picklist
+ this.max_qty_field = opts.max_qty_field;
+ // scanner won't add a new row if this flag is set.
+ this.dont_allow_new_row = opts.dont_allow_new_row;
+ // scanner will ask user to type the quantity instead of incrementing by 1
+ this.prompt_qty = opts.prompt_qty;
this.items_table_name = opts.items_table_name || "items";
this.items_table = this.frm.doc[this.items_table_name];
@@ -42,10 +48,7 @@
.then((r) => {
const data = r && r.message;
if (!data || Object.keys(data).length === 0) {
- frappe.show_alert({
- message: __("Cannot find Item with this Barcode"),
- indicator: "red",
- });
+ this.show_alert(__("Cannot find Item with this Barcode"), "red");
this.clean_up();
return;
}
@@ -56,22 +59,18 @@
update_table(data) {
let cur_grid = this.frm.fields_dict[this.items_table_name].grid;
- let row = null;
const {item_code, barcode, batch_no, serial_no} = data;
- // Check if batch is scanned and table has batch no field
- let batch_no_scan =
- Boolean(batch_no) && frappe.meta.has_field(cur_grid.doctype, this.batch_no_field);
-
- if (batch_no_scan) {
- row = this.get_batch_row_to_modify(batch_no);
- } else {
- // serial or barcode scan
- row = this.get_row_to_modify_on_scan(item_code);
- }
+ let row = this.get_row_to_modify_on_scan(item_code, batch_no);
if (!row) {
+ if (this.dont_allow_new_row) {
+ this.show_alert(__("Maximum quantity scanned for item {0}.", [item_code]), "red");
+ this.clean_up();
+ return;
+ }
+
// add new row if new item/batch is scanned
row = frappe.model.add_child(this.frm.doc, cur_grid.doctype, this.items_table_name);
// trigger any row add triggers defined on child table.
@@ -83,9 +82,10 @@
return;
}
- this.show_scan_message(row.idx, row.item_code);
this.set_selector_trigger_flag(row, data);
- this.set_item(row, item_code);
+ this.set_item(row, item_code).then(qty => {
+ this.show_scan_message(row.idx, row.item_code, qty);
+ });
this.set_serial_no(row, serial_no);
this.set_batch_no(row, batch_no);
this.set_barcode(row, barcode);
@@ -106,9 +106,23 @@
}
set_item(row, item_code) {
- const item_data = { item_code: item_code };
- item_data[this.qty_field] = (row[this.qty_field] || 0) + 1;
- frappe.model.set_value(row.doctype, row.name, item_data);
+ return new Promise(resolve => {
+ const increment = (value = 1) => {
+ const item_data = {item_code: item_code};
+ item_data[this.qty_field] = Number((row[this.qty_field] || 0)) + Number(value);
+ frappe.model.set_value(row.doctype, row.name, item_data);
+ };
+
+ if (this.prompt_qty) {
+ frappe.prompt(__("Please enter quantity for item {0}", [item_code]), ({value}) => {
+ increment(value);
+ resolve(value);
+ });
+ } else {
+ increment();
+ resolve();
+ }
+ });
}
set_serial_no(row, serial_no) {
@@ -137,53 +151,42 @@
}
}
- show_scan_message(idx, exist = null) {
+ show_scan_message(idx, exist = null, qty = 1) {
// show new row or qty increase toast
if (exist) {
- frappe.show_alert(
- {
- message: __("Row #{0}: Qty increased by 1", [idx]),
- indicator: "green",
- },
- 5
- );
+ this.show_alert(__("Row #{0}: Qty increased by {1}", [idx, qty]), "green");
} else {
- frappe.show_alert(
- {
- message: __("Row #{0}: Item added", [idx]),
- indicator: "green",
- },
- 5
- );
+ this.show_alert(__("Row #{0}: Item added", [idx]), "green")
}
}
is_duplicate_serial_no(row, serial_no) {
- const is_duplicate = !!serial_no && !!row[this.serial_no_field]
- && row[this.serial_no_field].includes(serial_no);
+ const is_duplicate = row[this.serial_no_field]?.includes(serial_no);
if (is_duplicate) {
- frappe.show_alert(
- {
- message: __("Serial No {0} is already added", [serial_no]),
- indicator: "orange",
- },
- 5
- );
+ this.show_alert(__("Serial No {0} is already added", [serial_no]), "orange");
}
return is_duplicate;
}
- get_batch_row_to_modify(batch_no) {
- // get row if batch already exists in table
- const existing_batch_row = this.items_table.find((d) => d.batch_no === batch_no);
- return existing_batch_row || this.get_existing_blank_row();
- }
+ get_row_to_modify_on_scan(item_code, batch_no) {
+ let cur_grid = this.frm.fields_dict[this.items_table_name].grid;
- get_row_to_modify_on_scan(item_code) {
- // get an existing item row to increment or blank row to modify
- const existing_item_row = this.items_table.find((d) => d.item_code === item_code);
- return existing_item_row || this.get_existing_blank_row();
+ // Check if batch is scanned and table has batch no field
+ let is_batch_no_scan = batch_no && frappe.meta.has_field(cur_grid.doctype, this.batch_no_field);
+ let check_max_qty = this.max_qty_field && frappe.meta.has_field(cur_grid.doctype, this.max_qty_field);
+
+ const matching_row = (row) => {
+ const item_match = row.item_code == item_code;
+ const batch_match = row.batch_no == batch_no;
+ const qty_in_limit = flt(row[this.qty_field]) < flt(row[this.max_qty_field]);
+
+ return item_match
+ && (!is_batch_no_scan || batch_match)
+ && (!check_max_qty || qty_in_limit)
+ }
+
+ return this.items_table.find(matching_row) || this.get_existing_blank_row();
}
get_existing_blank_row() {
@@ -194,4 +197,7 @@
this.scan_barcode_field.set_value("");
refresh_field(this.items_table_name);
}
+ show_alert(msg, indicator, duration=3) {
+ frappe.show_alert({message: msg, indicator: indicator}, duration);
+ }
};
diff --git a/erpnext/regional/india/e_invoice/einvoice.js b/erpnext/regional/india/e_invoice/einvoice.js
index 763e657..ea56d07 100644
--- a/erpnext/regional/india/e_invoice/einvoice.js
+++ b/erpnext/regional/india/e_invoice/einvoice.js
@@ -204,6 +204,29 @@
};
add_custom_button(__("Cancel E-Way Bill"), action);
}
+
+ if (irn && !irn_cancelled) {
+ const action = () => {
+ const dialog = frappe.msgprint({
+ title: __("Generate QRCode"),
+ message: __("Generate and attach QR Code using IRN?"),
+ primary_action: {
+ action: function() {
+ frappe.call({
+ method: 'erpnext.regional.india.e_invoice.utils.generate_qrcode',
+ args: { doctype, docname: name },
+ freeze: true,
+ callback: () => frm.reload_doc() || dialog.hide(),
+ error: () => dialog.hide()
+ });
+ }
+ },
+ primary_action_label: __('Yes')
+ });
+ dialog.show();
+ };
+ add_custom_button(__("Generate QRCode"), action);
+ }
}
});
};
@@ -211,86 +234,100 @@
const get_ewaybill_fields = (frm) => {
return [
{
- 'fieldname': 'transporter',
- 'label': 'Transporter',
- 'fieldtype': 'Link',
- 'options': 'Supplier',
- 'default': frm.doc.transporter
+ fieldname: "eway_part_a_section_break",
+ fieldtype: "Section Break",
+ label: "Part A",
},
{
- 'fieldname': 'gst_transporter_id',
- 'label': 'GST Transporter ID',
- 'fieldtype': 'Data',
- 'default': frm.doc.gst_transporter_id
+ fieldname: "transporter",
+ label: "Transporter",
+ fieldtype: "Link",
+ options: "Supplier",
+ default: frm.doc.transporter,
},
{
- 'fieldname': 'driver',
- 'label': 'Driver',
- 'fieldtype': 'Link',
- 'options': 'Driver',
- 'default': frm.doc.driver
+ fieldname: "transporter_name",
+ label: "Transporter Name",
+ fieldtype: "Data",
+ read_only: 1,
+ default: frm.doc.transporter_name,
+ depends_on: "transporter",
},
{
- 'fieldname': 'lr_no',
- 'label': 'Transport Receipt No',
- 'fieldtype': 'Data',
- 'default': frm.doc.lr_no
+ fieldname: "part_a_column_break",
+ fieldtype: "Column Break",
},
{
- 'fieldname': 'vehicle_no',
- 'label': 'Vehicle No',
- 'fieldtype': 'Data',
- 'default': frm.doc.vehicle_no
+ fieldname: "gst_transporter_id",
+ label: "GST Transporter ID",
+ fieldtype: "Data",
+ default: frm.doc.gst_transporter_id,
},
{
- 'fieldname': 'distance',
- 'label': 'Distance (in km)',
- 'fieldtype': 'Float',
- 'default': frm.doc.distance,
- 'description': 'Set as zero to auto calculate distance using pin codes',
+ fieldname: "distance",
+ label: "Distance (in km)",
+ fieldtype: "Float",
+ default: frm.doc.distance,
+ description: 'Set as zero to auto calculate distance using pin codes',
},
{
- 'fieldname': 'transporter_col_break',
- 'fieldtype': 'Column Break',
+ fieldname: "eway_part_b_section_break",
+ fieldtype: "Section Break",
+ label: "Part B",
},
{
- 'fieldname': 'transporter_name',
- 'label': 'Transporter Name',
- 'fieldtype': 'Data',
- 'read_only': 1,
- 'default': frm.doc.transporter_name,
- 'depends_on': 'transporter'
+ fieldname: "mode_of_transport",
+ label: "Mode of Transport",
+ fieldtype: "Select",
+ options: `\nRoad\nAir\nRail\nShip`,
+ default: frm.doc.mode_of_transport,
},
{
- 'fieldname': 'mode_of_transport',
- 'label': 'Mode of Transport',
- 'fieldtype': 'Select',
- 'options': `\nRoad\nAir\nRail\nShip`,
- 'default': frm.doc.mode_of_transport
+ fieldname: "gst_vehicle_type",
+ label: "GST Vehicle Type",
+ fieldtype: "Select",
+ options: `Regular\nOver Dimensional Cargo (ODC)`,
+ depends_on: 'eval:(doc.mode_of_transport === "Road")',
+ default: frm.doc.gst_vehicle_type,
},
{
- 'fieldname': 'driver_name',
- 'label': 'Driver Name',
- 'fieldtype': 'Data',
- 'fetch_from': 'driver.full_name',
- 'read_only': 1,
- 'default': frm.doc.driver_name,
- 'depends_on': 'driver'
+ fieldname: "vehicle_no",
+ label: "Vehicle No",
+ fieldtype: "Data",
+ default: frm.doc.vehicle_no,
},
{
- 'fieldname': 'lr_date',
- 'label': 'Transport Receipt Date',
- 'fieldtype': 'Date',
- 'default': frm.doc.lr_date
+ fieldname: "part_b_column_break",
+ fieldtype: "Column Break",
},
{
- 'fieldname': 'gst_vehicle_type',
- 'label': 'GST Vehicle Type',
- 'fieldtype': 'Select',
- 'options': `Regular\nOver Dimensional Cargo (ODC)`,
- 'depends_on': 'eval:(doc.mode_of_transport === "Road")',
- 'default': frm.doc.gst_vehicle_type
- }
+ fieldname: "lr_date",
+ label: "Transport Receipt Date",
+ fieldtype: "Date",
+ default: frm.doc.lr_date,
+ },
+ {
+ fieldname: "lr_no",
+ label: "Transport Receipt No",
+ fieldtype: "Data",
+ default: frm.doc.lr_no,
+ },
+ {
+ fieldname: "driver",
+ label: "Driver",
+ fieldtype: "Link",
+ options: "Driver",
+ default: frm.doc.driver,
+ },
+ {
+ fieldname: "driver_name",
+ label: "Driver Name",
+ fieldtype: "Data",
+ fetch_from: "driver.full_name",
+ read_only: 1,
+ default: frm.doc.driver_name,
+ depends_on: "driver",
+ },
];
};
diff --git a/erpnext/regional/india/e_invoice/utils.py b/erpnext/regional/india/e_invoice/utils.py
index 53d3211..ed1002a 100644
--- a/erpnext/regional/india/e_invoice/utils.py
+++ b/erpnext/regional/india/e_invoice/utils.py
@@ -167,7 +167,12 @@
title=_("Not Allowed"),
)
- invoice_type = "CRN" if invoice.is_return else "INV"
+ if invoice.is_return:
+ invoice_type = "CRN"
+ elif invoice.is_debit_note:
+ invoice_type = "DBN"
+ else:
+ invoice_type = "INV"
invoice_name = invoice.name
invoice_date = format_date(invoice.posting_date, "dd/mm/yyyy")
@@ -794,6 +799,7 @@
self.gstin_details_url = self.base_url + "/enriched/ei/api/master/gstin"
self.cancel_ewaybill_url = self.base_url + "/enriched/ei/api/ewayapi"
self.generate_ewaybill_url = self.base_url + "/enriched/ei/api/ewaybill"
+ self.get_qrcode_url = self.base_url + "/enriched/ei/others/qr/image"
def set_invoice(self):
self.invoice = None
@@ -857,8 +863,8 @@
return res
def auto_refresh_token(self):
- self.fetch_auth_token()
self.token_auto_refreshed = True
+ self.fetch_auth_token()
def log_request(self, url, headers, data, res):
headers.update({"password": self.credentials.password})
@@ -998,6 +1004,37 @@
return failed
+ def fetch_and_attach_qrcode_from_irn(self):
+ qrcode = self.get_qrcode_from_irn(self.invoice.irn)
+ if qrcode:
+ qrcode_file = self.create_qr_code_file(qrcode)
+ frappe.db.set_value("Sales Invoice", self.invoice.name, "qrcode_image", qrcode_file.file_url)
+ frappe.msgprint(_("QR Code attached to the invoice"), alert=True)
+ else:
+ frappe.msgprint(_("QR Code not found for the IRN"), alert=True)
+
+ def get_qrcode_from_irn(self, irn):
+ import requests
+
+ headers = self.get_headers()
+ headers.update({"width": "215", "height": "215", "imgtype": "jpg", "irn": irn})
+
+ try:
+ # using requests.get instead of make_request to avoid parsing the response
+ res = requests.get(self.get_qrcode_url, headers=headers)
+ self.log_request(self.get_qrcode_url, headers, None, None)
+ if res.status_code == 200:
+ return res.content
+ else:
+ raise RequestFailed(str(res.content, "utf-8"))
+
+ except RequestFailed as e:
+ self.raise_error(errors=str(e))
+
+ except Exception:
+ log_error()
+ self.raise_error()
+
def get_irn_details(self, irn):
headers = self.get_headers()
@@ -1198,8 +1235,6 @@
return errors
def raise_error(self, raise_exception=False, errors=None):
- if errors is None:
- errors = []
title = _("E Invoice Request Failed")
if errors:
frappe.throw(errors, title=title, as_list=1)
@@ -1240,13 +1275,18 @@
def attach_qrcode_image(self):
qrcode = self.invoice.signed_qr_code
- doctype = self.invoice.doctype
- docname = self.invoice.name
- filename = "QRCode_{}.png".format(docname).replace(os.path.sep, "__")
qr_image = io.BytesIO()
url = qrcreate(qrcode, error="L")
url.png(qr_image, scale=2, quiet_zone=1)
+ qrcode_file = self.create_qr_code_file(qr_image.getvalue())
+ self.invoice.qrcode_image = qrcode_file.file_url
+
+ def create_qr_code_file(self, qr_image):
+ doctype = self.invoice.doctype
+ docname = self.invoice.name
+ filename = "QRCode_{}.png".format(docname).replace(os.path.sep, "__")
+
_file = frappe.get_doc(
{
"doctype": "File",
@@ -1255,12 +1295,12 @@
"attached_to_name": docname,
"attached_to_field": "qrcode_image",
"is_private": 0,
- "content": qr_image.getvalue(),
+ "content": qr_image,
}
)
_file.save()
frappe.db.commit()
- self.invoice.qrcode_image = _file.file_url
+ return _file
def update_invoice(self):
self.invoice.flags.ignore_validate_update_after_submit = True
@@ -1306,6 +1346,12 @@
@frappe.whitelist()
+def generate_qrcode(doctype, docname):
+ gsp_connector = GSPConnector(doctype, docname)
+ gsp_connector.fetch_and_attach_qrcode_from_irn()
+
+
+@frappe.whitelist()
def generate_eway_bill(doctype, docname, **kwargs):
gsp_connector = GSPConnector(doctype, docname)
gsp_connector.generate_eway_bill(**kwargs)
diff --git a/erpnext/regional/report/hsn_wise_summary_of_outward_supplies/hsn_wise_summary_of_outward_supplies.py b/erpnext/regional/report/hsn_wise_summary_of_outward_supplies/hsn_wise_summary_of_outward_supplies.py
index 09f2df1..3f42668 100644
--- a/erpnext/regional/report/hsn_wise_summary_of_outward_supplies/hsn_wise_summary_of_outward_supplies.py
+++ b/erpnext/regional/report/hsn_wise_summary_of_outward_supplies/hsn_wise_summary_of_outward_supplies.py
@@ -32,7 +32,7 @@
added_item = []
for d in item_list:
if (d.parent, d.item_code) not in added_item:
- row = [d.gst_hsn_code, d.description, d.stock_uom, d.stock_qty]
+ row = [d.gst_hsn_code, d.description, d.stock_uom, d.stock_qty, d.tax_rate]
total_tax = 0
for tax in tax_columns:
item_tax = itemised_tax.get((d.parent, d.item_code), {}).get(tax, {})
@@ -40,11 +40,9 @@
row += [d.base_net_amount + total_tax]
row += [d.base_net_amount]
-
for tax in tax_columns:
item_tax = itemised_tax.get((d.parent, d.item_code), {}).get(tax, {})
row += [item_tax.get("tax_amount", 0)]
-
data.append(row)
added_item.append((d.parent, d.item_code))
if data:
@@ -64,6 +62,7 @@
{"fieldname": "description", "label": _("Description"), "fieldtype": "Data", "width": 300},
{"fieldname": "stock_uom", "label": _("Stock UOM"), "fieldtype": "Data", "width": 100},
{"fieldname": "stock_qty", "label": _("Stock Qty"), "fieldtype": "Float", "width": 90},
+ {"fieldname": "tax_rate", "label": _("Tax Rate"), "fieldtype": "Data", "width": 90},
{"fieldname": "total_amount", "label": _("Total Amount"), "fieldtype": "Currency", "width": 120},
{
"fieldname": "taxable_amount",
@@ -106,16 +105,25 @@
sum(`tabSales Invoice Item`.stock_qty) as stock_qty,
sum(`tabSales Invoice Item`.base_net_amount) as base_net_amount,
sum(`tabSales Invoice Item`.base_price_list_rate) as base_price_list_rate,
- `tabSales Invoice Item`.parent, `tabSales Invoice Item`.item_code,
- `tabGST HSN Code`.description
- from `tabSales Invoice`, `tabSales Invoice Item`, `tabGST HSN Code`
- where `tabSales Invoice`.name = `tabSales Invoice Item`.parent
+ `tabSales Invoice Item`.parent,
+ `tabSales Invoice Item`.item_code,
+ `tabGST HSN Code`.description,
+ json_extract(`tabSales Taxes and Charges`.item_wise_tax_detail,
+ concat('$."' , `tabSales Invoice Item`.item_code, '"[0]')) * count(distinct `tabSales Taxes and Charges`.name) as tax_rate
+ from
+ `tabSales Invoice`,
+ `tabSales Invoice Item`,
+ `tabGST HSN Code`,
+ `tabSales Taxes and Charges`
+ where
+ `tabSales Invoice`.name = `tabSales Invoice Item`.parent
+ and `tabSales Taxes and Charges`.parent = `tabSales Invoice`.name
and `tabSales Invoice`.docstatus = 1
and `tabSales Invoice Item`.gst_hsn_code is not NULL
and `tabSales Invoice Item`.gst_hsn_code = `tabGST HSN Code`.name %s %s
group by
- `tabSales Invoice Item`.parent, `tabSales Invoice Item`.item_code
-
+ `tabSales Invoice Item`.parent,
+ `tabSales Invoice Item`.item_code
"""
% (conditions, match_conditions),
filters,
@@ -213,15 +221,16 @@
result = []
for row in data:
- merged_hsn_dict.setdefault(row[0], {})
+ key = row[0] + "-" + str(row[4])
+ merged_hsn_dict.setdefault(key, {})
for i, d in enumerate(columns):
if d["fieldtype"] not in ("Int", "Float", "Currency"):
- merged_hsn_dict[row[0]][d["fieldname"]] = row[i]
+ merged_hsn_dict[key][d["fieldname"]] = row[i]
else:
- if merged_hsn_dict.get(row[0], {}).get(d["fieldname"], ""):
- merged_hsn_dict[row[0]][d["fieldname"]] += row[i]
+ if merged_hsn_dict.get(key, {}).get(d["fieldname"], ""):
+ merged_hsn_dict[key][d["fieldname"]] += row[i]
else:
- merged_hsn_dict[row[0]][d["fieldname"]] = row[i]
+ merged_hsn_dict[key][d["fieldname"]] = row[i]
for key, value in merged_hsn_dict.items():
result.append(value)
@@ -240,7 +249,7 @@
fp = "%02d%s" % (getdate(filters["to_date"]).month, getdate(filters["to_date"]).year)
- gst_json = {"version": "GST2.3.4", "hash": "hash", "gstin": gstin, "fp": fp}
+ gst_json = {"version": "GST3.0.3", "hash": "hash", "gstin": gstin, "fp": fp}
gst_json["hsn"] = {"data": get_hsn_wise_json_data(filters, report_data)}
@@ -271,7 +280,7 @@
"desc": hsn.get("description"),
"uqc": hsn.get("stock_uom").upper(),
"qty": hsn.get("stock_qty"),
- "val": flt(hsn.get("total_amount"), 2),
+ "rt": flt(hsn.get("tax_rate"), 2),
"txval": flt(hsn.get("taxable_amount", 2)),
"iamt": 0.0,
"camt": 0.0,
diff --git a/erpnext/selling/page/point_of_sale/pos_controller.js b/erpnext/selling/page/point_of_sale/pos_controller.js
index 7a68386..cb4bd51 100644
--- a/erpnext/selling/page/point_of_sale/pos_controller.js
+++ b/erpnext/selling/page/point_of_sale/pos_controller.js
@@ -479,16 +479,20 @@
frappe.dom.freeze();
this.frm = this.get_new_frm(this.frm);
this.frm.doc.items = [];
- const res = await frappe.call({
+ return frappe.call({
method: "erpnext.accounts.doctype.pos_invoice.pos_invoice.make_sales_return",
args: {
'source_name': doc.name,
'target_doc': this.frm.doc
+ },
+ callback: (r) => {
+ frappe.model.sync(r.message);
+ frappe.get_doc(r.message.doctype, r.message.name).__run_link_triggers = false;
+ this.set_pos_profile_data().then(() => {
+ frappe.dom.unfreeze();
+ });
}
});
- frappe.model.sync(res.message);
- await this.set_pos_profile_data();
- frappe.dom.unfreeze();
}
set_pos_profile_data() {
diff --git a/erpnext/selling/report/item_wise_sales_history/item_wise_sales_history.py b/erpnext/selling/report/item_wise_sales_history/item_wise_sales_history.py
index 091c20c..e10df2a 100644
--- a/erpnext/selling/report/item_wise_sales_history/item_wise_sales_history.py
+++ b/erpnext/selling/report/item_wise_sales_history/item_wise_sales_history.py
@@ -238,4 +238,5 @@
"datasets": [{"name": _("Total Sales Amount"), "values": datapoints[:30]}],
},
"type": "bar",
+ "fieldtype": "Currency",
}
diff --git a/erpnext/selling/report/quotation_trends/quotation_trends.py b/erpnext/selling/report/quotation_trends/quotation_trends.py
index 4e0758d..4d71ce7 100644
--- a/erpnext/selling/report/quotation_trends/quotation_trends.py
+++ b/erpnext/selling/report/quotation_trends/quotation_trends.py
@@ -54,4 +54,5 @@
},
"type": "line",
"lineOptions": {"regionFill": 1},
+ "fieldtype": "Currency",
}
diff --git a/erpnext/selling/report/sales_analytics/sales_analytics.py b/erpnext/selling/report/sales_analytics/sales_analytics.py
index 1a2476a..9d7d806 100644
--- a/erpnext/selling/report/sales_analytics/sales_analytics.py
+++ b/erpnext/selling/report/sales_analytics/sales_analytics.py
@@ -415,3 +415,8 @@
else:
labels = [d.get("label") for d in self.columns[1 : length - 1]]
self.chart = {"data": {"labels": labels, "datasets": []}, "type": "line"}
+
+ if self.filters["value_quantity"] == "Value":
+ self.chart["fieldtype"] = "Currency"
+ else:
+ self.chart["fieldtype"] = "Float"
diff --git a/erpnext/selling/report/sales_order_trends/sales_order_trends.py b/erpnext/selling/report/sales_order_trends/sales_order_trends.py
index 719f1c5..18f448c 100644
--- a/erpnext/selling/report/sales_order_trends/sales_order_trends.py
+++ b/erpnext/selling/report/sales_order_trends/sales_order_trends.py
@@ -51,4 +51,5 @@
},
"type": "line",
"lineOptions": {"regionFill": 1},
+ "fieldtype": "Currency",
}
diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py
index f97e7ca..0738bfb 100644
--- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py
+++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py
@@ -962,6 +962,44 @@
automatically_fetch_payment_terms(enable=0)
+ def test_returned_qty_in_return_dn(self):
+ # SO ---> SI ---> DN
+ # |
+ # |---> DN(Partial Sales Return) ---> SI(Credit Note)
+ # |
+ # |---> DN(Partial Sales Return) ---> SI(Credit Note)
+
+ from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_delivery_note
+ from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
+
+ so = make_sales_order(qty=10)
+ si = make_sales_invoice(so.name)
+ si.insert()
+ si.submit()
+ dn = make_delivery_note(si.name)
+ dn.insert()
+ dn.submit()
+ self.assertEqual(dn.items[0].returned_qty, 0)
+ self.assertEqual(dn.per_billed, 100)
+
+ from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_invoice
+
+ dn1 = create_delivery_note(is_return=1, return_against=dn.name, qty=-3)
+ si1 = make_sales_invoice(dn1.name)
+ si1.insert()
+ si1.submit()
+ dn1.reload()
+ self.assertEqual(dn1.items[0].returned_qty, 0)
+ self.assertEqual(dn1.per_billed, 100)
+
+ dn2 = create_delivery_note(is_return=1, return_against=dn.name, qty=-4)
+ si2 = make_sales_invoice(dn2.name)
+ si2.insert()
+ si2.submit()
+ dn2.reload()
+ self.assertEqual(dn2.items[0].returned_qty, 0)
+ self.assertEqual(dn2.per_billed, 100)
+
def create_delivery_note(**args):
dn = frappe.new_doc("Delivery Note")
diff --git a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json
index e2eb2a4..2d7abc8 100644
--- a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json
+++ b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json
@@ -737,7 +737,9 @@
"depends_on": "returned_qty",
"fieldname": "returned_qty",
"fieldtype": "Float",
- "label": "Returned Qty in Stock UOM"
+ "label": "Returned Qty in Stock UOM",
+ "no_copy": 1,
+ "read_only": 1
},
{
"fieldname": "incoming_rate",
@@ -778,7 +780,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2022-03-31 18:36:24.671913",
+ "modified": "2022-05-02 12:09:39.610075",
"modified_by": "Administrator",
"module": "Stock",
"name": "Delivery Note Item",
diff --git a/erpnext/stock/doctype/item_alternative/test_item_alternative.py b/erpnext/stock/doctype/item_alternative/test_item_alternative.py
index 32c58c5..b8f4803 100644
--- a/erpnext/stock/doctype/item_alternative/test_item_alternative.py
+++ b/erpnext/stock/doctype/item_alternative/test_item_alternative.py
@@ -16,6 +16,9 @@
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
from erpnext.manufacturing.doctype.work_order.work_order import make_stock_entry
from erpnext.stock.doctype.item.test_item import create_item
+from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import (
+ EmptyStockReconciliationItemsError,
+)
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
create_stock_reconciliation,
)
@@ -180,9 +183,12 @@
if not frappe.db.exists("Item", item_code):
create_item(item_code)
- create_stock_reconciliation(
- item_code="Test FG A RW 1", warehouse="_Test Warehouse - _TC", qty=10, rate=2000
- )
+ try:
+ create_stock_reconciliation(
+ item_code="Test FG A RW 1", warehouse="_Test Warehouse - _TC", qty=10, rate=2000
+ )
+ except EmptyStockReconciliationItemsError:
+ pass
if frappe.db.exists("Item", "Test FG A RW 1"):
doc = frappe.get_doc("Item", "Test FG A RW 1")
diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py
index a70ff17..c998629 100644
--- a/erpnext/stock/doctype/material_request/material_request.py
+++ b/erpnext/stock/doctype/material_request/material_request.py
@@ -597,7 +597,7 @@
if source.material_request_type == "Customer Provided":
target.purpose = "Material Receipt"
- target.run_method("calculate_rate_and_amount")
+ target.set_missing_values()
target.set_stock_entry_type()
target.set_job_card_data()
diff --git a/erpnext/stock/doctype/pick_list/pick_list.js b/erpnext/stock/doctype/pick_list/pick_list.js
index 13b74b5..799406c 100644
--- a/erpnext/stock/doctype/pick_list/pick_list.js
+++ b/erpnext/stock/doctype/pick_list/pick_list.js
@@ -158,6 +158,19 @@
get_query_filters: get_query_filters
});
});
+ },
+ scan_barcode: (frm) => {
+ const opts = {
+ frm,
+ items_table_name: 'locations',
+ qty_field: 'picked_qty',
+ max_qty_field: 'qty',
+ dont_allow_new_row: true,
+ prompt_qty: frm.doc.prompt_qty,
+ serial_no_field: "not_supported", // doesn't make sense for picklist without a separate field.
+ };
+ const barcode_scanner = new erpnext.utils.BarcodeScanner(opts);
+ barcode_scanner.process_scan();
}
});
diff --git a/erpnext/stock/doctype/pick_list/pick_list.json b/erpnext/stock/doctype/pick_list/pick_list.json
index e984c08..ff20909 100644
--- a/erpnext/stock/doctype/pick_list/pick_list.json
+++ b/erpnext/stock/doctype/pick_list/pick_list.json
@@ -17,6 +17,11 @@
"parent_warehouse",
"get_item_locations",
"section_break_6",
+ "scan_barcode",
+ "column_break_13",
+ "scan_mode",
+ "prompt_qty",
+ "section_break_15",
"locations",
"amended_from",
"print_settings_section",
@@ -36,6 +41,7 @@
"fieldtype": "Column Break"
},
{
+ "depends_on": "eval:!doc.docstatus",
"fieldname": "section_break_6",
"fieldtype": "Section Break"
},
@@ -126,11 +132,38 @@
"fieldtype": "Check",
"label": "Group Same Items",
"print_hide": 1
+ },
+ {
+ "fieldname": "section_break_15",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "scan_barcode",
+ "fieldtype": "Data",
+ "label": "Scan Barcode",
+ "options": "Barcode"
+ },
+ {
+ "fieldname": "column_break_13",
+ "fieldtype": "Column Break"
+ },
+ {
+ "default": "0",
+ "description": "If checked, picked qty won't automatically be fulfilled on submit of pick list.",
+ "fieldname": "scan_mode",
+ "fieldtype": "Check",
+ "label": "Scan Mode"
+ },
+ {
+ "default": "0",
+ "fieldname": "prompt_qty",
+ "fieldtype": "Check",
+ "label": "Prompt Qty"
}
],
"is_submittable": 1,
"links": [],
- "modified": "2022-04-21 07:56:40.646473",
+ "modified": "2022-05-11 09:09:53.029312",
"modified_by": "Administrator",
"module": "Stock",
"name": "Pick List",
diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py
index 70d2f23..7dc3ba0 100644
--- a/erpnext/stock/doctype/pick_list/pick_list.py
+++ b/erpnext/stock/doctype/pick_list/pick_list.py
@@ -41,8 +41,15 @@
def before_submit(self):
update_sales_orders = set()
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:
+ if self.scan_mode and item.picked_qty < item.stock_qty:
+ frappe.throw(
+ _(
+ "Row {0} picked quantity is less than the required quantity, additional {1} {2} required."
+ ).format(item.idx, item.stock_qty - item.picked_qty, item.stock_uom),
+ title=_("Pick List Incomplete"),
+ )
+ elif not self.scan_mode and item.picked_qty == 0:
+ # if the user has not entered any picked qty, set it to stock_qty, before submit
item.picked_qty = item.stock_qty
if item.sales_order_item:
@@ -672,8 +679,7 @@
else:
stock_entry = update_stock_entry_items_with_no_reference(pick_list, stock_entry)
- stock_entry.set_actual_qty()
- stock_entry.calculate_rate_and_amount()
+ stock_entry.set_missing_values()
return stock_entry.as_dict()
diff --git a/erpnext/stock/doctype/pick_list_item/pick_list_item.json b/erpnext/stock/doctype/pick_list_item/pick_list_item.json
index a96ebfc..a6f8c0d 100644
--- a/erpnext/stock/doctype/pick_list_item/pick_list_item.json
+++ b/erpnext/stock/doctype/pick_list_item/pick_list_item.json
@@ -202,4 +202,4 @@
"sort_order": "DESC",
"states": [],
"track_changes": 1
-}
\ No newline at end of file
+}
diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
index ec0e809..fcf0cd1 100644
--- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
+++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
@@ -1036,6 +1036,7 @@
def set_missing_values(source, target):
target.stock_entry_type = "Material Transfer"
target.purpose = "Material Transfer"
+ target.set_missing_values()
doclist = get_mapped_doc(
"Purchase Receipt",
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js
index 1df56ef..540ad18 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry.js
+++ b/erpnext/stock/doctype/stock_entry/stock_entry.js
@@ -470,7 +470,9 @@
},
callback: function(r) {
if (!r.exc) {
- $.extend(child, r.message);
+ ["actual_qty", "basic_rate"].forEach((field) => {
+ frappe.model.set_value(cdt, cdn, field, (r.message[field] || 0.0));
+ });
frm.events.calculate_basic_amount(frm, child);
}
}
@@ -1057,8 +1059,8 @@
function check_should_not_attach_bom_items(bom_no) {
return (
- bom_no === undefined ||
- (erpnext.stock.bom && erpnext.stock.bom.name === bom_no)
+ bom_no === undefined ||
+ (erpnext.stock.bom && erpnext.stock.bom.name === bom_no)
);
}
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py
index 27a6eaf..2a7354d 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry.py
+++ b/erpnext/stock/doctype/stock_entry/stock_entry.py
@@ -2197,6 +2197,12 @@
return sorted(list(set(get_serial_nos(self.pro_doc.serial_no)) - set(used_serial_nos)))
+ def set_missing_values(self):
+ "Updates rate and availability of all the items of mapped doc."
+ self.set_transfer_qty()
+ self.set_actual_qty()
+ self.calculate_rate_and_amount()
+
@frappe.whitelist()
def move_sample_to_retention_warehouse(company, items):
@@ -2246,6 +2252,7 @@
def make_stock_in_entry(source_name, target_doc=None):
def set_missing_values(source, target):
target.set_stock_entry_type()
+ target.set_missing_values()
def update_item(source_doc, target_doc, source_parent):
target_doc.t_warehouse = ""
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry_utils.py b/erpnext/stock/doctype/stock_entry/stock_entry_utils.py
index b3df728..c5c0cef 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry_utils.py
+++ b/erpnext/stock/doctype/stock_entry/stock_entry_utils.py
@@ -132,6 +132,7 @@
)
s.set_stock_entry_type()
+
if not args.do_not_save:
s.insert()
if not args.do_not_submit:
diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py
index 3ccd342..71baf9f 100644
--- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py
+++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py
@@ -652,6 +652,104 @@
serial_no = get_serial_nos(se.get("items")[0].serial_no)[0]
self.assertFalse(frappe.db.get_value("Serial No", serial_no, "warehouse"))
+ def test_serial_batch_item_stock_entry(self):
+ """
+ Behaviour: 1) Submit Stock Entry (Receipt) with Serial & Batched Item
+ 2) Cancel same Stock Entry
+ Expected Result: 1) Batch is created with Reference in Serial No
+ 2) Batch is deleted and Serial No is Inactive
+ """
+ from erpnext.stock.doctype.batch.batch import get_batch_qty
+
+ item = frappe.db.exists("Item", {"item_name": "Batched and Serialised Item"})
+ if not item:
+ item = create_item("Batched and Serialised Item")
+ item.has_batch_no = 1
+ item.create_new_batch = 1
+ item.has_serial_no = 1
+ item.batch_number_series = "B-BATCH-.##"
+ item.serial_no_series = "S-.####"
+ item.save()
+ else:
+ item = frappe.get_doc("Item", {"item_name": "Batched and Serialised Item"})
+
+ se = make_stock_entry(
+ item_code=item.item_code, target="_Test Warehouse - _TC", qty=1, basic_rate=100
+ )
+ batch_no = se.items[0].batch_no
+ serial_no = get_serial_nos(se.items[0].serial_no)[0]
+ batch_qty = get_batch_qty(batch_no, "_Test Warehouse - _TC", item.item_code)
+
+ batch_in_serial_no = frappe.db.get_value("Serial No", serial_no, "batch_no")
+ self.assertEqual(batch_in_serial_no, batch_no)
+
+ self.assertEqual(batch_qty, 1)
+
+ se.cancel()
+
+ batch_in_serial_no = frappe.db.get_value("Serial No", serial_no, "batch_no")
+ self.assertEqual(batch_in_serial_no, None)
+
+ self.assertEqual(frappe.db.get_value("Serial No", serial_no, "status"), "Inactive")
+ self.assertEqual(frappe.db.exists("Batch", batch_no), None)
+
+ def test_serial_batch_item_qty_deduction(self):
+ """
+ Behaviour: Create 2 Stock Entries, both adding Serial Nos to same batch
+ Expected: 1) Cancelling first Stock Entry (origin transaction of created batch)
+ should throw a LinkExistsError
+ 2) Cancelling second Stock Entry should make Serial Nos that are, linked to mentioned batch
+ and in that transaction only, Inactive.
+ """
+ from erpnext.stock.doctype.batch.batch import get_batch_qty
+
+ item = frappe.db.exists("Item", {"item_name": "Batched and Serialised Item"})
+ if not item:
+ item = create_item("Batched and Serialised Item")
+ item.has_batch_no = 1
+ item.create_new_batch = 1
+ item.has_serial_no = 1
+ item.batch_number_series = "B-BATCH-.##"
+ item.serial_no_series = "S-.####"
+ item.save()
+ else:
+ item = frappe.get_doc("Item", {"item_name": "Batched and Serialised Item"})
+
+ se1 = make_stock_entry(
+ item_code=item.item_code, target="_Test Warehouse - _TC", qty=1, basic_rate=100
+ )
+ batch_no = se1.items[0].batch_no
+ serial_no1 = get_serial_nos(se1.items[0].serial_no)[0]
+
+ # Check Source (Origin) Document of Batch
+ self.assertEqual(frappe.db.get_value("Batch", batch_no, "reference_name"), se1.name)
+
+ se2 = make_stock_entry(
+ item_code=item.item_code,
+ target="_Test Warehouse - _TC",
+ qty=1,
+ basic_rate=100,
+ batch_no=batch_no,
+ )
+ serial_no2 = get_serial_nos(se2.items[0].serial_no)[0]
+
+ batch_qty = get_batch_qty(batch_no, "_Test Warehouse - _TC", item.item_code)
+ self.assertEqual(batch_qty, 2)
+
+ se2.cancel()
+
+ # Check decrease in Batch Qty
+ batch_qty = get_batch_qty(batch_no, "_Test Warehouse - _TC", item.item_code)
+ self.assertEqual(batch_qty, 1)
+
+ # Check if Serial No from Stock Entry 1 is intact
+ self.assertEqual(frappe.db.get_value("Serial No", serial_no1, "batch_no"), batch_no)
+ self.assertEqual(frappe.db.get_value("Serial No", serial_no1, "status"), "Active")
+
+ # Check if Serial No from Stock Entry 2 is Unlinked and Inactive
+ self.assertEqual(frappe.db.get_value("Serial No", serial_no2, "batch_no"), None)
+ self.assertEqual(frappe.db.get_value("Serial No", serial_no2, "status"), "Inactive")
+
def test_warehouse_company_validation(self):
company = frappe.db.get_value("Warehouse", "_Test Warehouse 2 - _TC1", "company")
frappe.get_doc("User", "test2@example.com").add_roles(
@@ -1326,6 +1424,25 @@
self.assertRaises(frappe.ValidationError, se.save)
+ def test_mapped_stock_entry(self):
+ "Check if rate and stock details are populated in mapped SE given warehouse."
+ from erpnext.stock.doctype.purchase_receipt.purchase_receipt import make_stock_entry
+ from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
+
+ item_code = "_TestMappedItem"
+ create_item(item_code, is_stock_item=True)
+
+ pr = make_purchase_receipt(
+ item_code=item_code, qty=2, rate=100, company="_Test Company", warehouse="Stores - _TC"
+ )
+
+ mapped_se = make_stock_entry(pr.name)
+
+ self.assertEqual(mapped_se.items[0].s_warehouse, "Stores - _TC")
+ self.assertEqual(mapped_se.items[0].actual_qty, 2)
+ self.assertEqual(mapped_se.items[0].basic_rate, 100)
+ self.assertEqual(mapped_se.items[0].basic_amount, 200)
+
def make_serialized_item(**args):
args = frappe._dict(args)
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 5850ec7..4e2fc83 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
@@ -1183,6 +1183,42 @@
backdated.cancel()
self.assertEqual([1], ordered_qty_after_transaction())
+ def test_timestamp_clash(self):
+
+ item = make_item().name
+ warehouse = "_Test Warehouse - _TC"
+
+ reciept = make_stock_entry(
+ item_code=item,
+ to_warehouse=warehouse,
+ qty=100,
+ rate=10,
+ posting_date="2021-01-01",
+ posting_time="01:00:00",
+ )
+
+ consumption = make_stock_entry(
+ item_code=item,
+ from_warehouse=warehouse,
+ qty=50,
+ posting_date="2021-01-01",
+ posting_time="02:00:00.1234", # ms are possible when submitted without editing posting time
+ )
+
+ backdated_receipt = make_stock_entry(
+ item_code=item,
+ to_warehouse=warehouse,
+ qty=100,
+ posting_date="2021-01-01",
+ rate=10,
+ posting_time="02:00:00", # same posting time as consumption but ms part stripped
+ )
+
+ try:
+ backdated_receipt.cancel()
+ except Exception as e:
+ self.fail("Double processing of qty for clashing timestamp.")
+
def create_repack_entry(**args):
args = frappe._dict(args)
diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.json b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.json
index e545b8e..9a85431 100644
--- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.json
+++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.json
@@ -170,6 +170,7 @@
"options": "Warehouse"
},
{
+ "depends_on": "eval:!doc.docstatus",
"fieldname": "section_break_22",
"fieldtype": "Section Break"
},
@@ -182,7 +183,7 @@
"idx": 1,
"is_submittable": 1,
"links": [],
- "modified": "2022-03-27 08:57:47.161959",
+ "modified": "2022-05-11 09:10:26.327652",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Reconciliation",
diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
index 5d5a27f..bd60cf0 100644
--- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
+++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
@@ -62,6 +62,7 @@
self.make_sle_on_cancel()
self.make_gl_entries_on_cancel()
self.repost_future_sle_and_gle()
+ self.delete_auto_created_batches()
def remove_items_with_no_change(self):
"""Remove items if qty or rate is not changed"""
@@ -456,7 +457,7 @@
key = (d.item_code, d.warehouse)
if key not in merge_similar_entries:
- d.total_amount = d.actual_qty * d.valuation_rate
+ d.total_amount = flt(d.actual_qty) * d.valuation_rate
merge_similar_entries[key] = d
elif d.serial_no:
data = merge_similar_entries[key]
diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py
index 1e59aae..9088eb8 100644
--- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py
+++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py
@@ -31,6 +31,7 @@
def tearDown(self):
frappe.local.future_sle = {}
+ frappe.flags.pop("dont_execute_stock_reposts", None)
def test_reco_for_fifo(self):
self._test_reco_sle_gle("FIFO")
@@ -250,7 +251,7 @@
warehouse = "_Test Warehouse for Stock Reco2 - _TC"
sr = create_stock_reconciliation(
- item_code=item_code, warehouse=warehouse, qty=5, rate=200, do_not_submit=1
+ item_code=item_code, warehouse=warehouse, qty=5, rate=200, do_not_save=1
)
sr.save()
sr.submit()
@@ -288,6 +289,84 @@
stock_doc = frappe.get_doc("Stock Reconciliation", d)
stock_doc.cancel()
+ def test_stock_reco_for_serial_and_batch_item(self):
+ item = create_item("_TestBatchSerialItemReco")
+ item.has_batch_no = 1
+ item.create_new_batch = 1
+ item.has_serial_no = 1
+ item.batch_number_series = "TBS-BATCH-.##"
+ item.serial_no_series = "TBS-.####"
+ item.save()
+
+ warehouse = "_Test Warehouse for Stock Reco2 - _TC"
+
+ sr = create_stock_reconciliation(item_code=item.item_code, warehouse=warehouse, qty=1, rate=100)
+
+ batch_no = sr.items[0].batch_no
+
+ serial_nos = get_serial_nos(sr.items[0].serial_no)
+ self.assertEqual(len(serial_nos), 1)
+ self.assertEqual(frappe.db.get_value("Serial No", serial_nos[0], "batch_no"), batch_no)
+
+ sr.cancel()
+
+ self.assertEqual(frappe.db.get_value("Serial No", serial_nos[0], "status"), "Inactive")
+ self.assertEqual(frappe.db.exists("Batch", batch_no), None)
+
+ def test_stock_reco_for_serial_and_batch_item_with_future_dependent_entry(self):
+ """
+ Behaviour: 1) Create Stock Reconciliation, which will be the origin document
+ of a new batch having a serial no
+ 2) Create a Stock Entry that adds a serial no to the same batch following this
+ Stock Reconciliation
+ 3) Cancel Stock Entry
+ Expected Result: 3) Serial No only in the Stock Entry is Inactive and Batch qty decreases
+ """
+ from erpnext.stock.doctype.batch.batch import get_batch_qty
+ from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
+
+ item = create_item("_TestBatchSerialItemDependentReco")
+ item.has_batch_no = 1
+ item.create_new_batch = 1
+ item.has_serial_no = 1
+ item.batch_number_series = "TBSD-BATCH-.##"
+ item.serial_no_series = "TBSD-.####"
+ item.save()
+
+ warehouse = "_Test Warehouse for Stock Reco2 - _TC"
+
+ stock_reco = create_stock_reconciliation(
+ item_code=item.item_code, warehouse=warehouse, qty=1, rate=100
+ )
+ batch_no = stock_reco.items[0].batch_no
+ reco_serial_no = get_serial_nos(stock_reco.items[0].serial_no)[0]
+
+ stock_entry = make_stock_entry(
+ item_code=item.item_code, target=warehouse, qty=1, basic_rate=100, batch_no=batch_no
+ )
+ serial_no_2 = get_serial_nos(stock_entry.items[0].serial_no)[0]
+
+ # Check Batch qty after 2 transactions
+ batch_qty = get_batch_qty(batch_no, warehouse, item.item_code)
+ self.assertEqual(batch_qty, 2)
+
+ # Cancel latest stock document
+ stock_entry.cancel()
+
+ # Check Batch qty after cancellation
+ batch_qty = get_batch_qty(batch_no, warehouse, item.item_code)
+ self.assertEqual(batch_qty, 1)
+
+ # Check if Serial No from Stock Reconcilation is intact
+ self.assertEqual(frappe.db.get_value("Serial No", reco_serial_no, "batch_no"), batch_no)
+ self.assertEqual(frappe.db.get_value("Serial No", reco_serial_no, "status"), "Active")
+
+ # Check if Serial No from Stock Entry is Unlinked and Inactive
+ self.assertEqual(frappe.db.get_value("Serial No", serial_no_2, "batch_no"), None)
+ self.assertEqual(frappe.db.get_value("Serial No", serial_no_2, "status"), "Inactive")
+
+ stock_reco.cancel()
+
def test_customer_provided_items(self):
item_code = "Stock-Reco-customer-Item-100"
create_item(
@@ -306,6 +385,7 @@
-------------------------------------------
Var | Doc | Qty | Balance
-------------------------------------------
+ PR5 | PR | 10 | 10 (posting date: today-4) [backdated]
SR5 | Reco | 0 | 8 (posting date: today-4) [backdated]
PR1 | PR | 10 | 18 (posting date: today-3)
PR2 | PR | 1 | 19 (posting date: today-2)
@@ -315,6 +395,14 @@
item_code = make_item().name
warehouse = "_Test Warehouse - _TC"
+ frappe.flags.dont_execute_stock_reposts = True
+
+ def assertBalance(doc, qty_after_transaction):
+ sle_balance = frappe.db.get_value(
+ "Stock Ledger Entry", {"voucher_no": doc.name, "is_cancelled": 0}, "qty_after_transaction"
+ )
+ self.assertEqual(sle_balance, qty_after_transaction)
+
pr1 = make_purchase_receipt(
item_code=item_code, warehouse=warehouse, qty=10, rate=100, posting_date=add_days(nowdate(), -3)
)
@@ -324,62 +412,37 @@
pr3 = make_purchase_receipt(
item_code=item_code, warehouse=warehouse, qty=1, rate=100, posting_date=nowdate()
)
-
- pr1_balance = frappe.db.get_value(
- "Stock Ledger Entry", {"voucher_no": pr1.name, "is_cancelled": 0}, "qty_after_transaction"
- )
- pr3_balance = frappe.db.get_value(
- "Stock Ledger Entry", {"voucher_no": pr3.name, "is_cancelled": 0}, "qty_after_transaction"
- )
- self.assertEqual(pr1_balance, 10)
- self.assertEqual(pr3_balance, 12)
+ assertBalance(pr1, 10)
+ assertBalance(pr3, 12)
# post backdated stock reco in between
sr4 = create_stock_reconciliation(
item_code=item_code, warehouse=warehouse, qty=6, rate=100, posting_date=add_days(nowdate(), -1)
)
- pr3_balance = frappe.db.get_value(
- "Stock Ledger Entry", {"voucher_no": pr3.name, "is_cancelled": 0}, "qty_after_transaction"
- )
- self.assertEqual(pr3_balance, 7)
+ assertBalance(pr3, 7)
# post backdated stock reco at the start
sr5 = create_stock_reconciliation(
item_code=item_code, warehouse=warehouse, qty=8, rate=100, posting_date=add_days(nowdate(), -4)
)
- pr1_balance = frappe.db.get_value(
- "Stock Ledger Entry", {"voucher_no": pr1.name, "is_cancelled": 0}, "qty_after_transaction"
+ assertBalance(pr1, 18)
+ assertBalance(pr2, 19)
+ assertBalance(sr4, 6) # check if future stock reco is unaffected
+
+ # Make a backdated receipt and check only entries till first SR are affected
+ pr5 = make_purchase_receipt(
+ item_code=item_code, warehouse=warehouse, qty=10, rate=100, posting_date=add_days(nowdate(), -5)
)
- pr2_balance = frappe.db.get_value(
- "Stock Ledger Entry", {"voucher_no": pr2.name, "is_cancelled": 0}, "qty_after_transaction"
- )
- sr4_balance = frappe.db.get_value(
- "Stock Ledger Entry", {"voucher_no": sr4.name, "is_cancelled": 0}, "qty_after_transaction"
- )
- self.assertEqual(pr1_balance, 18)
- self.assertEqual(pr2_balance, 19)
- self.assertEqual(sr4_balance, 6) # check if future stock reco is unaffected
+ assertBalance(pr5, 10)
+ # check if future stock reco is unaffected
+ assertBalance(sr4, 6)
+ assertBalance(sr5, 8)
# cancel backdated stock reco and check future impact
sr5.cancel()
- pr1_balance = frappe.db.get_value(
- "Stock Ledger Entry", {"voucher_no": pr1.name, "is_cancelled": 0}, "qty_after_transaction"
- )
- pr2_balance = frappe.db.get_value(
- "Stock Ledger Entry", {"voucher_no": pr2.name, "is_cancelled": 0}, "qty_after_transaction"
- )
- sr4_balance = frappe.db.get_value(
- "Stock Ledger Entry", {"voucher_no": sr4.name, "is_cancelled": 0}, "qty_after_transaction"
- )
- self.assertEqual(pr1_balance, 10)
- self.assertEqual(pr2_balance, 11)
- self.assertEqual(sr4_balance, 6) # check if future stock reco is unaffected
-
- # teardown
- sr4.cancel()
- pr3.cancel()
- pr2.cancel()
- pr1.cancel()
+ assertBalance(pr1, 10)
+ assertBalance(pr2, 11)
+ assertBalance(sr4, 6) # check if future stock reco is unaffected
@change_settings("Stock Settings", {"allow_negative_stock": 0})
def test_backdated_stock_reco_future_negative_stock(self):
@@ -485,7 +548,6 @@
# repost will make this test useless, qty should update in realtime without reposts
frappe.flags.dont_execute_stock_reposts = True
- self.addCleanup(frappe.flags.pop, "dont_execute_stock_reposts")
item_code = make_item().name
warehouse = "_Test Warehouse - _TC"
@@ -684,11 +746,13 @@
},
)
- try:
- if not args.do_not_submit:
- sr.submit()
- except EmptyStockReconciliationItemsError:
- pass
+ if not args.do_not_save:
+ sr.insert()
+ try:
+ if not args.do_not_submit:
+ sr.submit()
+ except EmptyStockReconciliationItemsError:
+ pass
return sr
diff --git a/erpnext/stock/report/fifo_queue_vs_qty_after_transaction_comparison/__init__.py b/erpnext/stock/report/fifo_queue_vs_qty_after_transaction_comparison/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/erpnext/stock/report/fifo_queue_vs_qty_after_transaction_comparison/__init__.py
diff --git a/erpnext/stock/report/fifo_queue_vs_qty_after_transaction_comparison/fifo_queue_vs_qty_after_transaction_comparison.js b/erpnext/stock/report/fifo_queue_vs_qty_after_transaction_comparison/fifo_queue_vs_qty_after_transaction_comparison.js
new file mode 100644
index 0000000..0b8f496
--- /dev/null
+++ b/erpnext/stock/report/fifo_queue_vs_qty_after_transaction_comparison/fifo_queue_vs_qty_after_transaction_comparison.js
@@ -0,0 +1,53 @@
+// Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+/* eslint-disable */
+
+const DIFFERNCE_FIELD_NAMES = [
+ "fifo_qty_diff",
+ "fifo_value_diff",
+];
+
+frappe.query_reports["FIFO Queue vs Qty After Transaction Comparison"] = {
+ "filters": [
+ {
+ "fieldname": "item_code",
+ "fieldtype": "Link",
+ "label": "Item",
+ "options": "Item",
+ get_query: function() {
+ return {
+ filters: {is_stock_item: 1, has_serial_no: 0}
+ }
+ }
+ },
+ {
+ "fieldname": "item_group",
+ "fieldtype": "Link",
+ "label": "Item Group",
+ "options": "Item Group",
+ },
+ {
+ "fieldname": "warehouse",
+ "fieldtype": "Link",
+ "label": "Warehouse",
+ "options": "Warehouse",
+ },
+ {
+ "fieldname": "from_date",
+ "fieldtype": "Date",
+ "label": "From Posting Date",
+ },
+ {
+ "fieldname": "to_date",
+ "fieldtype": "Date",
+ "label": "From Posting Date",
+ }
+ ],
+ formatter (value, row, column, data, default_formatter) {
+ value = default_formatter(value, row, column, data);
+ if (DIFFERNCE_FIELD_NAMES.includes(column.fieldname) && Math.abs(data[column.fieldname]) > 0.001) {
+ value = "<span style='color:red'>" + value + "</span>";
+ }
+ return value;
+ },
+};
diff --git a/erpnext/stock/report/fifo_queue_vs_qty_after_transaction_comparison/fifo_queue_vs_qty_after_transaction_comparison.json b/erpnext/stock/report/fifo_queue_vs_qty_after_transaction_comparison/fifo_queue_vs_qty_after_transaction_comparison.json
new file mode 100644
index 0000000..5e958aa
--- /dev/null
+++ b/erpnext/stock/report/fifo_queue_vs_qty_after_transaction_comparison/fifo_queue_vs_qty_after_transaction_comparison.json
@@ -0,0 +1,27 @@
+{
+ "add_total_row": 0,
+ "columns": [],
+ "creation": "2022-05-11 04:09:13.460652",
+ "disable_prepared_report": 0,
+ "disabled": 0,
+ "docstatus": 0,
+ "doctype": "Report",
+ "filters": [],
+ "idx": 0,
+ "is_standard": "Yes",
+ "letter_head": "abc",
+ "modified": "2022-05-11 04:09:20.232177",
+ "modified_by": "Administrator",
+ "module": "Stock",
+ "name": "FIFO Queue vs Qty After Transaction Comparison",
+ "owner": "Administrator",
+ "prepared_report": 0,
+ "ref_doctype": "Stock Ledger Entry",
+ "report_name": "FIFO Queue vs Qty After Transaction Comparison",
+ "report_type": "Script Report",
+ "roles": [
+ {
+ "role": "Administrator"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/erpnext/stock/report/fifo_queue_vs_qty_after_transaction_comparison/fifo_queue_vs_qty_after_transaction_comparison.py b/erpnext/stock/report/fifo_queue_vs_qty_after_transaction_comparison/fifo_queue_vs_qty_after_transaction_comparison.py
new file mode 100644
index 0000000..9e14033
--- /dev/null
+++ b/erpnext/stock/report/fifo_queue_vs_qty_after_transaction_comparison/fifo_queue_vs_qty_after_transaction_comparison.py
@@ -0,0 +1,212 @@
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+import json
+
+import frappe
+from frappe import _
+from frappe.utils import flt
+from frappe.utils.nestedset import get_descendants_of
+
+SLE_FIELDS = (
+ "name",
+ "item_code",
+ "warehouse",
+ "posting_date",
+ "posting_time",
+ "creation",
+ "voucher_type",
+ "voucher_no",
+ "actual_qty",
+ "qty_after_transaction",
+ "stock_queue",
+ "batch_no",
+ "stock_value",
+ "valuation_rate",
+)
+
+
+def execute(filters=None):
+ columns = get_columns()
+ data = get_data(filters)
+ return columns, data
+
+
+def get_data(filters):
+ if not any([filters.warehouse, filters.item_code, filters.item_group]):
+ frappe.throw(_("Any one of following filters required: warehouse, Item Code, Item Group"))
+ sles = get_stock_ledger_entries(filters)
+ return find_first_bad_queue(sles)
+
+
+def get_stock_ledger_entries(filters):
+
+ sle_filters = {"is_cancelled": 0}
+
+ if filters.warehouse:
+ children = get_descendants_of("Warehouse", filters.warehouse)
+ sle_filters["warehouse"] = ("in", children + [filters.warehouse])
+
+ if filters.item_code:
+ sle_filters["item_code"] = filters.item_code
+ elif filters.get("item_group"):
+ item_group = filters.get("item_group")
+ children = get_descendants_of("Item Group", item_group)
+ item_group_filter = {"item_group": ("in", children + [item_group])}
+ sle_filters["item_code"] = (
+ "in",
+ frappe.get_all("Item", filters=item_group_filter, pluck="name", order_by=None),
+ )
+
+ if filters.from_date:
+ sle_filters["posting_date"] = (">=", filters.from_date)
+ if filters.to_date:
+ sle_filters["posting_date"] = ("<=", filters.to_date)
+
+ return frappe.get_all(
+ "Stock Ledger Entry",
+ fields=SLE_FIELDS,
+ filters=sle_filters,
+ order_by="timestamp(posting_date, posting_time), creation",
+ )
+
+
+def find_first_bad_queue(sles):
+ item_warehouse_sles = {}
+ for sle in sles:
+ item_warehouse_sles.setdefault((sle.item_code, sle.warehouse), []).append(sle)
+
+ data = []
+
+ for _item_wh, sles in item_warehouse_sles.items():
+ for idx, sle in enumerate(sles):
+ queue = json.loads(sle.stock_queue or "[]")
+
+ sle.fifo_queue_qty = 0.0
+ sle.fifo_stock_value = 0.0
+ for qty, rate in queue:
+ sle.fifo_queue_qty += flt(qty)
+ sle.fifo_stock_value += flt(qty) * flt(rate)
+
+ sle.fifo_qty_diff = sle.qty_after_transaction - sle.fifo_queue_qty
+ sle.fifo_value_diff = sle.stock_value - sle.fifo_stock_value
+
+ if sle.batch_no:
+ sle.use_batchwise_valuation = frappe.db.get_value(
+ "Batch", sle.batch_no, "use_batchwise_valuation", cache=True
+ )
+
+ if abs(sle.fifo_qty_diff) > 0.001 or abs(sle.fifo_value_diff) > 0.1:
+ if idx:
+ data.append(sles[idx - 1])
+ data.append(sle)
+ data.append({})
+ break
+
+ return data
+
+
+def get_columns():
+ return [
+ {
+ "fieldname": "name",
+ "fieldtype": "Link",
+ "label": _("Stock Ledger Entry"),
+ "options": "Stock Ledger Entry",
+ },
+ {
+ "fieldname": "item_code",
+ "fieldtype": "Link",
+ "label": _("Item Code"),
+ "options": "Item",
+ },
+ {
+ "fieldname": "warehouse",
+ "fieldtype": "Link",
+ "label": _("Warehouse"),
+ "options": "Warehouse",
+ },
+ {
+ "fieldname": "posting_date",
+ "fieldtype": "Data",
+ "label": _("Posting Date"),
+ },
+ {
+ "fieldname": "posting_time",
+ "fieldtype": "Data",
+ "label": _("Posting Time"),
+ },
+ {
+ "fieldname": "creation",
+ "fieldtype": "Data",
+ "label": _("Creation"),
+ },
+ {
+ "fieldname": "voucher_type",
+ "fieldtype": "Link",
+ "label": _("Voucher Type"),
+ "options": "DocType",
+ },
+ {
+ "fieldname": "voucher_no",
+ "fieldtype": "Dynamic Link",
+ "label": _("Voucher No"),
+ "options": "voucher_type",
+ },
+ {
+ "fieldname": "batch_no",
+ "fieldtype": "Link",
+ "label": _("Batch"),
+ "options": "Batch",
+ },
+ {
+ "fieldname": "use_batchwise_valuation",
+ "fieldtype": "Check",
+ "label": _("Batchwise Valuation"),
+ },
+ {
+ "fieldname": "actual_qty",
+ "fieldtype": "Float",
+ "label": _("Qty Change"),
+ },
+ {
+ "fieldname": "qty_after_transaction",
+ "fieldtype": "Float",
+ "label": _("(A) Qty After Transaction"),
+ },
+ {
+ "fieldname": "stock_queue",
+ "fieldtype": "Data",
+ "label": _("FIFO/LIFO Queue"),
+ },
+ {
+ "fieldname": "fifo_queue_qty",
+ "fieldtype": "Float",
+ "label": _("(C) Total qty in queue"),
+ },
+ {
+ "fieldname": "fifo_qty_diff",
+ "fieldtype": "Float",
+ "label": _("A - C"),
+ },
+ {
+ "fieldname": "stock_value",
+ "fieldtype": "Float",
+ "label": _("(D) Balance Stock Value"),
+ },
+ {
+ "fieldname": "fifo_stock_value",
+ "fieldtype": "Float",
+ "label": _("(E) Balance Stock Value in Queue"),
+ },
+ {
+ "fieldname": "fifo_value_diff",
+ "fieldtype": "Float",
+ "label": _("D - E"),
+ },
+ {
+ "fieldname": "valuation_rate",
+ "fieldtype": "Float",
+ "label": _("(H) Valuation Rate"),
+ },
+ ]
diff --git a/erpnext/stock/report/stock_analytics/stock_analytics.py b/erpnext/stock/report/stock_analytics/stock_analytics.py
index da0776b..89ca9d9 100644
--- a/erpnext/stock/report/stock_analytics/stock_analytics.py
+++ b/erpnext/stock/report/stock_analytics/stock_analytics.py
@@ -1,6 +1,7 @@
# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import datetime
+from typing import List
import frappe
from frappe import _, scrub
@@ -148,18 +149,26 @@
- Warehouse A : bal_qty/value
- Warehouse B : bal_qty/value
"""
+
+ expected_ranges = get_period_date_ranges(filters)
+ expected_periods = []
+ for _start_date, end_date in expected_ranges:
+ expected_periods.append(get_period(end_date, filters))
+
periodic_data = {}
for d in entry:
period = get_period(d.posting_date, filters)
bal_qty = 0
+ fill_intermediate_periods(periodic_data, d.item_code, period, expected_periods)
+
# if period against item does not exist yet, instantiate it
# insert existing balance dict against period, and add/subtract to it
if periodic_data.get(d.item_code) and not periodic_data.get(d.item_code).get(period):
previous_balance = periodic_data[d.item_code]["balance"].copy()
periodic_data[d.item_code][period] = previous_balance
- if d.voucher_type == "Stock Reconciliation":
+ if d.voucher_type == "Stock Reconciliation" and not d.batch_no:
if periodic_data.get(d.item_code) and periodic_data.get(d.item_code).get("balance").get(
d.warehouse
):
@@ -186,6 +195,36 @@
return periodic_data
+def fill_intermediate_periods(
+ periodic_data, item_code: str, current_period: str, all_periods: List[str]
+) -> None:
+ """There might be intermediate periods where no stock ledger entry exists, copy previous previous data.
+
+ Previous data is ONLY copied if period falls in report range and before period being processed currently.
+
+ args:
+ current_period: process till this period (exclusive)
+ all_periods: all periods expected in report via filters
+ periodic_data: report's periodic data
+ item_code: item_code being processed
+ """
+
+ previous_period_data = None
+ for period in all_periods:
+ if period == current_period:
+ return
+
+ if (
+ periodic_data.get(item_code)
+ and not periodic_data.get(item_code).get(period)
+ and previous_period_data
+ ):
+ # This period should exist since it's in report range, assign previous period data
+ periodic_data[item_code][period] = previous_period_data.copy()
+
+ previous_period_data = periodic_data.get(item_code, {}).get(period)
+
+
def get_data(filters):
data = []
items = get_items(filters)
@@ -194,6 +233,8 @@
periodic_data = get_periodic_data(sle, filters)
ranges = get_period_date_ranges(filters)
+ today = getdate()
+
for dummy, item_data in item_details.items():
row = {
"name": item_data.name,
@@ -202,14 +243,15 @@
"uom": item_data.stock_uom,
"brand": item_data.brand,
}
- total = 0
- for dummy, end_date in ranges:
+ previous_period_value = 0.0
+ for start_date, end_date in ranges:
period = get_period(end_date, filters)
period_data = periodic_data.get(item_data.name, {}).get(period)
- amount = sum(period_data.values()) if period_data else 0
- row[scrub(period)] = amount
- total += amount
- row["total"] = total
+ if period_data:
+ row[scrub(period)] = previous_period_value = sum(period_data.values())
+ else:
+ row[scrub(period)] = previous_period_value if today >= start_date else None
+
data.append(row)
return data
diff --git a/erpnext/stock/report/stock_analytics/test_stock_analytics.py b/erpnext/stock/report/stock_analytics/test_stock_analytics.py
index f6c98f9..dd8f8d8 100644
--- a/erpnext/stock/report/stock_analytics/test_stock_analytics.py
+++ b/erpnext/stock/report/stock_analytics/test_stock_analytics.py
@@ -1,13 +1,59 @@
import datetime
+import frappe
from frappe import _dict
from frappe.tests.utils import FrappeTestCase
+from frappe.utils.data import add_to_date, get_datetime, getdate, nowdate
from erpnext.accounts.utils import get_fiscal_year
-from erpnext.stock.report.stock_analytics.stock_analytics import get_period_date_ranges
+from erpnext.stock.doctype.item.test_item import make_item
+from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
+from erpnext.stock.report.stock_analytics.stock_analytics import execute, get_period_date_ranges
+
+
+def stock_analytics(filters):
+ col, data, *_ = execute(filters)
+ return col, data
class TestStockAnalyticsReport(FrappeTestCase):
+ def setUp(self) -> None:
+ self.item = make_item().name
+ self.warehouse = "_Test Warehouse - _TC"
+
+ def assert_single_item_report(self, movement, expected_buckets):
+ self.generate_stock(movement)
+ filters = _dict(
+ range="Monthly",
+ from_date=movement[0][1].replace(day=1),
+ to_date=movement[-1][1].replace(day=28),
+ value_quantity="Quantity",
+ company="_Test Company",
+ item_code=self.item,
+ )
+
+ cols, data = stock_analytics(filters)
+
+ self.assertEqual(len(data), 1)
+ row = frappe._dict(data[0])
+ self.assertEqual(row.name, self.item)
+ self.compare_analytics_row(row, cols, expected_buckets)
+
+ def generate_stock(self, movement):
+ for qty, posting_date in movement:
+ args = {"item": self.item, "qty": abs(qty), "posting_date": posting_date}
+ args["to_warehouse" if qty > 0 else "from_warehouse"] = self.warehouse
+ make_stock_entry(**args)
+
+ def compare_analytics_row(self, report_row, columns, expected_buckets):
+ # last (N) cols will be monthly data
+ no_of_buckets = len(expected_buckets)
+ month_cols = [col["fieldname"] for col in columns[-no_of_buckets:]]
+
+ actual_buckets = [report_row.get(col) for col in month_cols]
+
+ self.assertEqual(actual_buckets, expected_buckets)
+
def test_get_period_date_ranges(self):
filters = _dict(range="Monthly", from_date="2020-12-28", to_date="2021-02-06")
@@ -33,3 +79,38 @@
]
self.assertEqual(ranges, expected_ranges)
+
+ def test_basic_report_functionality(self):
+ """Stock analytics report generates balance "as of" periods based on
+ user defined ranges. Check that this behaviour is correct."""
+
+ # create stock movement in 3 months at 15th of month
+ today = getdate()
+ movement = [
+ (10, add_to_date(today, months=0).replace(day=15)),
+ (-5, add_to_date(today, months=1).replace(day=15)),
+ (10, add_to_date(today, months=2).replace(day=15)),
+ ]
+ self.assert_single_item_report(movement, [10, 5, 15])
+
+ def test_empty_month_in_between(self):
+ today = getdate()
+ movement = [
+ (100, add_to_date(today, months=0).replace(day=15)),
+ (-50, add_to_date(today, months=1).replace(day=15)),
+ # Skip a month
+ (20, add_to_date(today, months=3).replace(day=15)),
+ ]
+ self.assert_single_item_report(movement, [100, 50, 50, 70])
+
+ def test_multi_month_missings(self):
+ today = getdate()
+ movement = [
+ (100, add_to_date(today, months=0).replace(day=15)),
+ (-50, add_to_date(today, months=1).replace(day=15)),
+ # Skip a month
+ (20, add_to_date(today, months=3).replace(day=15)),
+ # Skip another month
+ (-10, add_to_date(today, months=5).replace(day=15)),
+ ]
+ self.assert_single_item_report(movement, [100, 50, 50, 70, 70, 60])
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 837c4a6..ed0e2fc 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
@@ -111,17 +111,17 @@
},
{
"fieldname": "posting_date",
- "fieldtype": "Date",
+ "fieldtype": "Data",
"label": _("Posting Date"),
},
{
"fieldname": "posting_time",
- "fieldtype": "Time",
+ "fieldtype": "Data",
"label": _("Posting Time"),
},
{
"fieldname": "creation",
- "fieldtype": "Datetime",
+ "fieldtype": "Data",
"label": _("Creation"),
},
{
diff --git a/erpnext/stock/report/test_reports.py b/erpnext/stock/report/test_reports.py
index 55b9104..d118d8e 100644
--- a/erpnext/stock/report/test_reports.py
+++ b/erpnext/stock/report/test_reports.py
@@ -65,6 +65,8 @@
("Delayed Item Report", {"based_on": "Delivery Note"}),
("Stock Ageing", {"range1": 30, "range2": 60, "range3": 90, "_optional": True}),
("Stock Ledger Invariant Check", {"warehouse": "_Test Warehouse - _TC", "item": "_Test Item"}),
+ ("FIFO Queue vs Qty After Transaction Comparison", {"warehouse": "_Test Warehouse - _TC"}),
+ ("FIFO Queue vs Qty After Transaction Comparison", {"item_group": "All Item Groups"}),
]
OPTIONAL_FILTERS = {
diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py
index 7e5c231..4789b52 100644
--- a/erpnext/stock/stock_ledger.py
+++ b/erpnext/stock/stock_ledger.py
@@ -1303,6 +1303,8 @@
datetime_limit_condition = ""
qty_shift = args.actual_qty
+ args["time_format"] = "%H:%i:%s"
+
# find difference/shift in qty caused by stock reconciliation
if args.voucher_type == "Stock Reconciliation":
qty_shift = get_stock_reco_qty_shift(args)
@@ -1315,7 +1317,7 @@
datetime_limit_condition = get_datetime_limit_condition(detail)
frappe.db.sql(
- """
+ f"""
update `tabStock Ledger Entry`
set qty_after_transaction = qty_after_transaction + {qty_shift}
where
@@ -1323,16 +1325,10 @@
and warehouse = %(warehouse)s
and voucher_no != %(voucher_no)s
and is_cancelled = 0
- and (timestamp(posting_date, posting_time) > timestamp(%(posting_date)s, %(posting_time)s)
- or (
- timestamp(posting_date, posting_time) = timestamp(%(posting_date)s, %(posting_time)s)
- and creation > %(creation)s
- )
- )
+ and timestamp(posting_date, time_format(posting_time, %(time_format)s))
+ > timestamp(%(posting_date)s, time_format(%(posting_time)s, %(time_format)s))
{datetime_limit_condition}
- """.format(
- qty_shift=qty_shift, datetime_limit_condition=datetime_limit_condition
- ),
+ """,
args,
)
@@ -1383,6 +1379,7 @@
and creation > %(creation)s
)
)
+ order by timestamp(posting_date, posting_time) asc, creation asc
limit 1
""",
args,
diff --git a/erpnext/translations/ru.csv b/erpnext/translations/ru.csv
index 6ca3344..fb56ff6 100644
--- a/erpnext/translations/ru.csv
+++ b/erpnext/translations/ru.csv
@@ -1357,7 +1357,7 @@
"Item Price appears multiple times based on Price List, Supplier/Customer, Currency, Item, UOM, Qty and Dates.","Цена товара отображается несколько раз на основе Прайс-листа, Поставщика / Клиента, Валюты, Предмет, UOM, Кол-во и Даты.",
Item Price updated for {0} in Price List {1},Цена продукта {0} обновлена в прайс-листе {1},
Item Row {0}: {1} {2} does not exist in above '{1}' table,Элемент Row {0}: {1} {2} не существует в таблице «{1}»,
-Item Tax Row {0} must have account of type Tax or Income or Expense or Chargeable,"Строка налога {0} должен иметь счет типа Налога, Доход, Расходов или Облагаемый налогом,"
+Item Tax Row {0} must have account of type Tax or Income or Expense or Chargeable,"Строка налога {0} должен иметь счет типа Налога, Доход, Расходов или Облагаемый налогом",
Item Template,Шаблон продукта,
Item Variant Settings,Параметры модификации продкута,
Item Variant {0} already exists with same attributes,Модификация продукта {0} с этими атрибутами уже существует,