Merge pull request #28776 from rtdany10/ksa-vat-updates
feat(Regional): KSA E-Invoing optimizations and POS support
diff --git a/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.js b/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.js
index 7f98354..6f6ca61 100644
--- a/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.js
+++ b/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.js
@@ -43,14 +43,11 @@
}
});
}
- else {
- frm.clear_table("purposes");
- }
-
if (!frm.doc.status) {
frm.set_value({ status: 'Draft' });
}
if (frm.doc.__islocal) {
+ frm.clear_table("purposes");
frm.set_value({ mntc_date: frappe.datetime.get_today() });
}
},
diff --git a/erpnext/regional/india/utils.py b/erpnext/regional/india/utils.py
index 9743c3b..fd3ec3c 100644
--- a/erpnext/regional/india/utils.py
+++ b/erpnext/regional/india/utils.py
@@ -213,7 +213,7 @@
tax_template_by_category = get_tax_template_based_on_category(master_doctype, company, party_details)
if tax_template_by_category:
- party_details.get['taxes_and_charges'] = tax_template_by_category
+ party_details['taxes_and_charges'] = tax_template_by_category
return
if not party_details.place_of_supply: return party_details
diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py
index 5ef0770..5a9e77e 100644
--- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py
+++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py
@@ -24,7 +24,8 @@
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
create_stock_reconciliation,
)
-from erpnext.stock.stock_ledger import get_previous_sle
+from erpnext.stock.stock_ledger import NegativeStockError, get_previous_sle
+from erpnext.tests.utils import ERPNextTestCase, change_settings
def get_sle(**args):
@@ -38,7 +39,7 @@
order by timestamp(posting_date, posting_time) desc, creation desc limit 1"""% condition,
values, as_dict=1)
-class TestStockEntry(unittest.TestCase):
+class TestStockEntry(ERPNextTestCase):
def tearDown(self):
frappe.set_user("Administrator")
frappe.db.set_value("Manufacturing Settings", None, "material_consumption", "0")
@@ -928,6 +929,83 @@
distributed_costs = [d.additional_cost for d in se.items]
self.assertEqual([40.0, 60.0], distributed_costs)
+ @change_settings("Stock Settings", {"allow_negative_stock": 0})
+ def test_future_negative_sle(self):
+ # Initialize item, batch, warehouse, opening qty
+ item_code = '_Test Future Neg Item'
+ batch_no = '_Test Future Neg Batch'
+ warehouses = [
+ '_Test Future Neg Warehouse Source',
+ '_Test Future Neg Warehouse Destination'
+ ]
+ warehouse_names = initialize_records_for_future_negative_sle_test(
+ item_code, batch_no, warehouses,
+ opening_qty=2, posting_date='2021-07-01'
+ )
+
+ # Executing an illegal sequence should raise an error
+ sequence_of_entries = [
+ dict(item_code=item_code,
+ qty=2,
+ from_warehouse=warehouse_names[0],
+ to_warehouse=warehouse_names[1],
+ batch_no=batch_no,
+ posting_date='2021-07-03',
+ purpose='Material Transfer'),
+ dict(item_code=item_code,
+ qty=2,
+ from_warehouse=warehouse_names[1],
+ to_warehouse=warehouse_names[0],
+ batch_no=batch_no,
+ posting_date='2021-07-04',
+ purpose='Material Transfer'),
+ dict(item_code=item_code,
+ qty=2,
+ from_warehouse=warehouse_names[0],
+ to_warehouse=warehouse_names[1],
+ batch_no=batch_no,
+ posting_date='2021-07-02', # Illegal SE
+ purpose='Material Transfer')
+ ]
+
+ self.assertRaises(NegativeStockError, create_stock_entries, sequence_of_entries)
+
+ @change_settings("Stock Settings", {"allow_negative_stock": 0})
+ def test_future_negative_sle_batch(self):
+ from erpnext.stock.doctype.batch.test_batch import TestBatch
+
+ # Initialize item, batch, warehouse, opening qty
+ item_code = '_Test MultiBatch Item'
+ TestBatch.make_batch_item(item_code)
+
+ batch_nos = [] # store generate batches
+ warehouse = '_Test Warehouse - _TC'
+
+ se1 = make_stock_entry(
+ item_code=item_code,
+ qty=2,
+ to_warehouse=warehouse,
+ posting_date='2021-09-01',
+ purpose='Material Receipt'
+ )
+ batch_nos.append(se1.items[0].batch_no)
+ se2 = make_stock_entry(
+ item_code=item_code,
+ qty=2,
+ to_warehouse=warehouse,
+ posting_date='2021-09-03',
+ purpose='Material Receipt'
+ )
+ batch_nos.append(se2.items[0].batch_no)
+
+ with self.assertRaises(NegativeStockError) as nse:
+ make_stock_entry(item_code=item_code,
+ qty=1,
+ from_warehouse=warehouse,
+ batch_no=batch_nos[1],
+ posting_date='2021-09-02', # backdated consumption of 2nd batch
+ purpose='Material Issue')
+
def make_serialized_item(**args):
args = frappe._dict(args)
se = frappe.copy_doc(test_records[0])
@@ -998,3 +1076,31 @@
]
test_records = frappe.get_test_records('Stock Entry')
+
+def initialize_records_for_future_negative_sle_test(
+ item_code, batch_no, warehouses, opening_qty, posting_date):
+ from erpnext.stock.doctype.batch.test_batch import TestBatch, make_new_batch
+ from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
+ create_stock_reconciliation,
+ )
+ from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
+
+ TestBatch.make_batch_item(item_code)
+ make_new_batch(item_code=item_code, batch_id=batch_no)
+ warehouse_names = [create_warehouse(w) for w in warehouses]
+ create_stock_reconciliation(
+ purpose='Opening Stock',
+ posting_date=posting_date,
+ posting_time='20:00:20',
+ item_code=item_code,
+ warehouse=warehouse_names[0],
+ valuation_rate=100,
+ qty=opening_qty,
+ batch_no=batch_no,
+ )
+ return warehouse_names
+
+
+def create_stock_entries(sequence_of_entries):
+ for entry_detail in sequence_of_entries:
+ make_stock_entry(**entry_detail)
diff --git a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py
index 93bca7a..c538307 100644
--- a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py
+++ b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py
@@ -8,7 +8,7 @@
from frappe import _
from frappe.core.doctype.role.role import get_users
from frappe.model.document import Document
-from frappe.utils import add_days, cint, flt, formatdate, get_datetime, getdate
+from frappe.utils import add_days, cint, formatdate, get_datetime, getdate
from erpnext.accounts.utils import get_fiscal_year
from erpnext.controllers.item_variant import ItemTemplateCannotHaveStock
@@ -43,7 +43,6 @@
def on_submit(self):
self.check_stock_frozen_date()
- self.actual_amt_check()
self.calculate_batch_qty()
if not self.get("via_landed_cost_voucher"):
@@ -57,18 +56,6 @@
"sum(actual_qty)") or 0
frappe.db.set_value("Batch", self.batch_no, "batch_qty", batch_qty)
- def actual_amt_check(self):
- """Validate that qty at warehouse for selected batch is >=0"""
- if self.batch_no and not self.get("allow_negative_stock"):
- batch_bal_after_transaction = flt(frappe.db.sql("""select sum(actual_qty)
- from `tabStock Ledger Entry`
- where is_cancelled =0 and warehouse=%s and item_code=%s and batch_no=%s""",
- (self.warehouse, self.item_code, self.batch_no))[0][0])
-
- if batch_bal_after_transaction < 0:
- frappe.throw(_("Stock balance in Batch {0} will become negative {1} for Item {2} at Warehouse {3}")
- .format(self.batch_no, batch_bal_after_transaction, self.item_code, self.warehouse))
-
def validate_mandatory(self):
mandatory = ['warehouse','posting_date','voucher_type','voucher_no','company']
for k in mandatory:
diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py
index d78632a..e95c0fc 100644
--- a/erpnext/stock/stock_ledger.py
+++ b/erpnext/stock/stock_ledger.py
@@ -1089,17 +1089,36 @@
allow_negative_stock = cint(allow_negative_stock) \
or cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock"))
- if (args.actual_qty < 0 or args.voucher_type == "Stock Reconciliation") and not allow_negative_stock:
- sle = get_future_sle_with_negative_qty(args)
- if sle:
- message = _("{0} units of {1} needed in {2} on {3} {4} for {5} to complete this transaction.").format(
- abs(sle[0]["qty_after_transaction"]),
- frappe.get_desk_link('Item', args.item_code),
- frappe.get_desk_link('Warehouse', args.warehouse),
- sle[0]["posting_date"], sle[0]["posting_time"],
- frappe.get_desk_link(sle[0]["voucher_type"], sle[0]["voucher_no"]))
+ if allow_negative_stock:
+ return
+ if not (args.actual_qty < 0 or args.voucher_type == "Stock Reconciliation"):
+ return
- frappe.throw(message, NegativeStockError, title='Insufficient Stock')
+ neg_sle = get_future_sle_with_negative_qty(args)
+ if neg_sle:
+ message = _("{0} units of {1} needed in {2} on {3} {4} for {5} to complete this transaction.").format(
+ abs(neg_sle[0]["qty_after_transaction"]),
+ frappe.get_desk_link('Item', args.item_code),
+ frappe.get_desk_link('Warehouse', args.warehouse),
+ neg_sle[0]["posting_date"], neg_sle[0]["posting_time"],
+ frappe.get_desk_link(neg_sle[0]["voucher_type"], neg_sle[0]["voucher_no"]))
+
+ frappe.throw(message, NegativeStockError, title='Insufficient Stock')
+
+
+ if not args.batch_no:
+ return
+
+ neg_batch_sle = get_future_sle_with_negative_batch_qty(args)
+ if neg_batch_sle:
+ message = _("{0} units of {1} needed in {2} on {3} {4} for {5} to complete this transaction.").format(
+ abs(neg_batch_sle[0]["cumulative_total"]),
+ frappe.get_desk_link('Batch', args.batch_no),
+ frappe.get_desk_link('Warehouse', args.warehouse),
+ neg_batch_sle[0]["posting_date"], neg_batch_sle[0]["posting_time"],
+ frappe.get_desk_link(neg_batch_sle[0]["voucher_type"], neg_batch_sle[0]["voucher_no"]))
+ frappe.throw(message, NegativeStockError, title="Insufficient Stock for Batch")
+
def get_future_sle_with_negative_qty(args):
return frappe.db.sql("""
@@ -1118,6 +1137,29 @@
limit 1
""", args, as_dict=1)
+
+def get_future_sle_with_negative_batch_qty(args):
+ return frappe.db.sql("""
+ with batch_ledger as (
+ select
+ posting_date, posting_time, voucher_type, voucher_no,
+ sum(actual_qty) over (order by posting_date, posting_time, creation) as cumulative_total
+ from `tabStock Ledger Entry`
+ where
+ item_code = %(item_code)s
+ and warehouse = %(warehouse)s
+ and batch_no=%(batch_no)s
+ and is_cancelled = 0
+ order by posting_date, posting_time, creation
+ )
+ select * from batch_ledger
+ where
+ cumulative_total < 0.0
+ and timestamp(posting_date, posting_time) >= timestamp(%(posting_date)s, %(posting_time)s)
+ limit 1
+ """, args, as_dict=1)
+
+
def _round_off_if_near_zero(number: float, precision: int = 6) -> float:
""" Rounds off the number to zero only if number is close to zero for decimal
specified in precision. Precision defaults to 6.