mirror of
https://github.com/frappe/erpnext.git
synced 2026-04-15 04:45:09 +00:00
Merge branch 'version-13-pre-release' into version-13
This commit is contained in:
@@ -5,7 +5,7 @@ import frappe
|
||||
|
||||
from erpnext.hooks import regional_overrides
|
||||
|
||||
__version__ = '13.20.1'
|
||||
__version__ = '13.21.0'
|
||||
|
||||
def get_default_company(user=None):
|
||||
'''Get default company for user'''
|
||||
|
||||
@@ -14,6 +14,10 @@ frappe.ui.form.on("Bank Reconciliation Tool", {
|
||||
});
|
||||
},
|
||||
|
||||
onload: function (frm) {
|
||||
frm.trigger('bank_account');
|
||||
},
|
||||
|
||||
refresh: function (frm) {
|
||||
frappe.require("assets/js/bank-reconciliation-tool.min.js", () =>
|
||||
frm.trigger("make_reconciliation_tool")
|
||||
@@ -51,7 +55,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", {
|
||||
bank_account: function (frm) {
|
||||
frappe.db.get_value(
|
||||
"Bank Account",
|
||||
frm.bank_account,
|
||||
frm.doc.bank_account,
|
||||
"account",
|
||||
(r) => {
|
||||
frappe.db.get_value(
|
||||
@@ -60,6 +64,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", {
|
||||
"account_currency",
|
||||
(r) => {
|
||||
frm.currency = r.account_currency;
|
||||
frm.trigger("render_chart");
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -124,7 +129,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", {
|
||||
}
|
||||
},
|
||||
|
||||
render_chart(frm) {
|
||||
render_chart: frappe.utils.debounce((frm) => {
|
||||
frm.cards_manager = new erpnext.accounts.bank_reconciliation.NumberCardManager(
|
||||
{
|
||||
$reconciliation_tool_cards: frm.get_field(
|
||||
@@ -136,7 +141,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", {
|
||||
currency: frm.currency,
|
||||
}
|
||||
);
|
||||
},
|
||||
}, 500),
|
||||
|
||||
render(frm) {
|
||||
if (frm.doc.bank_account) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"actions": [],
|
||||
"allow_import": 1,
|
||||
"allow_rename": 1,
|
||||
"creation": "2018-11-22 22:45:00.370913",
|
||||
"creation": "2022-01-19 01:09:13.297137",
|
||||
"doctype": "DocType",
|
||||
"document_type": "Setup",
|
||||
"editable_grid": 1,
|
||||
@@ -10,6 +10,9 @@
|
||||
"field_order": [
|
||||
"title",
|
||||
"company",
|
||||
"column_break_3",
|
||||
"disabled",
|
||||
"section_break_5",
|
||||
"taxes"
|
||||
],
|
||||
"fields": [
|
||||
@@ -36,10 +39,24 @@
|
||||
"label": "Company",
|
||||
"options": "Company",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_3",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "disabled",
|
||||
"fieldtype": "Check",
|
||||
"label": "Disabled"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_5",
|
||||
"fieldtype": "Section Break"
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"modified": "2021-03-08 19:50:21.416513",
|
||||
"modified": "2022-01-18 21:11:23.105589",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Item Tax Template",
|
||||
@@ -82,6 +99,7 @@
|
||||
"show_name_in_global_search": 1,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"title_field": "title",
|
||||
"track_changes": 1
|
||||
}
|
||||
@@ -8,6 +8,7 @@ frappe.provide("erpnext.journal_entry");
|
||||
frappe.ui.form.on("Journal Entry", {
|
||||
setup: function(frm) {
|
||||
frm.add_fetch("bank_account", "account", "account");
|
||||
frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice'];
|
||||
},
|
||||
|
||||
refresh: function(frm) {
|
||||
|
||||
@@ -167,7 +167,8 @@ class OpeningInvoiceCreationTool(Document):
|
||||
"is_pos": 0,
|
||||
"doctype": "Sales Invoice" if self.invoice_type == "Sales" else "Purchase Invoice",
|
||||
"update_stock": 0,
|
||||
"invoice_number": row.invoice_number
|
||||
"invoice_number": row.invoice_number,
|
||||
"disable_rounded_total": 1
|
||||
})
|
||||
|
||||
accounting_dimension = get_accounting_dimensions()
|
||||
|
||||
@@ -43,7 +43,6 @@ class POSInvoice(SalesInvoice):
|
||||
self.validate_serialised_or_batched_item()
|
||||
self.validate_stock_availablility()
|
||||
self.validate_return_items_qty()
|
||||
self.validate_non_stock_items()
|
||||
self.set_status()
|
||||
self.set_account_for_mode_of_payment()
|
||||
self.validate_pos()
|
||||
@@ -159,22 +158,39 @@ class POSInvoice(SalesInvoice):
|
||||
frappe.throw(_("Row #{}: Serial No. {} has already been transacted into another Sales Invoice. Please select valid serial no.")
|
||||
.format(item.idx, bold_delivered_serial_nos), title=_("Item Unavailable"))
|
||||
|
||||
def validate_invalid_serial_nos(self, item):
|
||||
serial_nos = get_serial_nos(item.serial_no)
|
||||
error_msg = []
|
||||
invalid_serials, msg = "", ""
|
||||
for serial_no in serial_nos:
|
||||
if not frappe.db.exists('Serial No', serial_no):
|
||||
invalid_serials = invalid_serials + (", " if invalid_serials else "") + serial_no
|
||||
msg = (_("Row #{}: Following Serial numbers for item {} are <b>Invalid</b>: {}").format(item.idx, frappe.bold(item.get("item_code")), frappe.bold(invalid_serials)))
|
||||
if invalid_serials:
|
||||
error_msg.append(msg)
|
||||
|
||||
if error_msg:
|
||||
frappe.throw(error_msg, title=_("Invalid Item"), as_list=True)
|
||||
|
||||
def validate_stock_availablility(self):
|
||||
if self.is_return or self.docstatus != 1:
|
||||
return
|
||||
|
||||
allow_negative_stock = frappe.db.get_single_value('Stock Settings', 'allow_negative_stock')
|
||||
for d in self.get('items'):
|
||||
is_service_item = not (frappe.db.get_value('Item', d.get('item_code'), 'is_stock_item'))
|
||||
if is_service_item:
|
||||
return
|
||||
if d.serial_no:
|
||||
self.validate_pos_reserved_serial_nos(d)
|
||||
self.validate_delivered_serial_nos(d)
|
||||
self.validate_invalid_serial_nos(d)
|
||||
elif d.batch_no:
|
||||
self.validate_pos_reserved_batch_qty(d)
|
||||
else:
|
||||
if allow_negative_stock:
|
||||
return
|
||||
|
||||
available_stock = get_stock_availability(d.item_code, d.warehouse)
|
||||
available_stock, is_stock_item = get_stock_availability(d.item_code, d.warehouse)
|
||||
|
||||
item_code, warehouse, qty = frappe.bold(d.item_code), frappe.bold(d.warehouse), frappe.bold(d.qty)
|
||||
if flt(available_stock) <= 0:
|
||||
@@ -245,14 +261,6 @@ class POSInvoice(SalesInvoice):
|
||||
.format(d.idx, bold_serial_no, bold_return_against)
|
||||
)
|
||||
|
||||
def validate_non_stock_items(self):
|
||||
for d in self.get("items"):
|
||||
is_stock_item = frappe.get_cached_value("Item", d.get("item_code"), "is_stock_item")
|
||||
if not is_stock_item:
|
||||
if not frappe.db.exists('Product Bundle', d.item_code):
|
||||
frappe.throw(_("Row #{}: Item {} is a non stock item. You can only include stock items in a POS Invoice.")
|
||||
.format(d.idx, frappe.bold(d.item_code)), title=_("Invalid Item"))
|
||||
|
||||
def validate_mode_of_payment(self):
|
||||
if len(self.payments) == 0:
|
||||
frappe.throw(_("At least one mode of payment is required for POS invoice."))
|
||||
@@ -492,12 +500,18 @@ class POSInvoice(SalesInvoice):
|
||||
@frappe.whitelist()
|
||||
def get_stock_availability(item_code, warehouse):
|
||||
if frappe.db.get_value('Item', item_code, 'is_stock_item'):
|
||||
is_stock_item = True
|
||||
bin_qty = get_bin_qty(item_code, warehouse)
|
||||
pos_sales_qty = get_pos_reserved_qty(item_code, warehouse)
|
||||
return bin_qty - pos_sales_qty
|
||||
return bin_qty - pos_sales_qty, is_stock_item
|
||||
else:
|
||||
is_stock_item = False
|
||||
if frappe.db.exists('Product Bundle', item_code):
|
||||
return get_bundle_availability(item_code, warehouse)
|
||||
return get_bundle_availability(item_code, warehouse), is_stock_item
|
||||
else:
|
||||
# Is a service item
|
||||
return 0, is_stock_item
|
||||
|
||||
|
||||
def get_bundle_availability(bundle_item_code, warehouse):
|
||||
product_bundle = frappe.get_doc('Product Bundle', bundle_item_code)
|
||||
|
||||
@@ -354,6 +354,24 @@ class TestPOSInvoice(unittest.TestCase):
|
||||
pos2.insert()
|
||||
self.assertRaises(frappe.ValidationError, pos2.submit)
|
||||
|
||||
def test_invalid_serial_no_validation(self):
|
||||
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_serialized_item
|
||||
|
||||
se = make_serialized_item(company='_Test Company',
|
||||
target_warehouse="Stores - _TC", cost_center='Main - _TC', expense_account='Cost of Goods Sold - _TC')
|
||||
serial_nos = se.get("items")[0].serial_no + 'wrong'
|
||||
|
||||
pos = create_pos_invoice(company='_Test Company', debit_to='Debtors - _TC',
|
||||
account_for_change_amount='Cash - _TC', warehouse='Stores - _TC', income_account='Sales - _TC',
|
||||
expense_account='Cost of Goods Sold - _TC', cost_center='Main - _TC',
|
||||
item=se.get("items")[0].item_code, rate=1000, qty=2, do_not_save=1)
|
||||
|
||||
pos.get('items')[0].has_serial_no = 1
|
||||
pos.get('items')[0].serial_no = serial_nos
|
||||
pos.insert()
|
||||
|
||||
self.assertRaises(frappe.ValidationError, pos.submit)
|
||||
|
||||
def test_loyalty_points(self):
|
||||
from erpnext.accounts.doctype.loyalty_program.loyalty_program import (
|
||||
get_loyalty_program_details_with_points,
|
||||
@@ -568,23 +586,29 @@ class TestPOSInvoice(unittest.TestCase):
|
||||
item_price.insert()
|
||||
pr = make_pricing_rule(selling=1, priority=5, discount_percentage=10)
|
||||
pr.save()
|
||||
pos_inv = create_pos_invoice(qty=1, do_not_submit=1)
|
||||
pos_inv.items[0].rate = 300
|
||||
pos_inv.save()
|
||||
self.assertEquals(pos_inv.items[0].discount_percentage, 10)
|
||||
# rate shouldn't change
|
||||
self.assertEquals(pos_inv.items[0].rate, 405)
|
||||
|
||||
pos_inv.ignore_pricing_rule = 1
|
||||
pos_inv.items[0].rate = 300
|
||||
pos_inv.save()
|
||||
self.assertEquals(pos_inv.ignore_pricing_rule, 1)
|
||||
# rate should change since pricing rules are ignored
|
||||
self.assertEquals(pos_inv.items[0].rate, 300)
|
||||
try:
|
||||
pos_inv = create_pos_invoice(qty=1, do_not_submit=1)
|
||||
pos_inv.items[0].rate = 300
|
||||
pos_inv.save()
|
||||
self.assertEquals(pos_inv.items[0].discount_percentage, 10)
|
||||
# rate shouldn't change
|
||||
self.assertEquals(pos_inv.items[0].rate, 405)
|
||||
|
||||
item_price.delete()
|
||||
pos_inv.delete()
|
||||
pr.delete()
|
||||
pos_inv.ignore_pricing_rule = 1
|
||||
pos_inv.save()
|
||||
self.assertEquals(pos_inv.ignore_pricing_rule, 1)
|
||||
# rate should reset since pricing rules are ignored
|
||||
self.assertEquals(pos_inv.items[0].rate, 450)
|
||||
|
||||
pos_inv.items[0].rate = 300
|
||||
pos_inv.save()
|
||||
self.assertEquals(pos_inv.items[0].rate, 300)
|
||||
|
||||
finally:
|
||||
item_price.delete()
|
||||
pos_inv.delete()
|
||||
pr.delete()
|
||||
|
||||
|
||||
def create_pos_invoice(**args):
|
||||
|
||||
@@ -85,12 +85,20 @@ class POSInvoiceMergeLog(Document):
|
||||
sales_invoice.set_posting_time = 1
|
||||
sales_invoice.posting_date = getdate(self.posting_date)
|
||||
sales_invoice.save()
|
||||
self.write_off_fractional_amount(sales_invoice, data)
|
||||
sales_invoice.submit()
|
||||
|
||||
self.consolidated_invoice = sales_invoice.name
|
||||
|
||||
return sales_invoice.name
|
||||
|
||||
def write_off_fractional_amount(self, invoice, data):
|
||||
pos_invoice_grand_total = sum(d.grand_total for d in data)
|
||||
|
||||
if abs(pos_invoice_grand_total - invoice.grand_total) < 1:
|
||||
invoice.write_off_amount += -1 * (pos_invoice_grand_total - invoice.grand_total)
|
||||
invoice.save()
|
||||
|
||||
def process_merging_into_credit_note(self, data):
|
||||
credit_note = self.get_new_sales_invoice()
|
||||
credit_note.is_return = 1
|
||||
@@ -103,6 +111,7 @@ class POSInvoiceMergeLog(Document):
|
||||
# TODO: return could be against multiple sales invoice which could also have been consolidated?
|
||||
# credit_note.return_against = self.consolidated_invoice
|
||||
credit_note.save()
|
||||
self.write_off_fractional_amount(credit_note, data)
|
||||
credit_note.submit()
|
||||
|
||||
self.consolidated_credit_note = credit_note.name
|
||||
@@ -136,9 +145,15 @@ class POSInvoiceMergeLog(Document):
|
||||
i.uom == item.uom and i.net_rate == item.net_rate and i.warehouse == item.warehouse):
|
||||
found = True
|
||||
i.qty = i.qty + item.qty
|
||||
i.amount = i.amount + item.net_amount
|
||||
i.net_amount = i.amount
|
||||
i.base_amount = i.base_amount + item.base_net_amount
|
||||
i.base_net_amount = i.base_amount
|
||||
|
||||
if not found:
|
||||
item.rate = item.net_rate
|
||||
item.amount = item.net_amount
|
||||
item.base_amount = item.base_net_amount
|
||||
item.price_list_rate = 0
|
||||
si_item = map_child_doc(item, invoice, {"doctype": "Sales Invoice Item"})
|
||||
items.append(si_item)
|
||||
@@ -170,6 +185,7 @@ class POSInvoiceMergeLog(Document):
|
||||
found = True
|
||||
if not found:
|
||||
payments.append(payment)
|
||||
|
||||
rounding_adjustment += doc.rounding_adjustment
|
||||
rounded_total += doc.rounded_total
|
||||
base_rounding_adjustment += doc.base_rounding_adjustment
|
||||
|
||||
@@ -12,6 +12,7 @@ from erpnext.accounts.doctype.pos_invoice.test_pos_invoice import create_pos_inv
|
||||
from erpnext.accounts.doctype.pos_invoice_merge_log.pos_invoice_merge_log import (
|
||||
consolidate_pos_invoices,
|
||||
)
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
|
||||
class TestPOSInvoiceMergeLog(unittest.TestCase):
|
||||
@@ -150,3 +151,132 @@ class TestPOSInvoiceMergeLog(unittest.TestCase):
|
||||
frappe.set_user("Administrator")
|
||||
frappe.db.sql("delete from `tabPOS Profile`")
|
||||
frappe.db.sql("delete from `tabPOS Invoice`")
|
||||
|
||||
|
||||
def test_consolidation_round_off_error_1(self):
|
||||
'''
|
||||
Test round off error in consolidated invoice creation if POS Invoice has inclusive tax
|
||||
'''
|
||||
|
||||
frappe.db.sql("delete from `tabPOS Invoice`")
|
||||
|
||||
try:
|
||||
make_stock_entry(
|
||||
to_warehouse="_Test Warehouse - _TC",
|
||||
item_code="_Test Item",
|
||||
rate=8000,
|
||||
qty=10,
|
||||
)
|
||||
|
||||
init_user_and_profile()
|
||||
|
||||
inv = create_pos_invoice(qty=3, rate=10000, do_not_save=True)
|
||||
inv.append("taxes", {
|
||||
"account_head": "_Test Account VAT - _TC",
|
||||
"charge_type": "On Net Total",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"description": "VAT",
|
||||
"doctype": "Sales Taxes and Charges",
|
||||
"rate": 7.5,
|
||||
"included_in_print_rate": 1
|
||||
})
|
||||
inv.append('payments', {
|
||||
'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 30000
|
||||
})
|
||||
inv.insert()
|
||||
inv.submit()
|
||||
|
||||
inv2 = create_pos_invoice(qty=3, rate=10000, do_not_save=True)
|
||||
inv2.append("taxes", {
|
||||
"account_head": "_Test Account VAT - _TC",
|
||||
"charge_type": "On Net Total",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"description": "VAT",
|
||||
"doctype": "Sales Taxes and Charges",
|
||||
"rate": 7.5,
|
||||
"included_in_print_rate": 1
|
||||
})
|
||||
inv2.append('payments', {
|
||||
'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 30000
|
||||
})
|
||||
inv2.insert()
|
||||
inv2.submit()
|
||||
|
||||
consolidate_pos_invoices()
|
||||
|
||||
inv.load_from_db()
|
||||
consolidated_invoice = frappe.get_doc('Sales Invoice', inv.consolidated_invoice)
|
||||
self.assertEqual(consolidated_invoice.outstanding_amount, 0)
|
||||
self.assertEqual(consolidated_invoice.status, 'Paid')
|
||||
|
||||
finally:
|
||||
frappe.set_user("Administrator")
|
||||
frappe.db.sql("delete from `tabPOS Profile`")
|
||||
frappe.db.sql("delete from `tabPOS Invoice`")
|
||||
|
||||
def test_consolidation_round_off_error_2(self):
|
||||
'''
|
||||
Test the same case as above but with an Unpaid POS Invoice
|
||||
'''
|
||||
frappe.db.sql("delete from `tabPOS Invoice`")
|
||||
|
||||
try:
|
||||
make_stock_entry(
|
||||
to_warehouse="_Test Warehouse - _TC",
|
||||
item_code="_Test Item",
|
||||
rate=8000,
|
||||
qty=10,
|
||||
)
|
||||
|
||||
init_user_and_profile()
|
||||
|
||||
inv = create_pos_invoice(qty=6, rate=10000, do_not_save=True)
|
||||
inv.append("taxes", {
|
||||
"account_head": "_Test Account VAT - _TC",
|
||||
"charge_type": "On Net Total",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"description": "VAT",
|
||||
"doctype": "Sales Taxes and Charges",
|
||||
"rate": 7.5,
|
||||
"included_in_print_rate": 1
|
||||
})
|
||||
inv.append('payments', {
|
||||
'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 60000
|
||||
})
|
||||
inv.insert()
|
||||
inv.submit()
|
||||
|
||||
inv2 = create_pos_invoice(qty=6, rate=10000, do_not_save=True)
|
||||
inv2.append("taxes", {
|
||||
"account_head": "_Test Account VAT - _TC",
|
||||
"charge_type": "On Net Total",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"description": "VAT",
|
||||
"doctype": "Sales Taxes and Charges",
|
||||
"rate": 7.5,
|
||||
"included_in_print_rate": 1
|
||||
})
|
||||
inv2.append('payments', {
|
||||
'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 60000
|
||||
})
|
||||
inv2.insert()
|
||||
inv2.submit()
|
||||
|
||||
inv3 = create_pos_invoice(qty=3, rate=600, do_not_save=True)
|
||||
inv3.append('payments', {
|
||||
'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 1000
|
||||
})
|
||||
inv3.insert()
|
||||
inv3.submit()
|
||||
|
||||
consolidate_pos_invoices()
|
||||
|
||||
inv.load_from_db()
|
||||
consolidated_invoice = frappe.get_doc('Sales Invoice', inv.consolidated_invoice)
|
||||
self.assertEqual(consolidated_invoice.outstanding_amount, 800)
|
||||
self.assertNotEqual(consolidated_invoice.status, 'Paid')
|
||||
|
||||
finally:
|
||||
frappe.set_user("Administrator")
|
||||
frappe.db.sql("delete from `tabPOS Profile`")
|
||||
frappe.db.sql("delete from `tabPOS Invoice`")
|
||||
|
||||
@@ -250,13 +250,17 @@ def get_pricing_rule_for_item(args, price_list_rate=0, doc=None, for_validate=Fa
|
||||
"free_item_data": [],
|
||||
"parent": args.parent,
|
||||
"parenttype": args.parenttype,
|
||||
"child_docname": args.get('child_docname')
|
||||
"child_docname": args.get('child_docname'),
|
||||
})
|
||||
|
||||
if args.ignore_pricing_rule or not args.item_code:
|
||||
if frappe.db.exists(args.doctype, args.name) and args.get("pricing_rules"):
|
||||
item_details = remove_pricing_rule_for_item(args.get("pricing_rules"),
|
||||
item_details, args.get('item_code'))
|
||||
item_details = remove_pricing_rule_for_item(
|
||||
args.get("pricing_rules"),
|
||||
item_details,
|
||||
item_code=args.get("item_code"),
|
||||
rate=args.get("price_list_rate"),
|
||||
)
|
||||
return item_details
|
||||
|
||||
update_args_for_pricing_rule(args)
|
||||
@@ -309,8 +313,12 @@ def get_pricing_rule_for_item(args, price_list_rate=0, doc=None, for_validate=Fa
|
||||
if not doc: return item_details
|
||||
|
||||
elif args.get("pricing_rules"):
|
||||
item_details = remove_pricing_rule_for_item(args.get("pricing_rules"),
|
||||
item_details, args.get('item_code'))
|
||||
item_details = remove_pricing_rule_for_item(
|
||||
args.get("pricing_rules"),
|
||||
item_details,
|
||||
item_code=args.get("item_code"),
|
||||
rate=args.get("price_list_rate"),
|
||||
)
|
||||
|
||||
return item_details
|
||||
|
||||
@@ -391,7 +399,7 @@ def apply_price_discount_rule(pricing_rule, item_details, args):
|
||||
item_details[field] += (pricing_rule.get(field, 0)
|
||||
if pricing_rule else args.get(field, 0))
|
||||
|
||||
def remove_pricing_rule_for_item(pricing_rules, item_details, item_code=None):
|
||||
def remove_pricing_rule_for_item(pricing_rules, item_details, item_code=None, rate=None):
|
||||
from erpnext.accounts.doctype.pricing_rule.utils import (
|
||||
get_applied_pricing_rules,
|
||||
get_pricing_rule_items,
|
||||
@@ -404,6 +412,7 @@ def remove_pricing_rule_for_item(pricing_rules, item_details, item_code=None):
|
||||
if pricing_rule.rate_or_discount == 'Discount Percentage':
|
||||
item_details.discount_percentage = 0.0
|
||||
item_details.discount_amount = 0.0
|
||||
item_details.rate = rate or 0.0
|
||||
|
||||
if pricing_rule.rate_or_discount == 'Discount Amount':
|
||||
item_details.discount_amount = 0.0
|
||||
@@ -422,6 +431,7 @@ def remove_pricing_rule_for_item(pricing_rules, item_details, item_code=None):
|
||||
item_details.applied_on_items = ','.join(items)
|
||||
|
||||
item_details.pricing_rules = ''
|
||||
item_details.pricing_rule_removed = True
|
||||
|
||||
return item_details
|
||||
|
||||
@@ -433,9 +443,12 @@ def remove_pricing_rules(item_list):
|
||||
out = []
|
||||
for item in item_list:
|
||||
item = frappe._dict(item)
|
||||
if item.get('pricing_rules'):
|
||||
out.append(remove_pricing_rule_for_item(item.get("pricing_rules"),
|
||||
item, item.item_code))
|
||||
if item.get("pricing_rules"):
|
||||
out.append(
|
||||
remove_pricing_rule_for_item(
|
||||
item.get("pricing_rules"), item, item.item_code, item.get("price_list_rate")
|
||||
)
|
||||
)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
@@ -650,6 +650,47 @@ class TestPricingRule(unittest.TestCase):
|
||||
frappe.delete_doc_if_exists("Pricing Rule", "_Test Pricing Rule with Min Qty - 1")
|
||||
frappe.delete_doc_if_exists("Pricing Rule", "_Test Pricing Rule with Min Qty - 2")
|
||||
|
||||
def test_remove_pricing_rule(self):
|
||||
item = make_item("Water Flask")
|
||||
make_item_price("Water Flask", "_Test Price List", 100)
|
||||
|
||||
pricing_rule_record = {
|
||||
"doctype": "Pricing Rule",
|
||||
"title": "_Test Water Flask Rule",
|
||||
"apply_on": "Item Code",
|
||||
"price_or_product_discount": "Price",
|
||||
"items": [{
|
||||
"item_code": "Water Flask",
|
||||
}],
|
||||
"selling": 1,
|
||||
"currency": "INR",
|
||||
"rate_or_discount": "Discount Percentage",
|
||||
"discount_percentage": 20,
|
||||
"company": "_Test Company"
|
||||
}
|
||||
rule = frappe.get_doc(pricing_rule_record)
|
||||
rule.insert()
|
||||
|
||||
si = create_sales_invoice(do_not_save=True, item_code="Water Flask")
|
||||
si.selling_price_list = "_Test Price List"
|
||||
si.save()
|
||||
|
||||
self.assertEqual(si.items[0].price_list_rate, 100)
|
||||
self.assertEqual(si.items[0].discount_percentage, 20)
|
||||
self.assertEqual(si.items[0].rate, 80)
|
||||
|
||||
si.ignore_pricing_rule = 1
|
||||
si.save()
|
||||
|
||||
self.assertEqual(si.items[0].discount_percentage, 0)
|
||||
self.assertEqual(si.items[0].rate, 100)
|
||||
|
||||
si.delete()
|
||||
rule.delete()
|
||||
frappe.get_doc("Item Price", {"item_code": "Water Flask"}).delete()
|
||||
item.delete()
|
||||
|
||||
|
||||
test_dependencies = ["Campaign"]
|
||||
|
||||
def make_pricing_rule(**args):
|
||||
|
||||
@@ -176,8 +176,8 @@ class PurchaseInvoice(BuyingController):
|
||||
|
||||
if self.supplier and account.account_type != "Payable":
|
||||
frappe.throw(
|
||||
_("Please ensure {} account is a Payable account. Change the account type to Payable or select a different account.")
|
||||
.format(frappe.bold("Credit To")), title=_("Invalid Account")
|
||||
_("Please ensure {} account {} is a Payable account. Change the account type to Payable or select a different account.")
|
||||
.format(frappe.bold("Credit To"), frappe.bold(self.credit_to)), title=_("Invalid Account")
|
||||
)
|
||||
|
||||
self.party_account_currency = account.account_currency
|
||||
@@ -535,8 +535,11 @@ class PurchaseInvoice(BuyingController):
|
||||
|
||||
voucher_wise_stock_value = {}
|
||||
if self.update_stock:
|
||||
for d in frappe.get_all('Stock Ledger Entry',
|
||||
fields = ["voucher_detail_no", "stock_value_difference", "warehouse"], filters={'voucher_no': self.name}):
|
||||
stock_ledger_entries = frappe.get_all("Stock Ledger Entry",
|
||||
fields = ["voucher_detail_no", "stock_value_difference", "warehouse"],
|
||||
filters={"voucher_no": self.name, "voucher_type": self.doctype, "is_cancelled": 0}
|
||||
)
|
||||
for d in stock_ledger_entries:
|
||||
voucher_wise_stock_value.setdefault((d.voucher_detail_no, d.warehouse), d.stock_value_difference)
|
||||
|
||||
valuation_tax_accounts = [d.account_head for d in self.get("taxes")
|
||||
|
||||
@@ -294,7 +294,7 @@ class SalesInvoice(SellingController):
|
||||
filters={ invoice_or_credit_note: self.name },
|
||||
pluck="pos_closing_entry"
|
||||
)
|
||||
if pos_closing_entry:
|
||||
if pos_closing_entry and pos_closing_entry[0]:
|
||||
msg = _("To cancel a {} you need to cancel the POS Closing Entry {}.").format(
|
||||
frappe.bold("Consolidated Sales Invoice"),
|
||||
get_link_to_form("POS Closing Entry", pos_closing_entry[0])
|
||||
@@ -587,7 +587,10 @@ class SalesInvoice(SellingController):
|
||||
frappe.throw(msg, title=_("Invalid Account"))
|
||||
|
||||
if self.customer and account.account_type != "Receivable":
|
||||
msg = _("Please ensure {} account is a Receivable account.").format(frappe.bold("Debit To")) + " "
|
||||
msg = _("Please ensure {} account {} is a Receivable account.").format(
|
||||
frappe.bold("Debit To"),
|
||||
frappe.bold(self.debit_to)
|
||||
) + " "
|
||||
msg += _("Change the account type to Receivable or select a different account.")
|
||||
frappe.throw(msg, title=_("Invalid Account"))
|
||||
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"autoname": "field:title",
|
||||
"creation": "2018-11-22 23:38:39.668804",
|
||||
"creation": "2022-01-19 01:09:28.920486",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"title"
|
||||
"title",
|
||||
"disabled"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -18,14 +19,21 @@
|
||||
"label": "Title",
|
||||
"reqd": 1,
|
||||
"unique": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "disabled",
|
||||
"fieldtype": "Check",
|
||||
"label": "Disabled"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2021-03-03 11:50:38.748872",
|
||||
"modified": "2022-01-18 21:13:41.161017",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Tax Category",
|
||||
"naming_rule": "By fieldname",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
@@ -65,5 +73,6 @@
|
||||
"quick_entry": 1,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"align_labels_right": 0,
|
||||
"creation": "2017-08-08 12:33:04.773099",
|
||||
"custom_format": 1,
|
||||
"disabled": 0,
|
||||
"doc_type": "Sales Invoice",
|
||||
"docstatus": 0,
|
||||
"doctype": "Print Format",
|
||||
"font": "Default",
|
||||
"html": "<style>\n\t.print-format table, .print-format tr, \n\t.print-format td, .print-format div, .print-format p {\n\t\tfont-family: Tahoma, sans-serif;\n\t\tline-height: 150%;\n\t\tvertical-align: middle;\n\t}\n\t@media screen {\n\t\t.print-format {\n\t\t\twidth: 4in;\n\t\t\tpadding: 0.25in;\n\t\t\tmin-height: 8in;\n\t\t}\n\t}\n</style>\n\n{% if letter_head %}\n {{ letter_head }}\n{% endif %}\n<p class=\"text-center\">\n\t{{ doc.company }}<br>\n\t{% if doc.company_address_display %}\n\t\t{% set company_address = doc.company_address_display.replace(\"\\n\", \" \").replace(\"<br>\", \" \") %}\n\t\t{% if \"GSTIN\" not in company_address %}\n\t\t\t{{ company_address }}\n\t\t\t<b>{{ _(\"GSTIN\") }}:</b>{{ doc.company_gstin }}\n\t\t{% else %}\n\t\t\t{{ company_address.replace(\"GSTIN\", \"<br>GSTIN\") }}\n\t\t{% endif %}\n\t{% endif %}\n\t<br>\n\t{% if doc.docstatus == 0 %}\n\t\t<b>{{ doc.status + \" \"+ (doc.select_print_heading or _(\"Invoice\")) }}</b><br>\n\t{% else %}\n\t\t<b>{{ doc.select_print_heading or _(\"Invoice\") }}</b><br>\n\t{% endif %}\n</p>\n<p>\n\t<b>{{ _(\"Receipt No\") }}:</b> {{ doc.name }}<br>\n\t<b>{{ _(\"Date\") }}:</b> {{ doc.get_formatted(\"posting_date\") }}<br>\n\t{% if doc.grand_total > 50000 %}\n\t\t{% set customer_address = doc.address_display.replace(\"\\n\", \" \").replace(\"<br>\", \" \") %}\n\t\t<b>{{ _(\"Customer\") }}:</b><br>\n\t\t{{ doc.customer_name }}<br>\n\t\t{{ customer_address }}\n\t{% endif %}\n</p>\n\n<hr>\n<table class=\"table table-condensed cart no-border\">\n\t<thead>\n\t\t<tr>\n\t\t\t<th width=\"50%\">{{ _(\"Item\") }}</b></th>\n\t\t\t<th width=\"25%\" class=\"text-right\">{{ _(\"Qty\") }}</th>\n\t\t\t<th width=\"25%\" class=\"text-right\">{{ _(\"Amount\") }}</th>\n\t\t</tr>\n\t</thead>\n\t<tbody>\n\t\t{%- for item in doc.items -%}\n\t\t<tr>\n\t\t\t<td>\n\t\t\t\t{{ item.item_code }}\n\t\t\t\t{%- if item.item_name != item.item_code -%}\n\t\t\t\t\t<br>{{ item.item_name }}\n\t\t\t\t{%- endif -%}\n\t\t\t\t{%- if item.gst_hsn_code -%}\n\t\t\t\t\t<br><b>{{ _(\"HSN/SAC\") }}:</b> {{ item.gst_hsn_code }}\n\t\t\t\t{%- endif -%}\n\t\t\t\t{%- if item.serial_no -%}\n\t\t\t\t\t<br><b>{{ _(\"Serial No\") }}:</b> {{ item.serial_no }}\n\t\t\t\t{%- endif -%}\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">{{ item.qty }}<br>@ {{ item.rate }}</td>\n\t\t\t<td class=\"text-right\">{{ item.get_formatted(\"amount\") }}</td>\n\t\t</tr>\n\t\t{%- endfor -%}\n\t</tbody>\n</table>\n<table class=\"table table-condensed no-border\">\n\t<tbody>\n\t\t<tr>\n\t\t\t{% if doc.flags.show_inclusive_tax_in_print %}\n\t\t\t\t<td class=\"text-right\" style=\"width: 70%\">\n\t\t\t\t\t{{ _(\"Total Excl. Tax\") }}\n\t\t\t\t</td>\n\t\t\t\t<td class=\"text-right\">\n\t\t\t\t\t{{ doc.get_formatted(\"net_total\", doc) }}\n\t\t\t\t</td>\n\t\t\t{% else %}\n\t\t\t\t<td class=\"text-right\" style=\"width: 70%\">\n\t\t\t\t\t{{ _(\"Total\") }}\n\t\t\t\t</td>\n\t\t\t\t<td class=\"text-right\">\n\t\t\t\t\t{{ doc.get_formatted(\"total\", doc) }}\n\t\t\t\t</td>\n\t\t\t{% endif %}\n\t\t</tr>\n\t\t{%- for row in doc.taxes -%}\n\t\t {%- if (not row.included_in_print_rate or doc.flags.show_inclusive_tax_in_print) and row.tax_amount != 0 -%}\n\t\t\t<tr>\n\t\t\t\t<td class=\"text-right\" style=\"width: 70%\">\n\t\t\t\t\t{{ row.description }}\n\t\t\t\t</td>\n\t\t\t\t<td class=\"text-right\">\n\t\t\t\t\t{{ row.get_formatted(\"tax_amount\", doc) }}\n\t\t\t\t</td>\n\t\t\t<tr>\n\t\t {%- endif -%}\n\t\t{%- endfor -%}\n\t\t{%- if doc.discount_amount -%}\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 75%\">\n\t\t\t\t{{ _(\"Discount\") }}\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ doc.get_formatted(\"discount_amount\") }}\n\t\t\t</td>\n\t\t</tr>\n\t\t{%- endif -%}\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 75%\">\n\t\t\t\t<b>{{ _(\"Grand Total\") }}</b>\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ doc.get_formatted(\"grand_total\") }}\n\t\t\t</td>\n\t\t</tr>\n\t\t{%- if doc.rounded_total -%}\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 75%\">\n\t\t\t\t<b>{{ _(\"Rounded Total\") }}</b>\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ doc.get_formatted(\"rounded_total\") }}\n\t\t\t</td>\n\t\t</tr>\n\t\t{%- endif -%}\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 75%\">\n\t\t\t\t<b>{{ _(\"Paid Amount\") }}</b>\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ doc.get_formatted(\"paid_amount\") }}\n\t\t\t</td>\n\t\t</tr>\n\t{%- if doc.change_amount -%}\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 75%\">\n\t\t\t\t<b>{{ _(\"Change Amount\") }}</b>\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ doc.get_formatted(\"change_amount\") }}\n\t\t\t</td>\n\t\t</tr>\n\t{%- endif -%}\n\t</tbody>\n</table>\n<p>{{ doc.terms or \"\" }}</p>\n<p class=\"text-center\">{{ _(\"Thank you, please visit again.\") }}</p>",
|
||||
"idx": 0,
|
||||
"line_breaks": 0,
|
||||
"modified": "2020-04-29 16:39:12.936215",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "GST POS Invoice",
|
||||
"owner": "Administrator",
|
||||
"print_format_builder": 0,
|
||||
"print_format_type": "Jinja",
|
||||
"raw_printing": 0,
|
||||
"show_section_headings": 0,
|
||||
"standard": "Yes"
|
||||
}
|
||||
@@ -120,11 +120,11 @@ def check_opening_balance(asset, liability, equity):
|
||||
opening_balance = 0
|
||||
float_precision = cint(frappe.db.get_default("float_precision")) or 2
|
||||
if asset:
|
||||
opening_balance = flt(asset[0].get("opening_balance", 0), float_precision)
|
||||
opening_balance = flt(asset[-1].get("opening_balance", 0), float_precision)
|
||||
if liability:
|
||||
opening_balance -= flt(liability[0].get("opening_balance", 0), float_precision)
|
||||
opening_balance -= flt(liability[-1].get("opening_balance", 0), float_precision)
|
||||
if equity:
|
||||
opening_balance -= flt(equity[0].get("opening_balance", 0), float_precision)
|
||||
opening_balance -= flt(equity[-1].get("opening_balance", 0), float_precision)
|
||||
|
||||
opening_balance = flt(opening_balance, float_precision)
|
||||
if opening_balance:
|
||||
|
||||
@@ -285,7 +285,8 @@ def add_total_row(out, root_type, balance_must_be, period_list, company_currency
|
||||
total_row = {
|
||||
"account_name": _("Total {0} ({1})").format(_(root_type), _(balance_must_be)),
|
||||
"account": _("Total {0} ({1})").format(_(root_type), _(balance_must_be)),
|
||||
"currency": company_currency
|
||||
"currency": company_currency,
|
||||
"opening_balance": 0.0
|
||||
}
|
||||
|
||||
for row in out:
|
||||
@@ -297,6 +298,7 @@ def add_total_row(out, root_type, balance_must_be, period_list, company_currency
|
||||
|
||||
total_row.setdefault("total", 0.0)
|
||||
total_row["total"] += flt(row["total"])
|
||||
total_row["opening_balance"] += row["opening_balance"]
|
||||
row["total"] = ""
|
||||
|
||||
if "total" in total_row:
|
||||
|
||||
@@ -42,6 +42,11 @@ frappe.query_reports["Gross Profit"] = {
|
||||
"parent_field": "parent_invoice",
|
||||
"initial_depth": 3,
|
||||
"formatter": function(value, row, column, data, default_formatter) {
|
||||
if (column.fieldname == "sales_invoice" && column.options == "Item" && data.indent == 0) {
|
||||
column._options = "Sales Invoice";
|
||||
} else {
|
||||
column._options = "Item";
|
||||
}
|
||||
value = default_formatter(value, row, column, data);
|
||||
|
||||
if (data && (data.indent == 0.0 || row[1].content == "Total")) {
|
||||
|
||||
@@ -23,7 +23,7 @@ def validate_filters(filters):
|
||||
def get_result(filters, tds_docs, tds_accounts, tax_category_map):
|
||||
supplier_map = get_supplier_pan_map()
|
||||
tax_rate_map = get_tax_rate_map(filters)
|
||||
gle_map = get_gle_map(filters, tds_docs)
|
||||
gle_map = get_gle_map(tds_docs)
|
||||
|
||||
out = []
|
||||
for name, details in gle_map.items():
|
||||
@@ -43,7 +43,7 @@ def get_result(filters, tds_docs, tds_accounts, tax_category_map):
|
||||
if entry.account in tds_accounts:
|
||||
tds_deducted += (entry.credit - entry.debit)
|
||||
|
||||
total_amount_credited += (entry.credit - entry.debit)
|
||||
total_amount_credited += entry.credit
|
||||
|
||||
if tds_deducted:
|
||||
row = {
|
||||
@@ -78,7 +78,7 @@ def get_supplier_pan_map():
|
||||
|
||||
return supplier_map
|
||||
|
||||
def get_gle_map(filters, documents):
|
||||
def get_gle_map(documents):
|
||||
# create gle_map of the form
|
||||
# {"purchase_invoice": list of dict of all gle created for this invoice}
|
||||
gle_map = {}
|
||||
@@ -86,7 +86,7 @@ def get_gle_map(filters, documents):
|
||||
gle = frappe.db.get_all('GL Entry',
|
||||
{
|
||||
"voucher_no": ["in", documents],
|
||||
"credit": (">", 0)
|
||||
"is_cancelled": 0
|
||||
},
|
||||
["credit", "debit", "account", "voucher_no", "posting_date", "voucher_type", "against", "party"],
|
||||
)
|
||||
@@ -184,21 +184,28 @@ def get_tds_docs(filters):
|
||||
payment_entries = []
|
||||
journal_entries = []
|
||||
tax_category_map = {}
|
||||
or_filters = {}
|
||||
bank_accounts = frappe.get_all('Account', {'is_group': 0, 'account_type': 'Bank'}, pluck="name")
|
||||
|
||||
tds_accounts = frappe.get_all("Tax Withholding Account", {'company': filters.get('company')},
|
||||
pluck="account")
|
||||
|
||||
query_filters = {
|
||||
"credit": ('>', 0),
|
||||
"account": ("in", tds_accounts),
|
||||
"posting_date": ("between", [filters.get("from_date"), filters.get("to_date")]),
|
||||
"is_cancelled": 0
|
||||
"is_cancelled": 0,
|
||||
"against": ("not in", bank_accounts)
|
||||
}
|
||||
|
||||
if filters.get('supplier'):
|
||||
query_filters.update({'against': filters.get('supplier')})
|
||||
if filters.get("supplier"):
|
||||
del query_filters["account"]
|
||||
del query_filters["against"]
|
||||
or_filters = {
|
||||
"against": filters.get('supplier'),
|
||||
"party": filters.get('supplier')
|
||||
}
|
||||
|
||||
tds_docs = frappe.get_all("GL Entry", query_filters, ["voucher_no", "voucher_type", "against", "party"])
|
||||
tds_docs = frappe.get_all("GL Entry", filters=query_filters, or_filters=or_filters, fields=["voucher_no", "voucher_type", "against", "party"])
|
||||
|
||||
for d in tds_docs:
|
||||
if d.voucher_type == "Purchase Invoice":
|
||||
|
||||
@@ -141,6 +141,7 @@
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"fetch_from": "item_code.image",
|
||||
"fieldname": "image",
|
||||
"fieldtype": "Attach Image",
|
||||
"hidden": 1,
|
||||
@@ -502,7 +503,7 @@
|
||||
"link_fieldname": "asset"
|
||||
}
|
||||
],
|
||||
"modified": "2021-06-24 14:58:51.097908",
|
||||
"modified": "2022-01-30 20:19:24.680027",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Assets",
|
||||
"name": "Asset",
|
||||
@@ -544,4 +545,4 @@
|
||||
"sort_order": "DESC",
|
||||
"title_field": "asset_name",
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ class Asset(AccountsController):
|
||||
self.validate_asset_values()
|
||||
self.validate_asset_and_reference()
|
||||
self.validate_item()
|
||||
self.validate_cost_center()
|
||||
self.set_missing_values()
|
||||
self.prepare_depreciation_data()
|
||||
self.validate_gross_and_purchase_amount()
|
||||
@@ -96,6 +97,19 @@ class Asset(AccountsController):
|
||||
elif item.is_stock_item:
|
||||
frappe.throw(_("Item {0} must be a non-stock item").format(self.item_code))
|
||||
|
||||
def validate_cost_center(self):
|
||||
if not self.cost_center: return
|
||||
|
||||
cost_center_company = frappe.db.get_value('Cost Center', self.cost_center, 'company')
|
||||
if cost_center_company != self.company:
|
||||
frappe.throw(
|
||||
_("Selected Cost Center {} doesn't belongs to {}").format(
|
||||
frappe.bold(self.cost_center),
|
||||
frappe.bold(self.company)
|
||||
),
|
||||
title=_("Invalid Cost Center")
|
||||
)
|
||||
|
||||
def validate_in_use_date(self):
|
||||
if not self.available_for_use_date:
|
||||
frappe.throw(_("Available for use date is required"))
|
||||
|
||||
@@ -1131,6 +1131,15 @@ class TestDepreciationBasics(AssetSetup):
|
||||
self.assertEqual(gle, expected_gle)
|
||||
self.assertEqual(asset.get("value_after_depreciation"), 0)
|
||||
|
||||
def test_asset_cost_center(self):
|
||||
asset = create_asset(is_existing_asset = 1, do_not_save=1)
|
||||
asset.cost_center = "Main - WP"
|
||||
|
||||
self.assertRaises(frappe.ValidationError, asset.submit)
|
||||
|
||||
asset.cost_center = "Main - _TC"
|
||||
asset.submit()
|
||||
|
||||
def create_asset_data():
|
||||
if not frappe.db.exists("Asset Category", "Computers"):
|
||||
create_asset_category()
|
||||
|
||||
@@ -682,17 +682,18 @@ class TestPurchaseOrder(unittest.TestCase):
|
||||
|
||||
bin1 = frappe.db.get_value("Bin",
|
||||
filters={"warehouse": "_Test Warehouse - _TC", "item_code": "_Test Item"},
|
||||
fieldname=["reserved_qty_for_sub_contract", "projected_qty"], as_dict=1)
|
||||
fieldname=["reserved_qty_for_sub_contract", "projected_qty", "modified"], as_dict=1)
|
||||
|
||||
# Submit PO
|
||||
po = create_purchase_order(item_code="_Test FG Item", is_subcontracted="Yes")
|
||||
|
||||
bin2 = frappe.db.get_value("Bin",
|
||||
filters={"warehouse": "_Test Warehouse - _TC", "item_code": "_Test Item"},
|
||||
fieldname=["reserved_qty_for_sub_contract", "projected_qty"], as_dict=1)
|
||||
fieldname=["reserved_qty_for_sub_contract", "projected_qty", "modified"], as_dict=1)
|
||||
|
||||
self.assertEqual(bin2.reserved_qty_for_sub_contract, bin1.reserved_qty_for_sub_contract + 10)
|
||||
self.assertEqual(bin2.projected_qty, bin1.projected_qty - 10)
|
||||
self.assertNotEqual(bin1.modified, bin2.modified)
|
||||
|
||||
# Create stock transfer
|
||||
rm_item = [{"item_code":"_Test FG Item","rm_item_code":"_Test Item","item_name":"_Test Item",
|
||||
|
||||
49
erpnext/change_log/v13/v13_21_0.md
Normal file
49
erpnext/change_log/v13/v13_21_0.md
Normal file
@@ -0,0 +1,49 @@
|
||||
## Version 13.21.0 Release Notes
|
||||
|
||||
### Features & Enhancements
|
||||
|
||||
- Provisional accounting for expenses ([#29451](https://github.com/frappe/erpnext/pull/29451))
|
||||
- Allowing non stock items in POS ([#29556](https://github.com/frappe/erpnext/pull/29556))
|
||||
- Option to disable Item Tax Template and Tax Category ([#29349](https://github.com/frappe/erpnext/pull/29349))
|
||||
|
||||
### Fixes
|
||||
|
||||
- Ignore linked invoices on Journal Entry cancel ([#29641](https://github.com/frappe/erpnext/pull/29641))
|
||||
- Do not hide Loan Repayment Entry field in salary slip ([#29535](https://github.com/frappe/erpnext/pull/29535))
|
||||
- Coupon code is applied even if ignore_pricing_rule is enabled ([#29859](https://github.com/frappe/erpnext/pull/29859))
|
||||
- Reserved for Production calculation considered closed work orders ([#29723](https://github.com/frappe/erpnext/pull/29723))
|
||||
- Disable rounded total in opening invoice creation tool ([#29789](https://github.com/frappe/erpnext/pull/29789))
|
||||
- Report GSTR-1 minor fixes ([#29700](https://github.com/frappe/erpnext/pull/29700))
|
||||
- Ignore rate validation for work order ([#29690](https://github.com/frappe/erpnext/pull/29690))
|
||||
- Incorrect provisional profit and loss in balance sheet ([#29601](https://github.com/frappe/erpnext/pull/29601))
|
||||
- Multiple WO for a single Production Plan Item ([#29603](https://github.com/frappe/erpnext/pull/29603))
|
||||
- Validation for invalid serial nos at POS invoice level ([#29447](https://github.com/frappe/erpnext/pull/29447))
|
||||
- Incorrect Grand Total in case of inclusive taxes on item ([#29701](https://github.com/frappe/erpnext/pull/29701))
|
||||
- Currency in bank reconciliation chart ([#29709](https://github.com/frappe/erpnext/pull/29709))
|
||||
- Set Pending Qty in Prod Plan after updating Work Order ([#29705](https://github.com/frappe/erpnext/pull/29705))
|
||||
- Enable Allow on Submit for 'Is Active' field in Salary Structure ([#29630](https://github.com/frappe/erpnext/pull/29630))
|
||||
- Bypass "Validate Selling Price for Item Against Purchase Rate or Valuation Rate" for free items ([#29359](https://github.com/frappe/erpnext/pull/29359))
|
||||
- Generate Warehouse wise FIFO Queue always and later aggregate if required ([#29788](https://github.com/frappe/erpnext/pull/29788))
|
||||
- Fixes in TDS payable monthly report ([#29791](https://github.com/frappe/erpnext/pull/29791))
|
||||
- Incorrect pricing rule filtering on selecting first item ([#29778](https://github.com/frappe/erpnext/pull/29778))
|
||||
- Stock Ageing Transfer Bucket logic for Repack Entry with split batch rows ([#29816](https://github.com/frappe/erpnext/pull/29816))
|
||||
- Incorrect packing list for recurring items & code cleanup ([#29456](https://github.com/frappe/erpnext/pull/29456))
|
||||
- Cost center validation of asset ([#29373](https://github.com/frappe/erpnext/pull/29373))
|
||||
- Coupon code item pricing dynamic updation issue in pos screen ([#29599](https://github.com/frappe/erpnext/pull/29599))
|
||||
- Billed amount in delivery note items ([#29290](https://github.com/frappe/erpnext/pull/29290))
|
||||
- Regenerate packed items on newly mapped doc ([#29642](https://github.com/frappe/erpnext/pull/29642))
|
||||
- Cannot jump to sales invoice in gross profit report ([#29748](https://github.com/frappe/erpnext/pull/29748))
|
||||
- Fetch image form item ([#29523](https://github.com/frappe/erpnext/pull/29523))
|
||||
- Add missing key in Loan ([#29660](https://github.com/frappe/erpnext/pull/29660))
|
||||
- Weed out disabled variants via sql query instead of pythonic looping separately ([#29639](https://github.com/frappe/erpnext/pull/29639 ())
|
||||
- Loan repayment via Salary Slip ([#29716](https://github.com/frappe/erpnext/pull/29716))
|
||||
- Earned leaves not allocated if assignment is created on month-end based on Leave Policy ([#29650](https://github.com/frappe/erpnext/pull/29650))
|
||||
- Time out error while making work orders from production plan ([#29736](https://github.com/frappe/erpnext/pull/29736))
|
||||
- Removal of coupon code ([#29896](https://github.com/frappe/erpnext/pull/29896))
|
||||
- Earned Leave allocation based on joining date fixes ([#29711](https://github.com/frappe/erpnext/pull/29711))
|
||||
- Total Credit amount in TDS Payable monthly report ([#29907](https://github.com/frappe/erpnext/pull/29907))
|
||||
- Pricing rule on transactions doesn't work ([#29597](https://github.com/frappe/erpnext/pull/29597))
|
||||
- Billing status for zero amount reference doc ([#29659](https://github.com/frappe/erpnext/pull/29659))
|
||||
- Zero rated exports in GSTR-3B report ([#29609](https://github.com/frappe/erpnext/pull/29609))
|
||||
- Update SO via Work Order made from MR ([#29803](https://github.com/frappe/erpnext/pull/29803))
|
||||
- Currency in bank reconciliation tool ([#29848](https://github.com/frappe/erpnext/pull/29848))
|
||||
@@ -408,6 +408,22 @@ class AccountsController(TransactionBase):
|
||||
if item_qty != len(get_serial_nos(item.get('serial_no'))):
|
||||
item.set(fieldname, value)
|
||||
|
||||
elif (
|
||||
ret.get("pricing_rule_removed")
|
||||
and value is not None
|
||||
and fieldname
|
||||
in [
|
||||
"discount_percentage",
|
||||
"discount_amount",
|
||||
"rate",
|
||||
"margin_rate_or_amount",
|
||||
"margin_type",
|
||||
"remove_free_item",
|
||||
]
|
||||
):
|
||||
# reset pricing rule fields if pricing_rule_removed
|
||||
item.set(fieldname, value)
|
||||
|
||||
if self.doctype in ["Purchase Invoice", "Sales Invoice"] and item.meta.get_field('is_fixed_asset'):
|
||||
item.set('is_fixed_asset', ret.get('is_fixed_asset', 0))
|
||||
|
||||
@@ -1319,6 +1335,9 @@ class AccountsController(TransactionBase):
|
||||
payment_schedule['discount_type'] = schedule.discount_type
|
||||
payment_schedule['discount'] = schedule.discount
|
||||
|
||||
if not schedule.invoice_portion:
|
||||
payment_schedule['payment_amount'] = schedule.payment_amount
|
||||
|
||||
self.append("payment_schedule", payment_schedule)
|
||||
|
||||
def set_due_date(self):
|
||||
@@ -1937,7 +1956,8 @@ def update_bin_on_delete(row, doctype):
|
||||
|
||||
qty_dict["ordered_qty"] = get_ordered_qty(row.item_code, row.warehouse)
|
||||
|
||||
update_bin_qty(row.item_code, row.warehouse, qty_dict)
|
||||
if row.warehouse:
|
||||
update_bin_qty(row.item_code, row.warehouse, qty_dict)
|
||||
|
||||
def validate_and_delete_children(parent, data):
|
||||
deleted_children = []
|
||||
|
||||
@@ -70,9 +70,18 @@ class BuyingController(StockController, Subcontracting):
|
||||
|
||||
# set contact and address details for supplier, if they are not mentioned
|
||||
if getattr(self, "supplier", None):
|
||||
self.update_if_missing(get_party_details(self.supplier, party_type="Supplier", ignore_permissions=self.flags.ignore_permissions,
|
||||
doctype=self.doctype, company=self.company, party_address=self.supplier_address, shipping_address=self.get('shipping_address'),
|
||||
fetch_payment_terms_template= not self.get('ignore_default_payment_terms_template')))
|
||||
self.update_if_missing(
|
||||
get_party_details(
|
||||
self.supplier,
|
||||
party_type="Supplier",
|
||||
doctype=self.doctype,
|
||||
company=self.company,
|
||||
party_address=self.get("supplier_address"),
|
||||
shipping_address=self.get('shipping_address'),
|
||||
fetch_payment_terms_template= not self.get('ignore_default_payment_terms_template'),
|
||||
ignore_permissions=self.flags.ignore_permissions
|
||||
)
|
||||
)
|
||||
|
||||
self.set_missing_item_details(for_validate)
|
||||
|
||||
|
||||
@@ -740,6 +740,7 @@ def get_tax_template(doctype, txt, searchfield, start, page_len, filters):
|
||||
|
||||
item_doc = frappe.get_cached_doc('Item', filters.get('item_code'))
|
||||
item_group = filters.get('item_group')
|
||||
company = filters.get('company')
|
||||
taxes = item_doc.taxes or []
|
||||
|
||||
while item_group:
|
||||
@@ -748,7 +749,7 @@ def get_tax_template(doctype, txt, searchfield, start, page_len, filters):
|
||||
item_group = item_group_doc.parent_item_group
|
||||
|
||||
if not taxes:
|
||||
return frappe.db.sql(""" SELECT name FROM `tabItem Tax Template` """)
|
||||
return frappe.get_all('Item Tax Template', filters={'disabled': 0, 'company': company}, as_list=True)
|
||||
else:
|
||||
valid_from = filters.get('valid_from')
|
||||
valid_from = valid_from[1] if isinstance(valid_from, list) else valid_from
|
||||
@@ -757,7 +758,7 @@ def get_tax_template(doctype, txt, searchfield, start, page_len, filters):
|
||||
'item_code': filters.get('item_code'),
|
||||
'posting_date': valid_from,
|
||||
'tax_category': filters.get('tax_category'),
|
||||
'company': filters.get('company')
|
||||
'company': company
|
||||
}
|
||||
|
||||
taxes = _get_item_tax_template(args, taxes, for_validate=True)
|
||||
|
||||
@@ -74,7 +74,8 @@ class SellingController(StockController):
|
||||
doctype=self.doctype, company=self.company,
|
||||
posting_date=self.get('posting_date'),
|
||||
fetch_payment_terms_template=fetch_payment_terms_template,
|
||||
party_address=self.customer_address, shipping_address=self.shipping_address_name)
|
||||
party_address=self.customer_address, shipping_address=self.shipping_address_name,
|
||||
company_address=self.get('company_address'))
|
||||
if not self.meta.get_field("sales_team"):
|
||||
party_details.pop("sales_team")
|
||||
self.update_if_missing(party_details)
|
||||
@@ -204,7 +205,7 @@ class SellingController(StockController):
|
||||
valuation_rate_map = {}
|
||||
|
||||
for item in self.items:
|
||||
if not item.item_code:
|
||||
if not item.item_code or item.is_free_item:
|
||||
continue
|
||||
|
||||
last_purchase_rate, is_stock_item = frappe.get_cached_value(
|
||||
@@ -251,7 +252,7 @@ class SellingController(StockController):
|
||||
valuation_rate_map[(rate.item_code, rate.warehouse)] = rate.valuation_rate
|
||||
|
||||
for item in self.items:
|
||||
if not item.item_code:
|
||||
if not item.item_code or item.is_free_item:
|
||||
continue
|
||||
|
||||
last_valuation_rate = valuation_rate_map.get(
|
||||
|
||||
@@ -400,6 +400,16 @@ class StatusUpdater(Document):
|
||||
ref_doc = frappe.get_doc(ref_dt, ref_dn)
|
||||
|
||||
ref_doc.db_set("per_billed", per_billed)
|
||||
|
||||
# set billling status
|
||||
if hasattr(ref_doc, 'billing_status'):
|
||||
if ref_doc.per_billed < 0.001:
|
||||
ref_doc.db_set("billing_status", "Not Billed")
|
||||
elif ref_doc.per_billed > 99.999999:
|
||||
ref_doc.db_set("billing_status", "Fully Billed")
|
||||
else:
|
||||
ref_doc.db_set("billing_status", "Partly Billed")
|
||||
|
||||
ref_doc.set_status(update=True)
|
||||
|
||||
def get_allowance_for(item_code, item_allowance=None, global_qty_allowance=None, global_amount_allowance=None, qty_or_amount="qty"):
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from typing import List, Tuple
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
@@ -181,33 +182,28 @@ class StockController(AccountsController):
|
||||
|
||||
return details
|
||||
|
||||
def get_items_and_warehouses(self):
|
||||
items, warehouses = [], []
|
||||
def get_items_and_warehouses(self) -> Tuple[List[str], List[str]]:
|
||||
"""Get list of items and warehouses affected by a transaction"""
|
||||
|
||||
if hasattr(self, "items"):
|
||||
item_doclist = self.get("items")
|
||||
elif self.doctype == "Stock Reconciliation":
|
||||
item_doclist = []
|
||||
data = json.loads(self.reconciliation_json)
|
||||
for row in data[data.index(self.head_row)+1:]:
|
||||
d = frappe._dict(zip(["item_code", "warehouse", "qty", "valuation_rate"], row))
|
||||
item_doclist.append(d)
|
||||
if not (hasattr(self, "items") or hasattr(self, "packed_items")):
|
||||
return [], []
|
||||
|
||||
if item_doclist:
|
||||
for d in item_doclist:
|
||||
if d.item_code and d.item_code not in items:
|
||||
items.append(d.item_code)
|
||||
item_rows = (self.get("items") or []) + (self.get("packed_items") or [])
|
||||
|
||||
if d.get("warehouse") and d.warehouse not in warehouses:
|
||||
warehouses.append(d.warehouse)
|
||||
items = {d.item_code for d in item_rows if d.item_code}
|
||||
|
||||
if self.doctype == "Stock Entry":
|
||||
if d.get("s_warehouse") and d.s_warehouse not in warehouses:
|
||||
warehouses.append(d.s_warehouse)
|
||||
if d.get("t_warehouse") and d.t_warehouse not in warehouses:
|
||||
warehouses.append(d.t_warehouse)
|
||||
warehouses = set()
|
||||
for d in item_rows:
|
||||
if d.get("warehouse"):
|
||||
warehouses.add(d.warehouse)
|
||||
|
||||
return items, warehouses
|
||||
if self.doctype == "Stock Entry":
|
||||
if d.get("s_warehouse"):
|
||||
warehouses.add(d.s_warehouse)
|
||||
if d.get("t_warehouse"):
|
||||
warehouses.add(d.t_warehouse)
|
||||
|
||||
return list(items), list(warehouses)
|
||||
|
||||
def get_stock_ledger_details(self):
|
||||
stock_ledger = {}
|
||||
@@ -219,7 +215,7 @@ class StockController(AccountsController):
|
||||
from
|
||||
`tabStock Ledger Entry`
|
||||
where
|
||||
voucher_type=%s and voucher_no=%s
|
||||
voucher_type=%s and voucher_no=%s and is_cancelled = 0
|
||||
""", (self.doctype, self.name), as_dict=True)
|
||||
|
||||
for sle in stock_ledger_entries:
|
||||
|
||||
@@ -106,6 +106,9 @@ class calculate_taxes_and_totals(object):
|
||||
self.doc.conversion_rate = flt(self.doc.conversion_rate)
|
||||
|
||||
def calculate_item_values(self):
|
||||
if self.doc.get('is_consolidated'):
|
||||
return
|
||||
|
||||
if not self.discount_amount_applied:
|
||||
for item in self.doc.get("items"):
|
||||
self.doc.round_floats_in(item)
|
||||
@@ -646,12 +649,12 @@ class calculate_taxes_and_totals(object):
|
||||
def calculate_change_amount(self):
|
||||
self.doc.change_amount = 0.0
|
||||
self.doc.base_change_amount = 0.0
|
||||
grand_total = self.doc.rounded_total or self.doc.grand_total
|
||||
base_grand_total = self.doc.base_rounded_total or self.doc.base_grand_total
|
||||
|
||||
if self.doc.doctype == "Sales Invoice" \
|
||||
and self.doc.paid_amount > self.doc.grand_total and not self.doc.is_return \
|
||||
and self.doc.paid_amount > grand_total and not self.doc.is_return \
|
||||
and any(d.type == "Cash" for d in self.doc.payments):
|
||||
grand_total = self.doc.rounded_total or self.doc.grand_total
|
||||
base_grand_total = self.doc.base_rounded_total or self.doc.base_grand_total
|
||||
|
||||
self.doc.change_amount = flt(self.doc.paid_amount - grand_total +
|
||||
self.doc.write_off_amount, self.doc.precision("change_amount"))
|
||||
|
||||
@@ -66,26 +66,24 @@ class ItemVariantsCacheManager:
|
||||
)
|
||||
]
|
||||
|
||||
# join with Website Item
|
||||
item_variants_data = frappe.get_all(
|
||||
'Item Variant Attribute',
|
||||
{'variant_of': parent_item_code},
|
||||
['parent', 'attribute', 'attribute_value'],
|
||||
order_by='name',
|
||||
as_list=1
|
||||
)
|
||||
|
||||
disabled_items = set(
|
||||
[i.name for i in frappe.db.get_all('Item', {'disabled': 1})]
|
||||
# Get Variants and tehir Attributes that are not disabled
|
||||
iva = frappe.qb.DocType("Item Variant Attribute")
|
||||
item = frappe.qb.DocType("Item")
|
||||
query = (
|
||||
frappe.qb.from_(iva)
|
||||
.join(item).on(item.name == iva.parent)
|
||||
.select(
|
||||
iva.parent, iva.attribute, iva.attribute_value
|
||||
).where(
|
||||
(iva.variant_of == parent_item_code)
|
||||
& (item.disabled == 0)
|
||||
).orderby(iva.name)
|
||||
)
|
||||
item_variants_data = query.run()
|
||||
|
||||
attribute_value_item_map = frappe._dict()
|
||||
item_attribute_value_map = frappe._dict()
|
||||
|
||||
# dont consider variants that are disabled
|
||||
# pull all other variants
|
||||
item_variants_data = [r for r in item_variants_data if r[0] not in disabled_items]
|
||||
|
||||
for row in item_variants_data:
|
||||
item_code, attribute, attribute_value = row
|
||||
# (attr, value) => [item1, item2]
|
||||
@@ -124,4 +122,7 @@ def build_cache(item_code):
|
||||
def enqueue_build_cache(item_code):
|
||||
if frappe.cache().hget('item_cache_build_in_progress', item_code):
|
||||
return
|
||||
frappe.enqueue(build_cache, item_code=item_code, queue='long')
|
||||
frappe.enqueue(
|
||||
"erpnext.e_commerce.variant_selector.item_variants_cache.build_cache",
|
||||
item_code=item_code, queue='long'
|
||||
)
|
||||
|
||||
@@ -106,6 +106,8 @@ class TestVariantSelector(ERPNextTestCase):
|
||||
})
|
||||
|
||||
make_web_item_price(item_code="Test-Tshirt-Temp-S-R", price_list_rate=100)
|
||||
|
||||
frappe.local.shopping_cart_settings = None # clear cached settings values
|
||||
next_values = get_next_attribute_and_values(
|
||||
"Test-Tshirt-Temp",
|
||||
selected_attributes={"Test Size": "Small", "Test Colour": "Red"}
|
||||
|
||||
@@ -6,6 +6,7 @@ import frappe
|
||||
from frappe import _, msgprint
|
||||
from frappe.desk.reportview import get_match_cond
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder.functions import Min
|
||||
from frappe.utils import comma_and, get_link_to_form, getdate
|
||||
|
||||
|
||||
@@ -60,8 +61,15 @@ class ProgramEnrollment(Document):
|
||||
frappe.throw(_("Student is already enrolled."))
|
||||
|
||||
def update_student_joining_date(self):
|
||||
date = frappe.db.sql("select min(enrollment_date) from `tabProgram Enrollment` where student= %s", self.student)
|
||||
frappe.db.set_value("Student", self.student, "joining_date", date)
|
||||
table = frappe.qb.DocType('Program Enrollment')
|
||||
date = (
|
||||
frappe.qb.from_(table)
|
||||
.select(Min(table.enrollment_date).as_('enrollment_date'))
|
||||
.where(table.student == self.student)
|
||||
).run(as_dict=True)
|
||||
|
||||
if date:
|
||||
frappe.db.set_value("Student", self.student, "joining_date", date[0].enrollment_date)
|
||||
|
||||
def make_fee_records(self):
|
||||
from erpnext.education.api import get_fee_components
|
||||
|
||||
@@ -1,2 +1,10 @@
|
||||
// Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
|
||||
frappe.ui.form.on('Amazon MWS Settings', {
|
||||
refresh: function (frm) {
|
||||
let app_link = "<a href='https://github.com/frappe/ecommerce_integrations' target='_blank'>Ecommerce Integrations</a>"
|
||||
frm.dashboard.add_comment(__("Amazon MWS Integration will be removed from ERPNext in Version 14. Please install {0} app to continue using it.", [app_link]), "yellow", true);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ def send_reminders_in_advance_weekly():
|
||||
|
||||
send_advance_holiday_reminders("Weekly")
|
||||
|
||||
|
||||
def send_reminders_in_advance_monthly():
|
||||
to_send_in_advance = int(frappe.db.get_single_value("HR Settings", "send_holiday_reminders"))
|
||||
frequency = frappe.db.get_single_value("HR Settings", "frequency")
|
||||
@@ -28,6 +29,7 @@ def send_reminders_in_advance_monthly():
|
||||
|
||||
send_advance_holiday_reminders("Monthly")
|
||||
|
||||
|
||||
def send_advance_holiday_reminders(frequency):
|
||||
"""Send Holiday Reminders in Advance to Employees
|
||||
`frequency` (str): 'Weekly' or 'Monthly'
|
||||
@@ -42,7 +44,7 @@ def send_advance_holiday_reminders(frequency):
|
||||
else:
|
||||
return
|
||||
|
||||
employees = frappe.db.get_all('Employee', pluck='name')
|
||||
employees = frappe.db.get_all('Employee', filters={'status': 'Active'}, pluck='name')
|
||||
for employee in employees:
|
||||
holidays = get_holidays_for_employee(
|
||||
employee,
|
||||
@@ -51,10 +53,13 @@ def send_advance_holiday_reminders(frequency):
|
||||
raise_exception=False
|
||||
)
|
||||
|
||||
if not (holidays is None):
|
||||
send_holidays_reminder_in_advance(employee, holidays)
|
||||
send_holidays_reminder_in_advance(employee, holidays)
|
||||
|
||||
|
||||
def send_holidays_reminder_in_advance(employee, holidays):
|
||||
if not holidays:
|
||||
return
|
||||
|
||||
employee_doc = frappe.get_doc('Employee', employee)
|
||||
employee_email = get_employee_email(employee_doc)
|
||||
frequency = frappe.db.get_single_value("HR Settings", "frequency")
|
||||
@@ -101,6 +106,7 @@ def send_birthday_reminders():
|
||||
reminder_text, message = get_birthday_reminder_text_and_message(others)
|
||||
send_birthday_reminder(person_email, reminder_text, others, message)
|
||||
|
||||
|
||||
def get_birthday_reminder_text_and_message(birthday_persons):
|
||||
if len(birthday_persons) == 1:
|
||||
birthday_person_text = birthday_persons[0]['name']
|
||||
@@ -116,6 +122,7 @@ def get_birthday_reminder_text_and_message(birthday_persons):
|
||||
|
||||
return reminder_text, message
|
||||
|
||||
|
||||
def send_birthday_reminder(recipients, reminder_text, birthday_persons, message):
|
||||
frappe.sendmail(
|
||||
recipients=recipients,
|
||||
@@ -129,10 +136,12 @@ def send_birthday_reminder(recipients, reminder_text, birthday_persons, message)
|
||||
header=_("Birthday Reminder 🎂")
|
||||
)
|
||||
|
||||
|
||||
def get_employees_who_are_born_today():
|
||||
"""Get all employee born today & group them based on their company"""
|
||||
return get_employees_having_an_event_today("birthday")
|
||||
|
||||
|
||||
def get_employees_having_an_event_today(event_type):
|
||||
"""Get all employee who have `event_type` today
|
||||
& group them based on their company. `event_type`
|
||||
@@ -210,13 +219,14 @@ def send_work_anniversary_reminders():
|
||||
reminder_text, message = get_work_anniversary_reminder_text_and_message(others)
|
||||
send_work_anniversary_reminder(person_email, reminder_text, others, message)
|
||||
|
||||
|
||||
def get_work_anniversary_reminder_text_and_message(anniversary_persons):
|
||||
if len(anniversary_persons) == 1:
|
||||
anniversary_person = anniversary_persons[0]['name']
|
||||
persons_name = anniversary_person
|
||||
# Number of years completed at the company
|
||||
completed_years = getdate().year - anniversary_persons[0]['date_of_joining'].year
|
||||
anniversary_person += f" completed {completed_years} years"
|
||||
anniversary_person += f" completed {completed_years} year(s)"
|
||||
else:
|
||||
person_names_with_years = []
|
||||
names = []
|
||||
@@ -225,7 +235,7 @@ def get_work_anniversary_reminder_text_and_message(anniversary_persons):
|
||||
names.append(person_text)
|
||||
# Number of years completed at the company
|
||||
completed_years = getdate().year - person['date_of_joining'].year
|
||||
person_text += f" completed {completed_years} years"
|
||||
person_text += f" completed {completed_years} year(s)"
|
||||
person_names_with_years.append(person_text)
|
||||
|
||||
# converts ["Jim", "Rim", "Dim"] to Jim, Rim & Dim
|
||||
@@ -239,6 +249,7 @@ def get_work_anniversary_reminder_text_and_message(anniversary_persons):
|
||||
|
||||
return reminder_text, message
|
||||
|
||||
|
||||
def send_work_anniversary_reminder(recipients, reminder_text, anniversary_persons, message):
|
||||
frappe.sendmail(
|
||||
recipients=recipients,
|
||||
@@ -249,5 +260,5 @@ def send_work_anniversary_reminder(recipients, reminder_text, anniversary_person
|
||||
anniversary_persons=anniversary_persons,
|
||||
message=message,
|
||||
),
|
||||
header=_("🎊️🎊️ Work Anniversary Reminder 🎊️🎊️")
|
||||
header=_("Work Anniversary Reminder")
|
||||
)
|
||||
|
||||
@@ -36,7 +36,7 @@ class TestEmployee(unittest.TestCase):
|
||||
employee_doc.reload()
|
||||
|
||||
make_holiday_list()
|
||||
frappe.db.set_value("Company", erpnext.get_default_company(), "default_holiday_list", "Salary Slip Test Holiday List")
|
||||
frappe.db.set_value("Company", employee_doc.company, "default_holiday_list", "Salary Slip Test Holiday List")
|
||||
|
||||
frappe.db.sql("""delete from `tabSalary Structure` where name='Test Inactive Employee Salary Slip'""")
|
||||
salary_structure = make_salary_structure("Test Inactive Employee Salary Slip", "Monthly",
|
||||
|
||||
@@ -5,10 +5,12 @@ import unittest
|
||||
from datetime import timedelta
|
||||
|
||||
import frappe
|
||||
from frappe.utils import getdate
|
||||
from frappe.utils import add_months, getdate
|
||||
|
||||
from erpnext.hr.doctype.employee.employee_reminders import send_holidays_reminder_in_advance
|
||||
from erpnext.hr.doctype.employee.test_employee import make_employee
|
||||
from erpnext.hr.doctype.hr_settings.hr_settings import set_proceed_with_frequency_change
|
||||
from erpnext.hr.utils import get_holidays_for_employee
|
||||
|
||||
|
||||
class TestEmployeeReminders(unittest.TestCase):
|
||||
@@ -46,6 +48,24 @@ class TestEmployeeReminders(unittest.TestCase):
|
||||
cls.test_employee = test_employee
|
||||
cls.test_holiday_dates = test_holiday_dates
|
||||
|
||||
# Employee without holidays in this month/week
|
||||
test_employee_2 = make_employee('test@empwithoutholiday.io', company="_Test Company")
|
||||
test_employee_2 = frappe.get_doc('Employee', test_employee_2)
|
||||
|
||||
test_holiday_list = make_holiday_list(
|
||||
'TestHolidayRemindersList2',
|
||||
holiday_dates=[
|
||||
{'holiday_date': add_months(getdate(), 1), 'description': 'test holiday1'},
|
||||
],
|
||||
from_date=add_months(getdate(), -2),
|
||||
to_date=add_months(getdate(), 2)
|
||||
)
|
||||
test_employee_2.holiday_list = test_holiday_list.name
|
||||
test_employee_2.save()
|
||||
|
||||
cls.test_employee_2 = test_employee_2
|
||||
cls.holiday_list_2 = test_holiday_list
|
||||
|
||||
@classmethod
|
||||
def get_test_holiday_dates(cls):
|
||||
today_date = getdate()
|
||||
@@ -61,6 +81,7 @@ class TestEmployeeReminders(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# Clear Email Queue
|
||||
frappe.db.sql("delete from `tabEmail Queue`")
|
||||
frappe.db.sql("delete from `tabEmail Queue Recipient`")
|
||||
|
||||
def test_is_holiday(self):
|
||||
from erpnext.hr.doctype.employee.employee import is_holiday
|
||||
@@ -103,11 +124,10 @@ class TestEmployeeReminders(unittest.TestCase):
|
||||
self.assertTrue("Subject: Birthday Reminder" in email_queue[0].message)
|
||||
|
||||
def test_work_anniversary_reminders(self):
|
||||
employee = frappe.get_doc("Employee", frappe.db.sql_list("select name from tabEmployee limit 1")[0])
|
||||
employee.date_of_joining = "1998" + frappe.utils.nowdate()[4:]
|
||||
employee.company_email = "test@example.com"
|
||||
employee.company = "_Test Company"
|
||||
employee.save()
|
||||
make_employee("test_work_anniversary@gmail.com",
|
||||
date_of_joining="1998" + frappe.utils.nowdate()[4:],
|
||||
company="_Test Company",
|
||||
)
|
||||
|
||||
from erpnext.hr.doctype.employee.employee_reminders import (
|
||||
get_employees_having_an_event_today,
|
||||
@@ -115,7 +135,12 @@ class TestEmployeeReminders(unittest.TestCase):
|
||||
)
|
||||
|
||||
employees_having_work_anniversary = get_employees_having_an_event_today('work_anniversary')
|
||||
self.assertTrue(employees_having_work_anniversary.get("_Test Company"))
|
||||
employees = employees_having_work_anniversary.get("_Test Company") or []
|
||||
user_ids = []
|
||||
for entry in employees:
|
||||
user_ids.append(entry.user_id)
|
||||
|
||||
self.assertTrue("test_work_anniversary@gmail.com" in user_ids)
|
||||
|
||||
hr_settings = frappe.get_doc("HR Settings", "HR Settings")
|
||||
hr_settings.send_work_anniversary_reminders = 1
|
||||
@@ -126,16 +151,24 @@ class TestEmployeeReminders(unittest.TestCase):
|
||||
email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True)
|
||||
self.assertTrue("Subject: Work Anniversary Reminder" in email_queue[0].message)
|
||||
|
||||
def test_send_holidays_reminder_in_advance(self):
|
||||
from erpnext.hr.doctype.employee.employee_reminders import send_holidays_reminder_in_advance
|
||||
from erpnext.hr.utils import get_holidays_for_employee
|
||||
def test_work_anniversary_reminder_not_sent_for_0_years(self):
|
||||
make_employee("test_work_anniversary_2@gmail.com",
|
||||
date_of_joining=getdate(),
|
||||
company="_Test Company",
|
||||
)
|
||||
|
||||
# Get HR settings and enable advance holiday reminders
|
||||
hr_settings = frappe.get_doc("HR Settings", "HR Settings")
|
||||
hr_settings.send_holiday_reminders = 1
|
||||
set_proceed_with_frequency_change()
|
||||
hr_settings.frequency = 'Weekly'
|
||||
hr_settings.save()
|
||||
from erpnext.hr.doctype.employee.employee_reminders import get_employees_having_an_event_today
|
||||
|
||||
employees_having_work_anniversary = get_employees_having_an_event_today('work_anniversary')
|
||||
employees = employees_having_work_anniversary.get("_Test Company") or []
|
||||
user_ids = []
|
||||
for entry in employees:
|
||||
user_ids.append(entry.user_id)
|
||||
|
||||
self.assertTrue("test_work_anniversary_2@gmail.com" not in user_ids)
|
||||
|
||||
def test_send_holidays_reminder_in_advance(self):
|
||||
setup_hr_settings('Weekly')
|
||||
|
||||
holidays = get_holidays_for_employee(
|
||||
self.test_employee.get('name'),
|
||||
@@ -151,32 +184,80 @@ class TestEmployeeReminders(unittest.TestCase):
|
||||
|
||||
email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True)
|
||||
self.assertEqual(len(email_queue), 1)
|
||||
self.assertTrue("Holidays this Week." in email_queue[0].message)
|
||||
|
||||
def test_advance_holiday_reminders_monthly(self):
|
||||
from erpnext.hr.doctype.employee.employee_reminders import send_reminders_in_advance_monthly
|
||||
|
||||
# Get HR settings and enable advance holiday reminders
|
||||
hr_settings = frappe.get_doc("HR Settings", "HR Settings")
|
||||
hr_settings.send_holiday_reminders = 1
|
||||
set_proceed_with_frequency_change()
|
||||
hr_settings.frequency = 'Monthly'
|
||||
hr_settings.save()
|
||||
setup_hr_settings('Monthly')
|
||||
|
||||
# disable emp 2, set same holiday list
|
||||
frappe.db.set_value('Employee', self.test_employee_2.name, {
|
||||
'status': 'Left',
|
||||
'holiday_list': self.test_employee.holiday_list
|
||||
})
|
||||
|
||||
send_reminders_in_advance_monthly()
|
||||
|
||||
email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True)
|
||||
self.assertTrue(len(email_queue) > 0)
|
||||
|
||||
# even though emp 2 has holiday, non-active employees should not be recipients
|
||||
recipients = frappe.db.get_all('Email Queue Recipient', pluck='recipient')
|
||||
self.assertTrue(self.test_employee_2.user_id not in recipients)
|
||||
|
||||
# teardown: enable emp 2
|
||||
frappe.db.set_value('Employee', self.test_employee_2.name, {
|
||||
'status': 'Active',
|
||||
'holiday_list': self.holiday_list_2.name
|
||||
})
|
||||
|
||||
def test_advance_holiday_reminders_weekly(self):
|
||||
from erpnext.hr.doctype.employee.employee_reminders import send_reminders_in_advance_weekly
|
||||
|
||||
# Get HR settings and enable advance holiday reminders
|
||||
hr_settings = frappe.get_doc("HR Settings", "HR Settings")
|
||||
hr_settings.send_holiday_reminders = 1
|
||||
hr_settings.frequency = 'Weekly'
|
||||
hr_settings.save()
|
||||
setup_hr_settings('Weekly')
|
||||
|
||||
# disable emp 2, set same holiday list
|
||||
frappe.db.set_value('Employee', self.test_employee_2.name, {
|
||||
'status': 'Left',
|
||||
'holiday_list': self.test_employee.holiday_list
|
||||
})
|
||||
|
||||
send_reminders_in_advance_weekly()
|
||||
|
||||
email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True)
|
||||
self.assertTrue(len(email_queue) > 0)
|
||||
|
||||
# even though emp 2 has holiday, non-active employees should not be recipients
|
||||
recipients = frappe.db.get_all('Email Queue Recipient', pluck='recipient')
|
||||
self.assertTrue(self.test_employee_2.user_id not in recipients)
|
||||
|
||||
# teardown: enable emp 2
|
||||
frappe.db.set_value('Employee', self.test_employee_2.name, {
|
||||
'status': 'Active',
|
||||
'holiday_list': self.holiday_list_2.name
|
||||
})
|
||||
|
||||
def test_reminder_not_sent_if_no_holdays(self):
|
||||
setup_hr_settings('Monthly')
|
||||
|
||||
# reminder not sent if there are no holidays
|
||||
holidays = get_holidays_for_employee(
|
||||
self.test_employee_2.get('name'),
|
||||
getdate(), getdate() + timedelta(days=3),
|
||||
only_non_weekly=True,
|
||||
raise_exception=False
|
||||
)
|
||||
send_holidays_reminder_in_advance(
|
||||
self.test_employee_2.get('name'),
|
||||
holidays
|
||||
)
|
||||
email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True)
|
||||
self.assertEqual(len(email_queue), 0)
|
||||
|
||||
|
||||
def setup_hr_settings(frequency=None):
|
||||
# Get HR settings and enable advance holiday reminders
|
||||
hr_settings = frappe.get_doc("HR Settings", "HR Settings")
|
||||
hr_settings.send_holiday_reminders = 1
|
||||
set_proceed_with_frequency_change()
|
||||
hr_settings.frequency = frequency or 'Weekly'
|
||||
hr_settings.save()
|
||||
@@ -27,12 +27,13 @@
|
||||
"fetch_from": "employee.user_id",
|
||||
"fieldname": "user_id",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "ERPNext User ID",
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"istable": 1,
|
||||
"modified": "2019-06-06 10:41:20.313756",
|
||||
"modified": "2022-02-13 19:44:21.302938",
|
||||
"modified_by": "Administrator",
|
||||
"module": "HR",
|
||||
"name": "Employee Group Table",
|
||||
@@ -42,4 +43,4 @@
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,10 +75,8 @@ class TestLeaveApplication(unittest.TestCase):
|
||||
frappe.db.sql("DELETE FROM `tab%s`" % dt) #nosec
|
||||
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
set_leave_approver()
|
||||
|
||||
frappe.db.sql("delete from tabAttendance where employee='_T-Employee-00001'")
|
||||
|
||||
def tearDown(self):
|
||||
@@ -134,10 +132,11 @@ class TestLeaveApplication(unittest.TestCase):
|
||||
make_allocation_record(leave_type=leave_type.name, from_date=get_year_start(date), to_date=get_year_ending(date))
|
||||
|
||||
holiday_list = make_holiday_list()
|
||||
frappe.db.set_value("Company", "_Test Company", "default_holiday_list", holiday_list)
|
||||
employee = get_employee()
|
||||
frappe.db.set_value("Company", employee.company, "default_holiday_list", holiday_list)
|
||||
first_sunday = get_first_sunday(holiday_list)
|
||||
|
||||
leave_application = make_leave_application("_T-Employee-00001", first_sunday, add_days(first_sunday, 3), leave_type.name)
|
||||
leave_application = make_leave_application(employee.name, first_sunday, add_days(first_sunday, 3), leave_type.name)
|
||||
leave_application.reload()
|
||||
self.assertEqual(leave_application.total_leave_days, 4)
|
||||
self.assertEqual(frappe.db.count('Attendance', {'leave_application': leave_application.name}), 4)
|
||||
@@ -157,25 +156,28 @@ class TestLeaveApplication(unittest.TestCase):
|
||||
make_allocation_record(leave_type=leave_type.name, from_date=get_year_start(date), to_date=get_year_ending(date))
|
||||
|
||||
holiday_list = make_holiday_list()
|
||||
frappe.db.set_value("Company", "_Test Company", "default_holiday_list", holiday_list)
|
||||
employee = get_employee()
|
||||
frappe.db.set_value("Company", employee.company, "default_holiday_list", holiday_list)
|
||||
first_sunday = get_first_sunday(holiday_list)
|
||||
|
||||
# already marked attendance on a holiday should be deleted in this case
|
||||
config = {
|
||||
"doctype": "Attendance",
|
||||
"employee": "_T-Employee-00001",
|
||||
"employee": employee.name,
|
||||
"status": "Present"
|
||||
}
|
||||
attendance_on_holiday = frappe.get_doc(config)
|
||||
attendance_on_holiday.attendance_date = first_sunday
|
||||
attendance_on_holiday.flags.ignore_validate = True
|
||||
attendance_on_holiday.save()
|
||||
|
||||
# already marked attendance on a non-holiday should be updated
|
||||
attendance = frappe.get_doc(config)
|
||||
attendance.attendance_date = add_days(first_sunday, 3)
|
||||
attendance.flags.ignore_validate = True
|
||||
attendance.save()
|
||||
|
||||
leave_application = make_leave_application("_T-Employee-00001", first_sunday, add_days(first_sunday, 3), leave_type.name)
|
||||
leave_application = make_leave_application(employee.name, first_sunday, add_days(first_sunday, 3), leave_type.name)
|
||||
leave_application.reload()
|
||||
# holiday should be excluded while marking attendance
|
||||
self.assertEqual(leave_application.total_leave_days, 3)
|
||||
@@ -325,7 +327,7 @@ class TestLeaveApplication(unittest.TestCase):
|
||||
employee = get_employee()
|
||||
|
||||
default_holiday_list = make_holiday_list()
|
||||
frappe.db.set_value("Company", "_Test Company", "default_holiday_list", default_holiday_list)
|
||||
frappe.db.set_value("Company", employee.company, "default_holiday_list", default_holiday_list)
|
||||
first_sunday = get_first_sunday(default_holiday_list)
|
||||
|
||||
optional_leave_date = add_days(first_sunday, 1)
|
||||
@@ -543,7 +545,7 @@ class TestLeaveApplication(unittest.TestCase):
|
||||
from erpnext.hr.utils import allocate_earned_leaves
|
||||
i = 0
|
||||
while(i<14):
|
||||
allocate_earned_leaves()
|
||||
allocate_earned_leaves(ignore_duplicates=True)
|
||||
i += 1
|
||||
self.assertEqual(get_leave_balance_on(employee.name, leave_type, nowdate()), 6)
|
||||
|
||||
@@ -551,7 +553,7 @@ class TestLeaveApplication(unittest.TestCase):
|
||||
frappe.db.set_value('Leave Type', leave_type, 'max_leaves_allowed', 0)
|
||||
i = 0
|
||||
while(i<6):
|
||||
allocate_earned_leaves()
|
||||
allocate_earned_leaves(ignore_duplicates=True)
|
||||
i += 1
|
||||
self.assertEqual(get_leave_balance_on(employee.name, leave_type, nowdate()), 9)
|
||||
|
||||
|
||||
@@ -8,12 +8,11 @@ from math import ceil
|
||||
import frappe
|
||||
from frappe import _, bold
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import date_diff, flt, formatdate, get_datetime, getdate
|
||||
from frappe.utils import date_diff, flt, formatdate, get_last_day, getdate
|
||||
from six import string_types
|
||||
|
||||
|
||||
class LeavePolicyAssignment(Document):
|
||||
|
||||
def validate(self):
|
||||
self.validate_policy_assignment_overlap()
|
||||
self.set_dates()
|
||||
@@ -95,10 +94,12 @@ class LeavePolicyAssignment(Document):
|
||||
new_leaves_allocated = 0
|
||||
|
||||
elif leave_type_details.get(leave_type).is_earned_leave == 1:
|
||||
if self.assignment_based_on == "Leave Period":
|
||||
new_leaves_allocated = self.get_leaves_for_passed_months(leave_type, new_leaves_allocated, leave_type_details, date_of_joining)
|
||||
else:
|
||||
if not self.assignment_based_on:
|
||||
new_leaves_allocated = 0
|
||||
else:
|
||||
# get leaves for past months if assignment is based on Leave Period / Joining Date
|
||||
new_leaves_allocated = self.get_leaves_for_passed_months(leave_type, new_leaves_allocated, leave_type_details, date_of_joining)
|
||||
|
||||
# Calculate leaves at pro-rata basis for employees joining after the beginning of the given leave period
|
||||
elif getdate(date_of_joining) > getdate(self.effective_from):
|
||||
remaining_period = ((date_diff(self.effective_to, date_of_joining) + 1) / (date_diff(self.effective_to, self.effective_from) + 1))
|
||||
@@ -109,21 +110,24 @@ class LeavePolicyAssignment(Document):
|
||||
def get_leaves_for_passed_months(self, leave_type, new_leaves_allocated, leave_type_details, date_of_joining):
|
||||
from erpnext.hr.utils import get_monthly_earned_leave
|
||||
|
||||
current_month = get_datetime().month
|
||||
current_year = get_datetime().year
|
||||
current_date = frappe.flags.current_date or getdate()
|
||||
if current_date > getdate(self.effective_to):
|
||||
current_date = getdate(self.effective_to)
|
||||
|
||||
from_date = frappe.db.get_value("Leave Period", self.leave_period, "from_date")
|
||||
if getdate(date_of_joining) > getdate(from_date):
|
||||
from_date = date_of_joining
|
||||
|
||||
from_date_month = get_datetime(from_date).month
|
||||
from_date_year = get_datetime(from_date).year
|
||||
from_date = getdate(self.effective_from)
|
||||
if getdate(date_of_joining) > from_date:
|
||||
from_date = getdate(date_of_joining)
|
||||
|
||||
months_passed = 0
|
||||
if current_year == from_date_year and current_month > from_date_month:
|
||||
months_passed = current_month - from_date_month
|
||||
elif current_year > from_date_year:
|
||||
months_passed = (12 - from_date_month) + current_month
|
||||
based_on_doj = leave_type_details.get(leave_type).based_on_date_of_joining
|
||||
|
||||
if current_date.year == from_date.year and current_date.month >= from_date.month:
|
||||
months_passed = current_date.month - from_date.month
|
||||
months_passed = add_current_month_if_applicable(months_passed, date_of_joining, based_on_doj)
|
||||
|
||||
elif current_date.year > from_date.year:
|
||||
months_passed = (12 - from_date.month) + current_date.month
|
||||
months_passed = add_current_month_if_applicable(months_passed, date_of_joining, based_on_doj)
|
||||
|
||||
if months_passed > 0:
|
||||
monthly_earned_leave = get_monthly_earned_leave(new_leaves_allocated,
|
||||
@@ -135,6 +139,23 @@ class LeavePolicyAssignment(Document):
|
||||
return new_leaves_allocated
|
||||
|
||||
|
||||
def add_current_month_if_applicable(months_passed, date_of_joining, based_on_doj):
|
||||
date = getdate(frappe.flags.current_date) or getdate()
|
||||
|
||||
if based_on_doj:
|
||||
# if leave type allocation is based on DOJ, and the date of assignment creation is same as DOJ,
|
||||
# then the month should be considered
|
||||
if date.day == date_of_joining.day:
|
||||
months_passed += 1
|
||||
else:
|
||||
last_day_of_month = get_last_day(date)
|
||||
# if its the last day of the month, then that month should be considered
|
||||
if last_day_of_month == date:
|
||||
months_passed += 1
|
||||
|
||||
return months_passed
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_assignment_for_multiple_employees(employees, data):
|
||||
|
||||
@@ -169,7 +190,7 @@ def create_assignment_for_multiple_employees(employees, data):
|
||||
def get_leave_type_details():
|
||||
leave_type_details = frappe._dict()
|
||||
leave_types = frappe.get_all("Leave Type",
|
||||
fields=["name", "is_lwp", "is_earned_leave", "is_compensatory",
|
||||
fields=["name", "is_lwp", "is_earned_leave", "is_compensatory", "based_on_date_of_joining",
|
||||
"is_carry_forward", "expire_carry_forwarded_leaves_after_days", "earned_leave_frequency", "rounding"])
|
||||
for d in leave_types:
|
||||
leave_type_details.setdefault(d.name, d)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import unittest
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_months, get_first_day, getdate
|
||||
from frappe.utils import add_months, get_first_day, get_last_day, getdate
|
||||
|
||||
from erpnext.hr.doctype.leave_application.test_leave_application import (
|
||||
get_employee,
|
||||
@@ -20,36 +20,31 @@ test_dependencies = ["Employee"]
|
||||
class TestLeavePolicyAssignment(unittest.TestCase):
|
||||
def setUp(self):
|
||||
for doctype in ["Leave Period", "Leave Application", "Leave Allocation", "Leave Policy Assignment", "Leave Ledger Entry"]:
|
||||
frappe.db.sql("delete from `tab{0}`".format(doctype)) #nosec
|
||||
frappe.db.delete(doctype)
|
||||
|
||||
employee = get_employee()
|
||||
self.original_doj = employee.date_of_joining
|
||||
self.employee = employee
|
||||
|
||||
def test_grant_leaves(self):
|
||||
leave_period = get_leave_period()
|
||||
employee = get_employee()
|
||||
|
||||
# create the leave policy with leave type "_Test Leave Type", allocation = 10
|
||||
# allocation = 10
|
||||
leave_policy = create_leave_policy()
|
||||
leave_policy.submit()
|
||||
|
||||
|
||||
data = {
|
||||
"assignment_based_on": "Leave Period",
|
||||
"leave_policy": leave_policy.name,
|
||||
"leave_period": leave_period.name
|
||||
}
|
||||
|
||||
leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data))
|
||||
|
||||
leave_policy_assignment_doc = frappe.get_doc("Leave Policy Assignment", leave_policy_assignments[0])
|
||||
leave_policy_assignment_doc.reload()
|
||||
|
||||
self.assertEqual(leave_policy_assignment_doc.leaves_allocated, 1)
|
||||
leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
|
||||
self.assertEqual(frappe.db.get_value("Leave Policy Assignment", leave_policy_assignments[0], "leaves_allocated"), 1)
|
||||
|
||||
leave_allocation = frappe.get_list("Leave Allocation", filters={
|
||||
"employee": employee.name,
|
||||
"employee": self.employee.name,
|
||||
"leave_policy":leave_policy.name,
|
||||
"leave_policy_assignment": leave_policy_assignments[0],
|
||||
"docstatus": 1})[0]
|
||||
|
||||
leave_alloc_doc = frappe.get_doc("Leave Allocation", leave_allocation)
|
||||
|
||||
self.assertEqual(leave_alloc_doc.new_leaves_allocated, 10)
|
||||
@@ -61,62 +56,45 @@ class TestLeavePolicyAssignment(unittest.TestCase):
|
||||
|
||||
def test_allow_to_grant_all_leave_after_cancellation_of_every_leave_allocation(self):
|
||||
leave_period = get_leave_period()
|
||||
employee = get_employee()
|
||||
|
||||
# create the leave policy with leave type "_Test Leave Type", allocation = 10
|
||||
leave_policy = create_leave_policy()
|
||||
leave_policy.submit()
|
||||
|
||||
|
||||
data = {
|
||||
"assignment_based_on": "Leave Period",
|
||||
"leave_policy": leave_policy.name,
|
||||
"leave_period": leave_period.name
|
||||
}
|
||||
|
||||
leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data))
|
||||
|
||||
leave_policy_assignment_doc = frappe.get_doc("Leave Policy Assignment", leave_policy_assignments[0])
|
||||
leave_policy_assignment_doc.reload()
|
||||
|
||||
leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
|
||||
|
||||
# every leave is allocated no more leave can be granted now
|
||||
self.assertEqual(leave_policy_assignment_doc.leaves_allocated, 1)
|
||||
|
||||
self.assertEqual(frappe.db.get_value("Leave Policy Assignment", leave_policy_assignments[0], "leaves_allocated"), 1)
|
||||
leave_allocation = frappe.get_list("Leave Allocation", filters={
|
||||
"employee": employee.name,
|
||||
"employee": self.employee.name,
|
||||
"leave_policy":leave_policy.name,
|
||||
"leave_policy_assignment": leave_policy_assignments[0],
|
||||
"docstatus": 1})[0]
|
||||
|
||||
leave_alloc_doc = frappe.get_doc("Leave Allocation", leave_allocation)
|
||||
|
||||
# User all allowed to grant leave when there is no allocation against assignment
|
||||
leave_alloc_doc.cancel()
|
||||
leave_alloc_doc.delete()
|
||||
|
||||
leave_policy_assignment_doc.reload()
|
||||
|
||||
|
||||
# User are now allowed to grant leave
|
||||
self.assertEqual(leave_policy_assignment_doc.leaves_allocated, 0)
|
||||
self.assertEqual(frappe.db.get_value("Leave Policy Assignment", leave_policy_assignments[0], "leaves_allocated"), 0)
|
||||
|
||||
def test_earned_leave_allocation(self):
|
||||
leave_period = create_leave_period("Test Earned Leave Period")
|
||||
employee = get_employee()
|
||||
leave_type = create_earned_leave_type("Test Earned Leave")
|
||||
|
||||
leave_policy = frappe.get_doc({
|
||||
"doctype": "Leave Policy",
|
||||
"leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 6}]
|
||||
}).insert()
|
||||
}).submit()
|
||||
|
||||
data = {
|
||||
"assignment_based_on": "Leave Period",
|
||||
"leave_policy": leave_policy.name,
|
||||
"leave_period": leave_period.name
|
||||
}
|
||||
leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data))
|
||||
leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
|
||||
|
||||
# leaves allocated should be 0 since it is an earned leave and allocation happens via scheduler based on set frequency
|
||||
leaves_allocated = frappe.db.get_value("Leave Allocation", {
|
||||
@@ -124,11 +102,200 @@ class TestLeavePolicyAssignment(unittest.TestCase):
|
||||
}, "total_leaves_allocated")
|
||||
self.assertEqual(leaves_allocated, 0)
|
||||
|
||||
def test_earned_leave_alloc_for_passed_months_based_on_leave_period(self):
|
||||
leave_period, leave_policy = setup_leave_period_and_policy(get_first_day(add_months(getdate(), -1)))
|
||||
|
||||
# Case 1: assignment created one month after the leave period, should allocate 1 leave
|
||||
frappe.flags.current_date = get_first_day(getdate())
|
||||
data = {
|
||||
"assignment_based_on": "Leave Period",
|
||||
"leave_policy": leave_policy.name,
|
||||
"leave_period": leave_period.name
|
||||
}
|
||||
leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
|
||||
|
||||
leaves_allocated = frappe.db.get_value("Leave Allocation", {
|
||||
"leave_policy_assignment": leave_policy_assignments[0]
|
||||
}, "total_leaves_allocated")
|
||||
self.assertEqual(leaves_allocated, 1)
|
||||
|
||||
def test_earned_leave_alloc_for_passed_months_on_month_end_based_on_leave_period(self):
|
||||
leave_period, leave_policy = setup_leave_period_and_policy(get_first_day(add_months(getdate(), -2)))
|
||||
# Case 2: assignment created on the last day of the leave period's latter month
|
||||
# should allocate 1 leave for current month even though the month has not ended
|
||||
# since the daily job might have already executed
|
||||
frappe.flags.current_date = get_last_day(getdate())
|
||||
|
||||
data = {
|
||||
"assignment_based_on": "Leave Period",
|
||||
"leave_policy": leave_policy.name,
|
||||
"leave_period": leave_period.name
|
||||
}
|
||||
leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
|
||||
|
||||
leaves_allocated = frappe.db.get_value("Leave Allocation", {
|
||||
"leave_policy_assignment": leave_policy_assignments[0]
|
||||
}, "total_leaves_allocated")
|
||||
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
|
||||
|
||||
leave_period, leave_policy = setup_leave_period_and_policy(get_first_day(add_months(getdate(), -2)))
|
||||
# initial leave allocation = 5
|
||||
leave_allocation = create_leave_allocation(employee=self.employee.name, employee_name=self.employee.employee_name, leave_type="Test Earned Leave",
|
||||
from_date=add_months(getdate(), -12), to_date=add_months(getdate(), -3), new_leaves_allocated=5, carry_forward=0)
|
||||
leave_allocation.submit()
|
||||
|
||||
# Case 3: assignment created on the last day of the leave period's latter month with carry forwarding
|
||||
frappe.flags.current_date = get_last_day(add_months(getdate(), -1))
|
||||
data = {
|
||||
"assignment_based_on": "Leave Period",
|
||||
"leave_policy": leave_policy.name,
|
||||
"leave_period": leave_period.name,
|
||||
"carry_forward": 1
|
||||
}
|
||||
# carry forwarded leaves = 5, 3 leaves allocated for passed months
|
||||
leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
|
||||
|
||||
details = frappe.db.get_value("Leave Allocation", {
|
||||
"leave_policy_assignment": leave_policy_assignments[0]
|
||||
}, ["total_leaves_allocated", "new_leaves_allocated", "unused_leaves", "name"], as_dict=True)
|
||||
self.assertEqual(details.new_leaves_allocated, 2)
|
||||
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")
|
||||
leave_policy = frappe.get_doc({
|
||||
"doctype": "Leave Policy",
|
||||
"title": "Test Leave Policy",
|
||||
"leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 12}]
|
||||
}).submit()
|
||||
|
||||
# joining date set to 2 months back
|
||||
self.employee.date_of_joining = get_first_day(add_months(getdate(), -2))
|
||||
self.employee.save()
|
||||
|
||||
# assignment created on the last day of the current month
|
||||
frappe.flags.current_date = get_last_day(getdate())
|
||||
data = {
|
||||
"assignment_based_on": "Joining Date",
|
||||
"leave_policy": leave_policy.name
|
||||
}
|
||||
leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
|
||||
leaves_allocated = frappe.db.get_value("Leave Allocation", {"leave_policy_assignment": leave_policy_assignments[0]},
|
||||
"total_leaves_allocated")
|
||||
effective_from = frappe.db.get_value("Leave Policy Assignment", leave_policy_assignments[0], "effective_from")
|
||||
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(get_first_day(add_months(getdate(), -2)), based_on_doj=True)
|
||||
|
||||
# joining date set to 2 months back
|
||||
self.employee.date_of_joining = get_first_day(add_months(getdate(), -2))
|
||||
self.employee.save()
|
||||
|
||||
# assignment created on the same day of the current month, should allocate leaves including the current month
|
||||
frappe.flags.current_date = get_first_day(getdate())
|
||||
|
||||
data = {
|
||||
"assignment_based_on": "Leave Period",
|
||||
"leave_policy": leave_policy.name,
|
||||
"leave_period": leave_period.name
|
||||
}
|
||||
leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
|
||||
|
||||
leaves_allocated = frappe.db.get_value("Leave Allocation", {
|
||||
"leave_policy_assignment": leave_policy_assignments[0]
|
||||
}, "total_leaves_allocated")
|
||||
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)
|
||||
leave_policy = frappe.get_doc({
|
||||
"doctype": "Leave Policy",
|
||||
"title": "Test Leave Policy",
|
||||
"leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 12}]
|
||||
}).submit()
|
||||
|
||||
# joining date set to 2 months back
|
||||
# leave should be allocated for current month too since this day is same as the joining day
|
||||
self.employee.date_of_joining = get_first_day(add_months(getdate(), -2))
|
||||
self.employee.save()
|
||||
|
||||
# assignment created on the first day of the current month
|
||||
frappe.flags.current_date = get_first_day(getdate())
|
||||
data = {
|
||||
"assignment_based_on": "Joining Date",
|
||||
"leave_policy": leave_policy.name
|
||||
}
|
||||
leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
|
||||
leaves_allocated = frappe.db.get_value("Leave Allocation", {"leave_policy_assignment": leave_policy_assignments[0]},
|
||||
"total_leaves_allocated")
|
||||
effective_from = frappe.db.get_value("Leave Policy Assignment", leave_policy_assignments[0], "effective_from")
|
||||
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
|
||||
|
||||
|
||||
def create_earned_leave_type(leave_type):
|
||||
def create_earned_leave_type(leave_type, based_on_doj=False):
|
||||
frappe.delete_doc_if_exists("Leave Type", leave_type, force=1)
|
||||
|
||||
return frappe.get_doc(dict(
|
||||
@@ -137,13 +304,15 @@ def create_earned_leave_type(leave_type):
|
||||
is_earned_leave=1,
|
||||
earned_leave_frequency="Monthly",
|
||||
rounding=0.5,
|
||||
max_leaves_allowed=6
|
||||
is_carry_forward=1,
|
||||
based_on_date_of_joining=based_on_doj
|
||||
)).insert()
|
||||
|
||||
|
||||
def create_leave_period(name):
|
||||
def create_leave_period(name, start_date=None):
|
||||
frappe.delete_doc_if_exists("Leave Period", name, force=1)
|
||||
start_date = get_first_day(getdate())
|
||||
if not start_date:
|
||||
start_date = get_first_day(getdate())
|
||||
|
||||
return frappe.get_doc(dict(
|
||||
name=name,
|
||||
@@ -152,4 +321,17 @@ def create_leave_period(name):
|
||||
to_date=add_months(start_date, 12),
|
||||
company="_Test Company",
|
||||
is_active=1
|
||||
)).insert()
|
||||
)).insert()
|
||||
|
||||
|
||||
def setup_leave_period_and_policy(start_date, based_on_doj=False):
|
||||
leave_type = create_earned_leave_type("Test Earned Leave", based_on_doj)
|
||||
leave_period = create_leave_period("Test Earned Leave Period",
|
||||
start_date=start_date)
|
||||
leave_policy = frappe.get_doc({
|
||||
"doctype": "Leave Policy",
|
||||
"title": "Test Leave Policy",
|
||||
"leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 12}]
|
||||
}).insert()
|
||||
|
||||
return leave_period, leave_policy
|
||||
@@ -353,7 +353,7 @@ def generate_leave_encashment():
|
||||
|
||||
create_leave_encashment(leave_allocation=leave_allocation)
|
||||
|
||||
def allocate_earned_leaves():
|
||||
def allocate_earned_leaves(ignore_duplicates=False):
|
||||
'''Allocate earned leaves to Employees'''
|
||||
e_leave_types = get_earned_leaves()
|
||||
today = getdate()
|
||||
@@ -377,13 +377,13 @@ def allocate_earned_leaves():
|
||||
|
||||
from_date=allocation.from_date
|
||||
|
||||
if e_leave_type.based_on_date_of_joining_date:
|
||||
if e_leave_type.based_on_date_of_joining:
|
||||
from_date = frappe.db.get_value("Employee", allocation.employee, "date_of_joining")
|
||||
|
||||
if check_effective_date(from_date, today, e_leave_type.earned_leave_frequency, e_leave_type.based_on_date_of_joining_date):
|
||||
update_previous_leave_allocation(allocation, annual_allocation, e_leave_type)
|
||||
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)
|
||||
|
||||
def update_previous_leave_allocation(allocation, annual_allocation, e_leave_type):
|
||||
def update_previous_leave_allocation(allocation, annual_allocation, e_leave_type, ignore_duplicates=False):
|
||||
earned_leaves = get_monthly_earned_leave(annual_allocation, e_leave_type.earned_leave_frequency, e_leave_type.rounding)
|
||||
|
||||
allocation = frappe.get_doc('Leave Allocation', allocation.name)
|
||||
@@ -393,9 +393,12 @@ def update_previous_leave_allocation(allocation, annual_allocation, e_leave_type
|
||||
new_allocation = e_leave_type.max_leaves_allowed
|
||||
|
||||
if new_allocation != allocation.total_leaves_allocated:
|
||||
allocation.db_set("total_leaves_allocated", new_allocation, update_modified=False)
|
||||
today_date = today()
|
||||
create_additional_leave_ledger_entry(allocation, earned_leaves, today_date)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def get_monthly_earned_leave(annual_leaves, frequency, rounding):
|
||||
earned_leaves = 0.0
|
||||
@@ -413,6 +416,28 @@ def get_monthly_earned_leave(annual_leaves, frequency, rounding):
|
||||
return earned_leaves
|
||||
|
||||
|
||||
def is_earned_leave_already_allocated(allocation, annual_allocation):
|
||||
from erpnext.hr.doctype.leave_policy_assignment.leave_policy_assignment import (
|
||||
get_leave_type_details,
|
||||
)
|
||||
|
||||
leave_type_details = get_leave_type_details()
|
||||
date_of_joining = frappe.db.get_value("Employee", allocation.employee, "date_of_joining")
|
||||
|
||||
assignment = frappe.get_doc("Leave Policy Assignment", allocation.leave_policy_assignment)
|
||||
leaves_for_passed_months = assignment.get_leaves_for_passed_months(allocation.leave_type,
|
||||
annual_allocation, leave_type_details, date_of_joining)
|
||||
|
||||
# exclude carry-forwarded leaves while checking for leave allocation for passed months
|
||||
num_allocations = allocation.total_leaves_allocated
|
||||
if allocation.unused_leaves:
|
||||
num_allocations -= allocation.unused_leaves
|
||||
|
||||
if num_allocations >= leaves_for_passed_months:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_leave_allocations(date, leave_type):
|
||||
return frappe.db.sql("""select name, employee, from_date, to_date, leave_policy_assignment, leave_policy
|
||||
from `tabLeave Allocation`
|
||||
@@ -434,7 +459,7 @@ def create_additional_leave_ledger_entry(allocation, leaves, date):
|
||||
allocation.unused_leaves = 0
|
||||
allocation.create_leave_ledger_entry()
|
||||
|
||||
def check_effective_date(from_date, to_date, frequency, based_on_date_of_joining_date):
|
||||
def check_effective_date(from_date, to_date, frequency, based_on_date_of_joining):
|
||||
import calendar
|
||||
|
||||
from dateutil import relativedelta
|
||||
@@ -445,7 +470,7 @@ def check_effective_date(from_date, to_date, frequency, based_on_date_of_joining
|
||||
#last day of month
|
||||
last_day = calendar.monthrange(to_date.year, to_date.month)[1]
|
||||
|
||||
if (from_date.day == to_date.day and based_on_date_of_joining_date) or (not based_on_date_of_joining_date and to_date.day == last_day):
|
||||
if (from_date.day == to_date.day and based_on_date_of_joining) or (not based_on_date_of_joining and to_date.day == last_day):
|
||||
if frequency == "Monthly":
|
||||
return True
|
||||
elif frequency == "Quarterly" and rd.months % 3:
|
||||
|
||||
@@ -74,39 +74,6 @@ class LoanInterestAccrual(AccountsController):
|
||||
})
|
||||
)
|
||||
|
||||
if self.payable_principal_amount:
|
||||
gle_map.append(
|
||||
self.get_gl_dict({
|
||||
"account": self.loan_account,
|
||||
"party_type": self.applicant_type,
|
||||
"party": self.applicant,
|
||||
"against": self.interest_income_account,
|
||||
"debit": self.payable_principal_amount,
|
||||
"debit_in_account_currency": self.interest_amount,
|
||||
"against_voucher_type": "Loan",
|
||||
"against_voucher": self.loan,
|
||||
"remarks": _("Interest accrued from {0} to {1} against loan: {2}").format(
|
||||
self.last_accrual_date, self.posting_date, self.loan),
|
||||
"cost_center": erpnext.get_default_cost_center(self.company),
|
||||
"posting_date": self.posting_date
|
||||
})
|
||||
)
|
||||
|
||||
gle_map.append(
|
||||
self.get_gl_dict({
|
||||
"account": self.interest_income_account,
|
||||
"against": self.loan_account,
|
||||
"credit": self.payable_principal_amount,
|
||||
"credit_in_account_currency": self.interest_amount,
|
||||
"against_voucher_type": "Loan",
|
||||
"against_voucher": self.loan,
|
||||
"remarks": ("Interest accrued from {0} to {1} against loan: {2}").format(
|
||||
self.last_accrual_date, self.posting_date, self.loan),
|
||||
"cost_center": erpnext.get_default_cost_center(self.company),
|
||||
"posting_date": self.posting_date
|
||||
})
|
||||
)
|
||||
|
||||
if gle_map:
|
||||
make_gl_entries(gle_map, cancel=cancel, adv_adj=adv_adj)
|
||||
|
||||
|
||||
@@ -346,7 +346,7 @@ class LoanRepayment(AccountsController):
|
||||
gle_map.append(
|
||||
self.get_gl_dict({
|
||||
"account": loan_details.penalty_income_account,
|
||||
"against": payment_account,
|
||||
"against": loan_details.loan_account,
|
||||
"credit": self.total_penalty_paid,
|
||||
"credit_in_account_currency": self.total_penalty_paid,
|
||||
"against_voucher_type": "Loan",
|
||||
@@ -368,7 +368,9 @@ class LoanRepayment(AccountsController):
|
||||
"against_voucher": self.against_loan,
|
||||
"remarks": remarks,
|
||||
"cost_center": self.cost_center,
|
||||
"posting_date": getdate(self.posting_date)
|
||||
"posting_date": getdate(self.posting_date),
|
||||
"party_type": loan_details.applicant_type if self.repay_from_salary else '',
|
||||
"party": loan_details.applicant if self.repay_from_salary else ''
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -70,7 +70,6 @@
|
||||
{
|
||||
"fieldname": "loan_repayment_entry",
|
||||
"fieldtype": "Link",
|
||||
"hidden": 1,
|
||||
"label": "Loan Repayment Entry",
|
||||
"no_copy": 1,
|
||||
"options": "Loan Repayment",
|
||||
@@ -88,7 +87,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2021-03-14 20:47:11.725818",
|
||||
"modified": "2022-01-31 14:50:14.823213",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Loan Management",
|
||||
"name": "Salary Slip Loan",
|
||||
@@ -97,5 +96,6 @@
|
||||
"quick_entry": 1,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
@@ -93,7 +93,7 @@ frappe.ui.form.on("BOM", {
|
||||
});
|
||||
}
|
||||
|
||||
if(frm.doc.docstatus!=0) {
|
||||
if(frm.doc.docstatus==1) {
|
||||
frm.add_custom_button(__("Work Order"), function() {
|
||||
frm.trigger("make_work_order");
|
||||
}, __("Create"));
|
||||
|
||||
@@ -29,9 +29,24 @@ from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
|
||||
|
||||
class ProductionPlan(Document):
|
||||
def validate(self):
|
||||
self.set_pending_qty_in_row_without_reference()
|
||||
self.calculate_total_planned_qty()
|
||||
self.set_status()
|
||||
|
||||
def set_pending_qty_in_row_without_reference(self):
|
||||
"Set Pending Qty in independent rows (not from SO or MR)."
|
||||
if self.docstatus > 0: # set only to initialise value before submit
|
||||
return
|
||||
|
||||
for item in self.po_items:
|
||||
if not item.get("sales_order") or not item.get("material_request"):
|
||||
item.pending_qty = item.planned_qty
|
||||
|
||||
def calculate_total_planned_qty(self):
|
||||
self.total_planned_qty = 0
|
||||
for d in self.po_items:
|
||||
self.total_planned_qty += flt(d.planned_qty)
|
||||
|
||||
def validate_data(self):
|
||||
for d in self.get('po_items'):
|
||||
if not d.bom_no:
|
||||
@@ -264,11 +279,6 @@ class ProductionPlan(Document):
|
||||
'qty': so_detail['qty']
|
||||
})
|
||||
|
||||
def calculate_total_planned_qty(self):
|
||||
self.total_planned_qty = 0
|
||||
for d in self.po_items:
|
||||
self.total_planned_qty += flt(d.planned_qty)
|
||||
|
||||
def calculate_total_produced_qty(self):
|
||||
self.total_produced_qty = 0
|
||||
for d in self.po_items:
|
||||
@@ -276,10 +286,11 @@ class ProductionPlan(Document):
|
||||
|
||||
self.db_set("total_produced_qty", self.total_produced_qty, update_modified=False)
|
||||
|
||||
def update_produced_qty(self, produced_qty, production_plan_item):
|
||||
def update_produced_pending_qty(self, produced_qty, production_plan_item):
|
||||
for data in self.po_items:
|
||||
if data.name == production_plan_item:
|
||||
data.produced_qty = produced_qty
|
||||
data.pending_qty = flt(data.planned_qty - produced_qty)
|
||||
data.db_update()
|
||||
|
||||
self.calculate_total_produced_qty()
|
||||
@@ -342,6 +353,7 @@ class ProductionPlan(Document):
|
||||
|
||||
def get_production_items(self):
|
||||
item_dict = {}
|
||||
|
||||
for d in self.po_items:
|
||||
item_details = {
|
||||
"production_item" : d.item_code,
|
||||
@@ -358,12 +370,12 @@ class ProductionPlan(Document):
|
||||
"production_plan" : self.name,
|
||||
"production_plan_item" : d.name,
|
||||
"product_bundle_item" : d.product_bundle_item,
|
||||
"planned_start_date" : d.planned_start_date
|
||||
"planned_start_date" : d.planned_start_date,
|
||||
"project" : self.project
|
||||
}
|
||||
|
||||
item_details.update({
|
||||
"project": self.project or frappe.db.get_value("Sales Order", d.sales_order, "project")
|
||||
})
|
||||
if not item_details['project'] and d.sales_order:
|
||||
item_details['project'] = frappe.get_cached_value("Sales Order", d.sales_order, "project")
|
||||
|
||||
if self.get_items_from == "Material Request":
|
||||
item_details.update({
|
||||
@@ -381,39 +393,59 @@ class ProductionPlan(Document):
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_work_order(self):
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import get_default_warehouse
|
||||
|
||||
wo_list, po_list = [], []
|
||||
subcontracted_po = {}
|
||||
default_warehouses = get_default_warehouse()
|
||||
|
||||
self.validate_data()
|
||||
self.make_work_order_for_finished_goods(wo_list)
|
||||
self.make_work_order_for_subassembly_items(wo_list, subcontracted_po)
|
||||
self.make_work_order_for_finished_goods(wo_list, default_warehouses)
|
||||
self.make_work_order_for_subassembly_items(wo_list, subcontracted_po, default_warehouses)
|
||||
self.make_subcontracted_purchase_order(subcontracted_po, po_list)
|
||||
self.show_list_created_message('Work Order', wo_list)
|
||||
self.show_list_created_message('Purchase Order', po_list)
|
||||
|
||||
def make_work_order_for_finished_goods(self, wo_list):
|
||||
def make_work_order_for_finished_goods(self, wo_list, default_warehouses):
|
||||
items_data = self.get_production_items()
|
||||
|
||||
for key, item in items_data.items():
|
||||
if self.sub_assembly_items:
|
||||
item['use_multi_level_bom'] = 0
|
||||
|
||||
set_default_warehouses(item, default_warehouses)
|
||||
work_order = self.create_work_order(item)
|
||||
if work_order:
|
||||
wo_list.append(work_order)
|
||||
|
||||
def make_work_order_for_subassembly_items(self, wo_list, subcontracted_po):
|
||||
def make_work_order_for_subassembly_items(self, wo_list, subcontracted_po, default_warehouses):
|
||||
for row in self.sub_assembly_items:
|
||||
if row.type_of_manufacturing == 'Subcontract':
|
||||
subcontracted_po.setdefault(row.supplier, []).append(row)
|
||||
continue
|
||||
|
||||
args = {}
|
||||
self.prepare_args_for_sub_assembly_items(row, args)
|
||||
work_order = self.create_work_order(args)
|
||||
work_order_data = {
|
||||
'wip_warehouse': default_warehouses.get('wip_warehouse'),
|
||||
'fg_warehouse': default_warehouses.get('fg_warehouse')
|
||||
}
|
||||
|
||||
self.prepare_data_for_sub_assembly_items(row, work_order_data)
|
||||
work_order = self.create_work_order(work_order_data)
|
||||
if work_order:
|
||||
wo_list.append(work_order)
|
||||
|
||||
def prepare_data_for_sub_assembly_items(self, row, wo_data):
|
||||
for field in ["production_item", "item_name", "qty", "fg_warehouse",
|
||||
"description", "bom_no", "stock_uom", "bom_level",
|
||||
"production_plan_item", "schedule_date"]:
|
||||
if row.get(field):
|
||||
wo_data[field] = row.get(field)
|
||||
|
||||
wo_data.update({
|
||||
"use_multi_level_bom": 0,
|
||||
"production_plan": self.name,
|
||||
"production_plan_sub_assembly_item": row.name
|
||||
})
|
||||
|
||||
def make_subcontracted_purchase_order(self, subcontracted_po, purchase_orders):
|
||||
if not subcontracted_po:
|
||||
return
|
||||
@@ -424,7 +456,7 @@ class ProductionPlan(Document):
|
||||
po.schedule_date = getdate(po_list[0].schedule_date) if po_list[0].schedule_date else nowdate()
|
||||
po.is_subcontracted = 'Yes'
|
||||
for row in po_list:
|
||||
args = {
|
||||
po_data = {
|
||||
'item_code': row.production_item,
|
||||
'warehouse': row.fg_warehouse,
|
||||
'production_plan_sub_assembly_item': row.name,
|
||||
@@ -434,9 +466,9 @@ class ProductionPlan(Document):
|
||||
|
||||
for field in ['schedule_date', 'qty', 'uom', 'stock_uom', 'item_name',
|
||||
'description', 'production_plan_item']:
|
||||
args[field] = row.get(field)
|
||||
po_data[field] = row.get(field)
|
||||
|
||||
po.append('items', args)
|
||||
po.append('items', po_data)
|
||||
|
||||
po.set_missing_values()
|
||||
po.flags.ignore_mandatory = True
|
||||
@@ -453,24 +485,9 @@ class ProductionPlan(Document):
|
||||
doc_list = [get_link_to_form(doctype, p) for p in doc_list]
|
||||
msgprint(_("{0} created").format(comma_and(doc_list)))
|
||||
|
||||
def prepare_args_for_sub_assembly_items(self, row, args):
|
||||
for field in ["production_item", "item_name", "qty", "fg_warehouse",
|
||||
"description", "bom_no", "stock_uom", "bom_level",
|
||||
"production_plan_item", "schedule_date"]:
|
||||
args[field] = row.get(field)
|
||||
|
||||
args.update({
|
||||
"use_multi_level_bom": 0,
|
||||
"production_plan": self.name,
|
||||
"production_plan_sub_assembly_item": row.name
|
||||
})
|
||||
|
||||
def create_work_order(self, item):
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import (
|
||||
OverProductionError,
|
||||
get_default_warehouse,
|
||||
)
|
||||
warehouse = get_default_warehouse()
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import OverProductionError
|
||||
|
||||
wo = frappe.new_doc("Work Order")
|
||||
wo.update(item)
|
||||
wo.planned_start_date = item.get('planned_start_date') or item.get('schedule_date')
|
||||
@@ -479,11 +496,11 @@ class ProductionPlan(Document):
|
||||
wo.fg_warehouse = item.get("warehouse")
|
||||
|
||||
wo.set_work_order_operations()
|
||||
wo.set_required_items()
|
||||
|
||||
if not wo.fg_warehouse:
|
||||
wo.fg_warehouse = warehouse.get('fg_warehouse')
|
||||
try:
|
||||
wo.flags.ignore_mandatory = True
|
||||
wo.flags.ignore_validate = True
|
||||
wo.insert()
|
||||
return wo.name
|
||||
except OverProductionError:
|
||||
@@ -589,7 +606,8 @@ def download_raw_materials(doc, warehouses=None):
|
||||
if isinstance(doc, str):
|
||||
doc = frappe._dict(json.loads(doc))
|
||||
|
||||
item_list = [['Item Code', 'Description', 'Stock UOM', 'Warehouse', 'Required Qty as per BOM',
|
||||
item_list = [['Item Code', 'Item Name', 'Description',
|
||||
'Stock UOM', 'Warehouse', 'Required Qty as per BOM',
|
||||
'Projected Qty', 'Available Qty In Hand', 'Ordered Qty', 'Planned Qty',
|
||||
'Reserved Qty for Production', 'Safety Stock', 'Required Qty']]
|
||||
|
||||
@@ -598,7 +616,8 @@ def download_raw_materials(doc, warehouses=None):
|
||||
items = get_items_for_material_requests(doc, warehouses=warehouses, get_parent_warehouse_data=True)
|
||||
|
||||
for d in items:
|
||||
item_list.append([d.get('item_code'), d.get('description'), d.get('stock_uom'), d.get('warehouse'),
|
||||
item_list.append([d.get('item_code'), d.get('item_name'),
|
||||
d.get('description'), d.get('stock_uom'), d.get('warehouse'),
|
||||
d.get('required_bom_qty'), d.get('projected_qty'), d.get('actual_qty'), d.get('ordered_qty'),
|
||||
d.get('planned_qty'), d.get('reserved_qty_for_production'), d.get('safety_stock'), d.get('quantity')])
|
||||
|
||||
@@ -1024,3 +1043,8 @@ def get_sub_assembly_items(bom_no, bom_data, to_produce_qty, indent=0):
|
||||
|
||||
if d.value:
|
||||
get_sub_assembly_items(d.value, bom_data, stock_qty, indent=indent+1)
|
||||
|
||||
def set_default_warehouses(row, default_warehouses):
|
||||
for field in ['wip_warehouse', 'fg_warehouse']:
|
||||
if not row.get(field):
|
||||
row[field] = default_warehouses.get(field)
|
||||
@@ -11,6 +11,7 @@ from erpnext.manufacturing.doctype.production_plan.production_plan import (
|
||||
)
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
from erpnext.stock.doctype.item.test_item import create_item
|
||||
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
|
||||
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
|
||||
create_stock_reconciliation,
|
||||
)
|
||||
@@ -36,15 +37,21 @@ class TestProductionPlan(ERPNextTestCase):
|
||||
if not frappe.db.get_value('BOM', {'item': item}):
|
||||
make_bom(item = item, raw_materials = raw_materials)
|
||||
|
||||
def test_production_plan(self):
|
||||
def test_production_plan_mr_creation(self):
|
||||
"Test if MRs are created for unavailable raw materials."
|
||||
pln = create_production_plan(item_code='Test Production Item 1')
|
||||
self.assertTrue(len(pln.mr_items), 2)
|
||||
pln.make_material_request()
|
||||
|
||||
pln = frappe.get_doc('Production Plan', pln.name)
|
||||
pln.make_material_request()
|
||||
pln.reload()
|
||||
self.assertTrue(pln.status, 'Material Requested')
|
||||
material_requests = frappe.get_all('Material Request Item', fields = ['distinct parent'],
|
||||
filters = {'production_plan': pln.name}, as_list=1)
|
||||
|
||||
material_requests = frappe.get_all(
|
||||
'Material Request Item',
|
||||
fields = ['distinct parent'],
|
||||
filters = {'production_plan': pln.name},
|
||||
as_list=1
|
||||
)
|
||||
|
||||
self.assertTrue(len(material_requests), 2)
|
||||
|
||||
@@ -66,27 +73,42 @@ class TestProductionPlan(ERPNextTestCase):
|
||||
pln.cancel()
|
||||
|
||||
def test_production_plan_start_date(self):
|
||||
"Test if Work Order has same Planned Start Date as Prod Plan."
|
||||
planned_date = add_to_date(date=None, days=3)
|
||||
plan = create_production_plan(item_code='Test Production Item 1', planned_start_date=planned_date)
|
||||
plan = create_production_plan(
|
||||
item_code='Test Production Item 1',
|
||||
planned_start_date=planned_date
|
||||
)
|
||||
plan.make_work_order()
|
||||
|
||||
work_orders = frappe.get_all('Work Order', fields = ['name', 'planned_start_date'],
|
||||
filters = {'production_plan': plan.name})
|
||||
work_orders = frappe.get_all(
|
||||
'Work Order',
|
||||
fields = ['name', 'planned_start_date'],
|
||||
filters = {'production_plan': plan.name}
|
||||
)
|
||||
|
||||
self.assertEqual(work_orders[0].planned_start_date, planned_date)
|
||||
|
||||
for wo in work_orders:
|
||||
frappe.delete_doc('Work Order', wo.name)
|
||||
|
||||
frappe.get_doc('Production Plan', plan.name).cancel()
|
||||
plan.reload()
|
||||
plan.cancel()
|
||||
|
||||
def test_production_plan_for_existing_ordered_qty(self):
|
||||
"""
|
||||
- Enable 'ignore_existing_ordered_qty'.
|
||||
- Test if MR Planning table pulls Raw Material Qty even if it is in stock.
|
||||
"""
|
||||
sr1 = create_stock_reconciliation(item_code="Raw Material Item 1",
|
||||
target="_Test Warehouse - _TC", qty=1, rate=110)
|
||||
sr2 = create_stock_reconciliation(item_code="Raw Material Item 2",
|
||||
target="_Test Warehouse - _TC", qty=1, rate=120)
|
||||
|
||||
pln = create_production_plan(item_code='Test Production Item 1', ignore_existing_ordered_qty=0)
|
||||
pln = create_production_plan(
|
||||
item_code='Test Production Item 1',
|
||||
ignore_existing_ordered_qty=1
|
||||
)
|
||||
self.assertTrue(len(pln.mr_items), 1)
|
||||
self.assertTrue(flt(pln.mr_items[0].quantity), 1.0)
|
||||
|
||||
@@ -95,23 +117,39 @@ class TestProductionPlan(ERPNextTestCase):
|
||||
pln.cancel()
|
||||
|
||||
def test_production_plan_with_non_stock_item(self):
|
||||
pln = create_production_plan(item_code='Test Production Item 1', include_non_stock_items=0)
|
||||
"Test if MR Planning table includes Non Stock RM."
|
||||
pln = create_production_plan(
|
||||
item_code='Test Production Item 1',
|
||||
include_non_stock_items=1
|
||||
)
|
||||
self.assertTrue(len(pln.mr_items), 3)
|
||||
pln.cancel()
|
||||
|
||||
def test_production_plan_without_multi_level(self):
|
||||
pln = create_production_plan(item_code='Test Production Item 1', use_multi_level_bom=0)
|
||||
"Test MR Planning table for non exploded BOM."
|
||||
pln = create_production_plan(
|
||||
item_code='Test Production Item 1',
|
||||
use_multi_level_bom=0
|
||||
)
|
||||
self.assertTrue(len(pln.mr_items), 2)
|
||||
pln.cancel()
|
||||
|
||||
def test_production_plan_without_multi_level_for_existing_ordered_qty(self):
|
||||
"""
|
||||
- Disable 'ignore_existing_ordered_qty'.
|
||||
- Test if MR Planning table avoids pulling Raw Material Qty as it is in stock for
|
||||
non exploded BOM.
|
||||
"""
|
||||
sr1 = create_stock_reconciliation(item_code="Raw Material Item 1",
|
||||
target="_Test Warehouse - _TC", qty=1, rate=130)
|
||||
sr2 = create_stock_reconciliation(item_code="Subassembly Item 1",
|
||||
target="_Test Warehouse - _TC", qty=1, rate=140)
|
||||
|
||||
pln = create_production_plan(item_code='Test Production Item 1',
|
||||
use_multi_level_bom=0, ignore_existing_ordered_qty=0)
|
||||
pln = create_production_plan(
|
||||
item_code='Test Production Item 1',
|
||||
use_multi_level_bom=0,
|
||||
ignore_existing_ordered_qty=0
|
||||
)
|
||||
self.assertTrue(len(pln.mr_items), 0)
|
||||
|
||||
sr1.cancel()
|
||||
@@ -119,6 +157,7 @@ class TestProductionPlan(ERPNextTestCase):
|
||||
pln.cancel()
|
||||
|
||||
def test_production_plan_sales_orders(self):
|
||||
"Test if previously fulfilled SO (with WO) is pulled into Prod Plan."
|
||||
item = 'Test Production Item 1'
|
||||
so = make_sales_order(item_code=item, qty=1)
|
||||
sales_order = so.name
|
||||
@@ -166,24 +205,25 @@ class TestProductionPlan(ERPNextTestCase):
|
||||
self.assertEqual(sales_orders, [])
|
||||
|
||||
def test_production_plan_combine_items(self):
|
||||
"Test combining FG items in Production Plan."
|
||||
item = 'Test Production Item 1'
|
||||
so = make_sales_order(item_code=item, qty=1)
|
||||
so1 = make_sales_order(item_code=item, qty=1)
|
||||
|
||||
pln = frappe.new_doc('Production Plan')
|
||||
pln.company = so.company
|
||||
pln.company = so1.company
|
||||
pln.get_items_from = 'Sales Order'
|
||||
pln.append('sales_orders', {
|
||||
'sales_order': so.name,
|
||||
'sales_order_date': so.transaction_date,
|
||||
'customer': so.customer,
|
||||
'grand_total': so.grand_total
|
||||
'sales_order': so1.name,
|
||||
'sales_order_date': so1.transaction_date,
|
||||
'customer': so1.customer,
|
||||
'grand_total': so1.grand_total
|
||||
})
|
||||
so = make_sales_order(item_code=item, qty=2)
|
||||
so2 = make_sales_order(item_code=item, qty=2)
|
||||
pln.append('sales_orders', {
|
||||
'sales_order': so.name,
|
||||
'sales_order_date': so.transaction_date,
|
||||
'customer': so.customer,
|
||||
'grand_total': so.grand_total
|
||||
'sales_order': so2.name,
|
||||
'sales_order_date': so2.transaction_date,
|
||||
'customer': so2.customer,
|
||||
'grand_total': so2.grand_total
|
||||
})
|
||||
pln.combine_items = 1
|
||||
pln.get_items()
|
||||
@@ -214,28 +254,37 @@ class TestProductionPlan(ERPNextTestCase):
|
||||
so_wo_qty = frappe.db.get_value('Sales Order Item', so_item, 'work_order_qty')
|
||||
self.assertEqual(so_wo_qty, 0.0)
|
||||
|
||||
latest_plan = frappe.get_doc('Production Plan', pln.name)
|
||||
latest_plan.cancel()
|
||||
pln.reload()
|
||||
pln.cancel()
|
||||
|
||||
def test_pp_to_mr_customer_provided(self):
|
||||
#Material Request from Production Plan for Customer Provided
|
||||
" Test Material Request from Production Plan for Customer Provided Item."
|
||||
create_item('CUST-0987', is_customer_provided_item = 1, customer = '_Test Customer', is_purchase_item = 0)
|
||||
create_item('Production Item CUST')
|
||||
|
||||
for item, raw_materials in {'Production Item CUST': ['Raw Material Item 1', 'CUST-0987']}.items():
|
||||
if not frappe.db.get_value('BOM', {'item': item}):
|
||||
make_bom(item = item, raw_materials = raw_materials)
|
||||
production_plan = create_production_plan(item_code = 'Production Item CUST')
|
||||
production_plan.make_material_request()
|
||||
material_request = frappe.db.get_value('Material Request Item', {'production_plan': production_plan.name, 'item_code': 'CUST-0987'}, 'parent')
|
||||
|
||||
material_request = frappe.db.get_value(
|
||||
'Material Request Item',
|
||||
{'production_plan': production_plan.name, 'item_code': 'CUST-0987'},
|
||||
'parent'
|
||||
)
|
||||
mr = frappe.get_doc('Material Request', material_request)
|
||||
|
||||
self.assertTrue(mr.material_request_type, 'Customer Provided')
|
||||
self.assertTrue(mr.customer, '_Test Customer')
|
||||
|
||||
def test_production_plan_with_multi_level_bom(self):
|
||||
#|Item Code | Qty |
|
||||
#|Test BOM 1 | 1 |
|
||||
#| Test BOM 2 | 2 |
|
||||
#| Test BOM 3 | 3 |
|
||||
"""
|
||||
Item Code | Qty |
|
||||
|Test BOM 1 | 1 |
|
||||
|Test BOM 2 | 2 |
|
||||
|Test BOM 3 | 3 |
|
||||
"""
|
||||
|
||||
for item_code in ["Test BOM 1", "Test BOM 2", "Test BOM 3", "Test RM BOM 1"]:
|
||||
create_item(item_code, is_stock_item=1)
|
||||
@@ -264,15 +313,18 @@ class TestProductionPlan(ERPNextTestCase):
|
||||
pln.make_work_order()
|
||||
|
||||
#last level sub-assembly work order produce qty
|
||||
to_produce_qty = frappe.db.get_value("Work Order",
|
||||
{"production_plan": pln.name, "production_item": "Test BOM 3"}, "qty")
|
||||
to_produce_qty = frappe.db.get_value(
|
||||
"Work Order",
|
||||
{"production_plan": pln.name, "production_item": "Test BOM 3"},
|
||||
"qty"
|
||||
)
|
||||
|
||||
self.assertEqual(to_produce_qty, 18.0)
|
||||
pln.cancel()
|
||||
frappe.delete_doc("Production Plan", pln.name)
|
||||
|
||||
def test_get_warehouse_list_group(self):
|
||||
"""Check if required warehouses are returned"""
|
||||
"Check if required child warehouses are returned."
|
||||
warehouse_json = '[{\"warehouse\":\"_Test Warehouse Group - _TC\"}]'
|
||||
|
||||
warehouses = set(get_warehouse_list(warehouse_json))
|
||||
@@ -284,6 +336,7 @@ class TestProductionPlan(ERPNextTestCase):
|
||||
msg=f"Following warehouses were expected {', '.join(missing_warehouse)}")
|
||||
|
||||
def test_get_warehouse_list_single(self):
|
||||
"Check if same warehouse is returned in absence of child warehouses."
|
||||
warehouse_json = '[{\"warehouse\":\"_Test Scrap Warehouse - _TC\"}]'
|
||||
|
||||
warehouses = set(get_warehouse_list(warehouse_json))
|
||||
@@ -292,6 +345,7 @@ class TestProductionPlan(ERPNextTestCase):
|
||||
self.assertEqual(warehouses, expected_warehouses)
|
||||
|
||||
def test_get_sales_order_with_variant(self):
|
||||
"Check if Template BOM is fetched in absence of Variant BOM."
|
||||
rm_item = create_item('PIV_RM', valuation_rate = 100)
|
||||
if not frappe.db.exists('Item', {"item_code": 'PIV'}):
|
||||
item = create_item('PIV', valuation_rate = 100)
|
||||
@@ -348,7 +402,7 @@ class TestProductionPlan(ERPNextTestCase):
|
||||
frappe.db.rollback()
|
||||
|
||||
def test_subassmebly_sorting(self):
|
||||
""" Test subassembly sorting in case of multiple items with nested BOMs"""
|
||||
"Test subassembly sorting in case of multiple items with nested BOMs."
|
||||
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
|
||||
|
||||
prefix = "_TestLevel_"
|
||||
@@ -385,8 +439,164 @@ class TestProductionPlan(ERPNextTestCase):
|
||||
# lowest most level of subassembly should be first
|
||||
self.assertIn("SuperSecret", plan.sub_assembly_items[0].production_item)
|
||||
|
||||
def test_multiple_work_order_for_production_plan_item(self):
|
||||
"Test producing Prod Plan (making WO) in parts."
|
||||
def create_work_order(item, pln, qty):
|
||||
# Get Production Items
|
||||
items_data = pln.get_production_items()
|
||||
|
||||
# Update qty
|
||||
items_data[(item, None, None)]["qty"] = qty
|
||||
|
||||
# Create and Submit Work Order for each item in items_data
|
||||
for key, item in items_data.items():
|
||||
if pln.sub_assembly_items:
|
||||
item['use_multi_level_bom'] = 0
|
||||
|
||||
wo_name = pln.create_work_order(item)
|
||||
wo_doc = frappe.get_doc("Work Order", wo_name)
|
||||
wo_doc.update({
|
||||
'wip_warehouse': 'Work In Progress - _TC',
|
||||
'fg_warehouse': 'Finished Goods - _TC'
|
||||
})
|
||||
wo_doc.submit()
|
||||
wo_list.append(wo_name)
|
||||
|
||||
item = "Test Production Item 1"
|
||||
raw_materials = ["Raw Material Item 1", "Raw Material Item 2"]
|
||||
|
||||
# Create BOM
|
||||
bom = make_bom(item=item, raw_materials=raw_materials)
|
||||
|
||||
# Create Production Plan
|
||||
pln = create_production_plan(item_code=bom.item, planned_qty=10)
|
||||
|
||||
# All the created Work Orders
|
||||
wo_list = []
|
||||
|
||||
# Create and Submit 1st Work Order for 5 qty
|
||||
create_work_order(item, pln, 5)
|
||||
pln.reload()
|
||||
self.assertEqual(pln.po_items[0].ordered_qty, 5)
|
||||
|
||||
# Create and Submit 2nd Work Order for 3 qty
|
||||
create_work_order(item, pln, 3)
|
||||
pln.reload()
|
||||
self.assertEqual(pln.po_items[0].ordered_qty, 8)
|
||||
|
||||
# Cancel 1st Work Order
|
||||
wo1 = frappe.get_doc("Work Order", wo_list[0])
|
||||
wo1.cancel()
|
||||
pln.reload()
|
||||
self.assertEqual(pln.po_items[0].ordered_qty, 3)
|
||||
|
||||
# Cancel 2nd Work Order
|
||||
wo2 = frappe.get_doc("Work Order", wo_list[1])
|
||||
wo2.cancel()
|
||||
pln.reload()
|
||||
self.assertEqual(pln.po_items[0].ordered_qty, 0)
|
||||
|
||||
def test_production_plan_pending_qty_with_sales_order(self):
|
||||
"""
|
||||
Test Prod Plan impact via: SO -> Prod Plan -> WO -> SE -> SE (cancel)
|
||||
"""
|
||||
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 as make_se_from_wo,
|
||||
)
|
||||
|
||||
make_stock_entry(item_code="Raw Material Item 1",
|
||||
target="Work In Progress - _TC",
|
||||
qty=2, basic_rate=100
|
||||
)
|
||||
make_stock_entry(item_code="Raw Material Item 2",
|
||||
target="Work In Progress - _TC",
|
||||
qty=2, basic_rate=100
|
||||
)
|
||||
|
||||
item = 'Test Production Item 1'
|
||||
so = make_sales_order(item_code=item, qty=1)
|
||||
|
||||
pln = create_production_plan(
|
||||
company=so.company,
|
||||
get_items_from="Sales Order",
|
||||
sales_order=so,
|
||||
skip_getting_mr_items=True
|
||||
)
|
||||
self.assertEqual(pln.po_items[0].pending_qty, 1)
|
||||
|
||||
wo = make_wo_order_test_record(
|
||||
item_code=item, qty=1,
|
||||
company=so.company,
|
||||
wip_warehouse='Work In Progress - _TC',
|
||||
fg_warehouse='Finished Goods - _TC',
|
||||
skip_transfer=1,
|
||||
do_not_submit=True
|
||||
)
|
||||
wo.production_plan = pln.name
|
||||
wo.production_plan_item = pln.po_items[0].name
|
||||
wo.submit()
|
||||
|
||||
se = frappe.get_doc(make_se_from_wo(wo.name, "Manufacture", 1))
|
||||
se.submit()
|
||||
|
||||
pln.reload()
|
||||
self.assertEqual(pln.po_items[0].pending_qty, 0)
|
||||
|
||||
se.cancel()
|
||||
pln.reload()
|
||||
self.assertEqual(pln.po_items[0].pending_qty, 1)
|
||||
|
||||
def test_production_plan_pending_qty_independent_items(self):
|
||||
"Test Prod Plan impact if items are added independently (no from SO or MR)."
|
||||
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 as make_se_from_wo,
|
||||
)
|
||||
|
||||
make_stock_entry(item_code="Raw Material Item 1",
|
||||
target="Work In Progress - _TC",
|
||||
qty=2, basic_rate=100
|
||||
)
|
||||
make_stock_entry(item_code="Raw Material Item 2",
|
||||
target="Work In Progress - _TC",
|
||||
qty=2, basic_rate=100
|
||||
)
|
||||
|
||||
pln = create_production_plan(
|
||||
item_code='Test Production Item 1',
|
||||
skip_getting_mr_items=True
|
||||
)
|
||||
self.assertEqual(pln.po_items[0].pending_qty, 1)
|
||||
|
||||
wo = make_wo_order_test_record(
|
||||
item_code='Test Production Item 1', qty=1,
|
||||
company=pln.company,
|
||||
wip_warehouse='Work In Progress - _TC',
|
||||
fg_warehouse='Finished Goods - _TC',
|
||||
skip_transfer=1,
|
||||
do_not_submit=True
|
||||
)
|
||||
wo.production_plan = pln.name
|
||||
wo.production_plan_item = pln.po_items[0].name
|
||||
wo.submit()
|
||||
|
||||
se = frappe.get_doc(make_se_from_wo(wo.name, "Manufacture", 1))
|
||||
se.submit()
|
||||
|
||||
pln.reload()
|
||||
self.assertEqual(pln.po_items[0].pending_qty, 0)
|
||||
|
||||
se.cancel()
|
||||
pln.reload()
|
||||
self.assertEqual(pln.po_items[0].pending_qty, 1)
|
||||
|
||||
def create_production_plan(**args):
|
||||
"""
|
||||
sales_order (obj): Sales Order Doc Object
|
||||
get_items_from (str): Sales Order/Material Request
|
||||
skip_getting_mr_items (bool): Whether or not to plan for new MRs
|
||||
"""
|
||||
args = frappe._dict(args)
|
||||
|
||||
pln = frappe.get_doc({
|
||||
@@ -394,20 +604,35 @@ def create_production_plan(**args):
|
||||
'company': args.company or '_Test Company',
|
||||
'customer': args.customer or '_Test Customer',
|
||||
'posting_date': nowdate(),
|
||||
'include_non_stock_items': args.include_non_stock_items or 1,
|
||||
'include_subcontracted_items': args.include_subcontracted_items or 1,
|
||||
'ignore_existing_ordered_qty': args.ignore_existing_ordered_qty or 1,
|
||||
'po_items': [{
|
||||
'include_non_stock_items': args.include_non_stock_items or 0,
|
||||
'include_subcontracted_items': args.include_subcontracted_items or 0,
|
||||
'ignore_existing_ordered_qty': args.ignore_existing_ordered_qty or 0,
|
||||
'get_items_from': 'Sales Order'
|
||||
})
|
||||
|
||||
if not args.get("sales_order"):
|
||||
pln.append('po_items', {
|
||||
'use_multi_level_bom': args.use_multi_level_bom or 1,
|
||||
'item_code': args.item_code,
|
||||
'bom_no': frappe.db.get_value('Item', args.item_code, 'default_bom'),
|
||||
'planned_qty': args.planned_qty or 1,
|
||||
'planned_start_date': args.planned_start_date or now_datetime()
|
||||
}]
|
||||
})
|
||||
mr_items = get_items_for_material_requests(pln.as_dict())
|
||||
for d in mr_items:
|
||||
pln.append('mr_items', d)
|
||||
})
|
||||
|
||||
if args.get("get_items_from") == "Sales Order" and args.get("sales_order"):
|
||||
so = args.get("sales_order")
|
||||
pln.append('sales_orders', {
|
||||
'sales_order': so.name,
|
||||
'sales_order_date': so.transaction_date,
|
||||
'customer': so.customer,
|
||||
'grand_total': so.grand_total
|
||||
})
|
||||
pln.get_items()
|
||||
|
||||
if not args.get("skip_getting_mr_items"):
|
||||
mr_items = get_items_for_material_requests(pln.as_dict())
|
||||
for d in mr_items:
|
||||
pln.append('mr_items', d)
|
||||
|
||||
if not args.do_not_save:
|
||||
pln.insert()
|
||||
|
||||
@@ -198,6 +198,21 @@ class TestWorkOrder(ERPNextTestCase):
|
||||
self.assertEqual(cint(bin1_on_end_production.reserved_qty_for_production),
|
||||
cint(bin1_on_start_production.reserved_qty_for_production))
|
||||
|
||||
def test_reserved_qty_for_production_closed(self):
|
||||
|
||||
wo1 = make_wo_order_test_record(item="_Test FG Item", qty=2,
|
||||
source_warehouse=self.warehouse)
|
||||
item = wo1.required_items[0].item_code
|
||||
bin_before = get_bin(item, self.warehouse)
|
||||
bin_before.update_reserved_qty_for_production()
|
||||
|
||||
make_wo_order_test_record(item="_Test FG Item", qty=2,
|
||||
source_warehouse=self.warehouse)
|
||||
close_work_order(wo1.name, "Closed")
|
||||
|
||||
bin_after = get_bin(item, self.warehouse)
|
||||
self.assertEqual(bin_before.reserved_qty_for_production, bin_after.reserved_qty_for_production)
|
||||
|
||||
def test_backflush_qty_for_overpduction_manufacture(self):
|
||||
cancel_stock_entry = []
|
||||
allow_overproduction("overproduction_percentage_for_work_order", 30)
|
||||
@@ -700,7 +715,8 @@ class TestWorkOrder(ERPNextTestCase):
|
||||
wo = make_wo_order_test_record(item=item_name, qty=1, source_warehouse=source_warehouse,
|
||||
company=company)
|
||||
|
||||
self.assertRaises(frappe.ValidationError, make_stock_entry, wo.name, 'Material Transfer for Manufacture')
|
||||
stock_entry = frappe.get_doc(make_stock_entry(wo.name, 'Material Transfer for Manufacture'))
|
||||
self.assertRaises(frappe.ValidationError, stock_entry.save)
|
||||
|
||||
def test_wo_completion_with_pl_bom(self):
|
||||
from erpnext.manufacturing.doctype.bom.test_bom import (
|
||||
|
||||
@@ -8,6 +8,8 @@ from dateutil.relativedelta import relativedelta
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.model.mapper import get_mapped_doc
|
||||
from frappe.query_builder import Case
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import (
|
||||
cint,
|
||||
date_diff,
|
||||
@@ -74,7 +76,6 @@ class WorkOrder(Document):
|
||||
|
||||
self.set_required_items(reset_only_qty = len(self.get("required_items")))
|
||||
|
||||
|
||||
def validate_sales_order(self):
|
||||
if self.sales_order:
|
||||
self.check_sales_order_on_hold_or_close()
|
||||
@@ -271,7 +272,7 @@ class WorkOrder(Document):
|
||||
|
||||
produced_qty = total_qty[0][0] if total_qty else 0
|
||||
|
||||
production_plan.run_method("update_produced_qty", produced_qty, self.production_plan_item)
|
||||
production_plan.run_method("update_produced_pending_qty", produced_qty, self.production_plan_item)
|
||||
|
||||
def before_submit(self):
|
||||
self.create_serial_no_batch_no()
|
||||
@@ -449,7 +450,13 @@ class WorkOrder(Document):
|
||||
|
||||
def update_ordered_qty(self):
|
||||
if self.production_plan and self.production_plan_item:
|
||||
qty = self.qty if self.docstatus == 1 else 0
|
||||
qty = frappe.get_value("Production Plan Item", self.production_plan_item, "ordered_qty") or 0.0
|
||||
|
||||
if self.docstatus == 1:
|
||||
qty += self.qty
|
||||
elif self.docstatus == 2:
|
||||
qty -= self.qty
|
||||
|
||||
frappe.db.set_value('Production Plan Item',
|
||||
self.production_plan_item, 'ordered_qty', qty)
|
||||
|
||||
@@ -535,7 +542,7 @@ class WorkOrder(Document):
|
||||
if node.is_bom:
|
||||
operations.extend(_get_operations(node.name, qty=node.exploded_qty))
|
||||
|
||||
bom_qty = frappe.db.get_value("BOM", self.bom_no, "quantity")
|
||||
bom_qty = frappe.get_cached_value("BOM", self.bom_no, "quantity")
|
||||
operations.extend(_get_operations(self.bom_no, qty=1.0/bom_qty))
|
||||
|
||||
for correct_index, operation in enumerate(operations, start=1):
|
||||
@@ -615,7 +622,7 @@ class WorkOrder(Document):
|
||||
frappe.delete_doc("Job Card", d.name)
|
||||
|
||||
def validate_production_item(self):
|
||||
if frappe.db.get_value("Item", self.production_item, "has_variants"):
|
||||
if frappe.get_cached_value("Item", self.production_item, "has_variants"):
|
||||
frappe.throw(_("Work Order cannot be raised against a Item Template"), ItemHasVariantError)
|
||||
|
||||
if self.production_item:
|
||||
@@ -1165,3 +1172,27 @@ def create_pick_list(source_name, target_doc=None, for_qty=None):
|
||||
doc.set_item_locations()
|
||||
|
||||
return doc
|
||||
|
||||
def get_reserved_qty_for_production(item_code: str, warehouse: str) -> float:
|
||||
"""Get total reserved quantity for any item in specified warehouse"""
|
||||
wo = frappe.qb.DocType("Work Order")
|
||||
wo_item = frappe.qb.DocType("Work Order Item")
|
||||
|
||||
return (
|
||||
frappe.qb
|
||||
.from_(wo)
|
||||
.from_(wo_item)
|
||||
.select(Sum(Case()
|
||||
.when(wo.skip_transfer == 0, wo_item.required_qty - wo_item.transferred_qty)
|
||||
.else_(wo_item.required_qty - wo_item.consumed_qty))
|
||||
)
|
||||
.where(
|
||||
(wo_item.item_code == item_code)
|
||||
& (wo_item.parent == wo.name)
|
||||
& (wo.docstatus == 1)
|
||||
& (wo_item.source_warehouse == warehouse)
|
||||
& (wo.status.notin(["Stopped", "Completed", "Closed"]))
|
||||
& ((wo_item.required_qty > wo_item.transferred_qty)
|
||||
| (wo_item.required_qty > wo_item.consumed_qty))
|
||||
)
|
||||
).run()[0][0] or 0.0
|
||||
|
||||
@@ -89,10 +89,10 @@ def get_bom_stock(filters):
|
||||
GROUP BY bom_item.item_code""".format(qty_field=qty_field, table=table, conditions=conditions, bom=bom), as_dict=1)
|
||||
|
||||
def get_manufacturer_records():
|
||||
details = frappe.get_all('Item Manufacturer', fields = ["manufacturer", "manufacturer_part_no", "parent"])
|
||||
details = frappe.get_all('Item Manufacturer', fields = ["manufacturer", "manufacturer_part_no", "item_code"])
|
||||
manufacture_details = frappe._dict()
|
||||
for detail in details:
|
||||
dic = manufacture_details.setdefault(detail.get('parent'), {})
|
||||
dic = manufacture_details.setdefault(detail.get('item_code'), {})
|
||||
dic.setdefault('manufacturer', []).append(detail.get('manufacturer'))
|
||||
dic.setdefault('manufacturer_part', []).append(detail.get('manufacturer_part_no'))
|
||||
|
||||
|
||||
@@ -347,4 +347,7 @@ erpnext.patches.v13_0.hospitality_deprecation_warning
|
||||
erpnext.patches.v13_0.delete_bank_reconciliation_detail
|
||||
erpnext.patches.v13_0.update_sane_transfer_against
|
||||
erpnext.patches.v13_0.enable_provisional_accounting
|
||||
erpnext.patches.v13_0.update_disbursement_account
|
||||
erpnext.patches.v13_0.update_disbursement_account
|
||||
erpnext.patches.v13_0.update_reserved_qty_closed_wo
|
||||
erpnext.patches.v13_0.amazon_mws_deprecation_warning
|
||||
erpnext.patches.v13_0.set_work_order_qty_in_so_from_mr
|
||||
|
||||
@@ -10,6 +10,8 @@ def execute():
|
||||
FROM `tabBin`""",as_dict=1)
|
||||
|
||||
for entry in bin_details:
|
||||
if not (entry.item_code and entry.warehouse):
|
||||
continue
|
||||
update_bin_qty(entry.get("item_code"), entry.get("warehouse"), {
|
||||
"indented_qty": get_indented_qty(entry.get("item_code"), entry.get("warehouse"))
|
||||
})
|
||||
|
||||
15
erpnext/patches/v13_0/amazon_mws_deprecation_warning.py
Normal file
15
erpnext/patches/v13_0/amazon_mws_deprecation_warning.py
Normal file
@@ -0,0 +1,15 @@
|
||||
import click
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
|
||||
frappe.reload_doc("erpnext_integrations", "doctype", "amazon_mws_settings")
|
||||
if not frappe.db.get_single_value("Amazon MWS Settings", "enable_amazon"):
|
||||
return
|
||||
|
||||
click.secho(
|
||||
"Amazon MWS Integration is moved to a separate app and will be removed from ERPNext in version-14.\n"
|
||||
"Please install the app to continue using the integration: https://github.com/frappe/ecommerce_integrations",
|
||||
fg="yellow",
|
||||
)
|
||||
36
erpnext/patches/v13_0/set_work_order_qty_in_so_from_mr.py
Normal file
36
erpnext/patches/v13_0/set_work_order_qty_in_so_from_mr.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
"""
|
||||
1. Get submitted Work Orders with MR, MR Item and SO set
|
||||
2. Get SO Item detail from MR Item detail in WO, and set in WO
|
||||
3. Update work_order_qty in SO
|
||||
"""
|
||||
work_order = frappe.qb.DocType("Work Order")
|
||||
query = (
|
||||
frappe.qb.from_(work_order)
|
||||
.select(
|
||||
work_order.name, work_order.produced_qty,
|
||||
work_order.material_request,
|
||||
work_order.material_request_item,
|
||||
work_order.sales_order
|
||||
).where(
|
||||
(work_order.material_request.isnotnull())
|
||||
& (work_order.material_request_item.isnotnull())
|
||||
& (work_order.sales_order.isnotnull())
|
||||
& (work_order.docstatus == 1)
|
||||
& (work_order.produced_qty > 0)
|
||||
)
|
||||
)
|
||||
results = query.run(as_dict=True)
|
||||
|
||||
for row in results:
|
||||
so_item = frappe.get_value(
|
||||
"Material Request Item", row.material_request_item, "sales_order_item"
|
||||
)
|
||||
frappe.db.set_value("Work Order", row.name, "sales_order_item", so_item)
|
||||
|
||||
if so_item:
|
||||
wo = frappe.get_doc("Work Order", row.name)
|
||||
wo.update_work_order_qty_in_so()
|
||||
28
erpnext/patches/v13_0/update_reserved_qty_closed_wo.py
Normal file
28
erpnext/patches/v13_0/update_reserved_qty_closed_wo.py
Normal file
@@ -0,0 +1,28 @@
|
||||
import frappe
|
||||
|
||||
from erpnext.stock.utils import get_bin
|
||||
|
||||
|
||||
def execute():
|
||||
|
||||
wo = frappe.qb.DocType("Work Order")
|
||||
wo_item = frappe.qb.DocType("Work Order Item")
|
||||
|
||||
incorrect_item_wh = (
|
||||
frappe.qb
|
||||
.from_(wo)
|
||||
.join(wo_item).on(wo.name == wo_item.parent)
|
||||
.select(wo_item.item_code, wo.source_warehouse).distinct()
|
||||
.where(
|
||||
(wo.status == "Closed")
|
||||
& (wo.docstatus == 1)
|
||||
& (wo.source_warehouse.notnull())
|
||||
)
|
||||
).run()
|
||||
|
||||
for item_code, warehouse in incorrect_item_wh:
|
||||
if not (item_code and warehouse):
|
||||
continue
|
||||
|
||||
bin = get_bin(item_code, warehouse)
|
||||
bin.update_reserved_qty_for_production()
|
||||
@@ -29,9 +29,11 @@ def execute():
|
||||
""")
|
||||
|
||||
for item_code, warehouse in repost_for:
|
||||
update_bin_qty(item_code, warehouse, {
|
||||
"reserved_qty": get_reserved_qty(item_code, warehouse)
|
||||
})
|
||||
if not (item_code and warehouse):
|
||||
continue
|
||||
update_bin_qty(item_code, warehouse, {
|
||||
"reserved_qty": get_reserved_qty(item_code, warehouse)
|
||||
})
|
||||
|
||||
frappe.db.sql("""delete from tabBin
|
||||
where exists(
|
||||
|
||||
@@ -14,6 +14,8 @@ def execute():
|
||||
union
|
||||
select item_code, warehouse from `tabStock Ledger Entry`) a"""):
|
||||
try:
|
||||
if not (item_code and warehouse):
|
||||
continue
|
||||
count += 1
|
||||
update_bin_qty(item_code, warehouse, {
|
||||
"indented_qty": get_indented_qty(item_code, warehouse),
|
||||
|
||||
@@ -479,11 +479,12 @@ def get_emp_list(sal_struct, cond, end_date, payroll_payable_account):
|
||||
""" % cond, {"sal_struct": tuple(sal_struct), "from_date": end_date, "payroll_payable_account": payroll_payable_account}, as_dict=True)
|
||||
|
||||
def remove_payrolled_employees(emp_list, start_date, end_date):
|
||||
new_emp_list = []
|
||||
for employee_details in emp_list:
|
||||
if frappe.db.exists("Salary Slip", {"employee": employee_details.employee, "start_date": start_date, "end_date": end_date, "docstatus": 1}):
|
||||
emp_list.remove(employee_details)
|
||||
if not frappe.db.exists("Salary Slip", {"employee": employee_details.employee, "start_date": start_date, "end_date": end_date, "docstatus": 1}):
|
||||
new_emp_list.append(employee_details)
|
||||
|
||||
return emp_list
|
||||
return new_emp_list
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_start_end_dates(payroll_frequency, start_date=None, company=None):
|
||||
|
||||
@@ -125,7 +125,7 @@ class TestPayrollEntry(unittest.TestCase):
|
||||
|
||||
if not frappe.db.exists("Account", "_Test Payroll Payable - _TC"):
|
||||
create_account(account_name="_Test Payroll Payable",
|
||||
company="_Test Company", parent_account="Current Liabilities - _TC")
|
||||
company="_Test Company", parent_account="Current Liabilities - _TC", account_type="Payable")
|
||||
|
||||
if not frappe.db.get_value("Company", "_Test Company", "default_payroll_payable_account") or \
|
||||
frappe.db.get_value("Company", "_Test Company", "default_payroll_payable_account") != "_Test Payroll Payable - _TC":
|
||||
|
||||
@@ -147,7 +147,7 @@ class TestSalarySlip(unittest.TestCase):
|
||||
# Payroll based on attendance
|
||||
frappe.db.set_value("Payroll Settings", None, "payroll_based_on", "Attendance")
|
||||
|
||||
emp = make_employee("test_employee_timesheet@salary.com", company="_Test Company")
|
||||
emp = make_employee("test_employee_timesheet@salary.com", company="_Test Company", holiday_list="Salary Slip Test Holiday List")
|
||||
frappe.db.set_value("Employee", emp, {"relieving_date": None, "status": "Active"})
|
||||
|
||||
# mark attendance
|
||||
@@ -731,7 +731,7 @@ def get_salary_component_account(sal_comp, company_list=None):
|
||||
})
|
||||
sal_comp.save()
|
||||
|
||||
def create_account(account_name, company, parent_account):
|
||||
def create_account(account_name, company, parent_account, account_type=None):
|
||||
company_abbr = frappe.get_cached_value('Company', company, 'abbr')
|
||||
account = frappe.db.get_value("Account", account_name + " - " + company_abbr)
|
||||
if not account:
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"width": "50%"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"default": "Yes",
|
||||
"fieldname": "is_active",
|
||||
"fieldtype": "Select",
|
||||
@@ -232,10 +233,11 @@
|
||||
"idx": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2021-03-31 15:41:12.342380",
|
||||
"modified": "2022-02-03 23:50:10.205676",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Payroll",
|
||||
"name": "Salary Structure",
|
||||
"naming_rule": "Set by user",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
@@ -271,5 +273,6 @@
|
||||
],
|
||||
"show_name_in_global_search": 1,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC"
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
@@ -1443,7 +1443,8 @@ erpnext.TransactionController = erpnext.taxes_and_totals.extend({
|
||||
"item_code": d.item_code,
|
||||
"pricing_rules": d.pricing_rules,
|
||||
"parenttype": d.parenttype,
|
||||
"parent": d.parent
|
||||
"parent": d.parent,
|
||||
"price_list_rate": d.price_list_rate
|
||||
})
|
||||
}
|
||||
});
|
||||
@@ -2264,19 +2265,12 @@ erpnext.TransactionController = erpnext.taxes_and_totals.extend({
|
||||
|
||||
coupon_code: function() {
|
||||
var me = this;
|
||||
if (this.frm.doc.coupon_code) {
|
||||
frappe.run_serially([
|
||||
() => this.frm.doc.ignore_pricing_rule=1,
|
||||
() => me.ignore_pricing_rule(),
|
||||
() => this.frm.doc.ignore_pricing_rule=0,
|
||||
() => me.apply_pricing_rule()
|
||||
]);
|
||||
} else {
|
||||
frappe.run_serially([
|
||||
() => this.frm.doc.ignore_pricing_rule=1,
|
||||
() => me.ignore_pricing_rule()
|
||||
]);
|
||||
}
|
||||
frappe.run_serially([
|
||||
() => this.frm.doc.ignore_pricing_rule=1,
|
||||
() => me.ignore_pricing_rule(),
|
||||
() => this.frm.doc.ignore_pricing_rule=0,
|
||||
() => me.apply_pricing_rule()
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -296,6 +296,10 @@ class GSTR3BReport(Document):
|
||||
inter_state_supply_details = {}
|
||||
|
||||
for inv, items_based_on_rate in self.items_based_on_tax_rate.items():
|
||||
gst_category = self.invoice_detail_map.get(inv, {}).get('gst_category')
|
||||
place_of_supply = self.invoice_detail_map.get(inv, {}).get('place_of_supply') or '00-Other Territory'
|
||||
export_type = self.invoice_detail_map.get(inv, {}).get('export_type')
|
||||
|
||||
for rate, items in items_based_on_rate.items():
|
||||
for item_code, taxable_value in self.invoice_items.get(inv).items():
|
||||
if item_code in items:
|
||||
@@ -303,9 +307,8 @@ class GSTR3BReport(Document):
|
||||
self.report_dict['sup_details']['osup_nil_exmp']['txval'] += taxable_value
|
||||
elif item_code in self.is_non_gst:
|
||||
self.report_dict['sup_details']['osup_nongst']['txval'] += taxable_value
|
||||
elif rate == 0:
|
||||
elif rate == 0 or (gst_category == 'Overseas' and export_type == 'Without Payment of Tax'):
|
||||
self.report_dict['sup_details']['osup_zero']['txval'] += taxable_value
|
||||
#self.report_dict['sup_details']['osup_zero'][key] += tax_amount
|
||||
else:
|
||||
if inv in self.cgst_sgst_invoices:
|
||||
tax_rate = rate/2
|
||||
@@ -316,9 +319,6 @@ class GSTR3BReport(Document):
|
||||
self.report_dict['sup_details']['osup_det']['iamt'] += (taxable_value * rate /100)
|
||||
self.report_dict['sup_details']['osup_det']['txval'] += taxable_value
|
||||
|
||||
gst_category = self.invoice_detail_map.get(inv, {}).get('gst_category')
|
||||
place_of_supply = self.invoice_detail_map.get(inv, {}).get('place_of_supply') or '00-Other Territory'
|
||||
|
||||
if gst_category in ['Unregistered', 'Registered Composition', 'UIN Holders'] and \
|
||||
self.gst_details.get("gst_state") != place_of_supply.split("-")[1]:
|
||||
inter_state_supply_details.setdefault((gst_category, place_of_supply), {
|
||||
|
||||
@@ -40,7 +40,11 @@ frappe.query_reports["DATEV"] = {
|
||||
});
|
||||
|
||||
query_report.page.add_menu_item(__("Download DATEV File"), () => {
|
||||
const filters = JSON.stringify(query_report.get_values());
|
||||
const filters = encodeURIComponent(
|
||||
JSON.stringify(
|
||||
query_report.get_values()
|
||||
)
|
||||
);
|
||||
window.open(`/api/method/erpnext.regional.report.datev.datev.download_datev_csv?filters=${filters}`);
|
||||
});
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ class Gstr1Report(object):
|
||||
posting_date,
|
||||
base_grand_total,
|
||||
base_rounded_total,
|
||||
COALESCE(NULLIF(customer_gstin,''), NULLIF(billing_address_gstin, '')) as customer_gstin,
|
||||
NULLIF(billing_address_gstin, '') as billing_address_gstin,
|
||||
place_of_supply,
|
||||
ecommerce_gstin,
|
||||
reverse_charge,
|
||||
@@ -260,7 +260,7 @@ class Gstr1Report(object):
|
||||
|
||||
|
||||
if self.filters.get("type_of_business") == "B2B":
|
||||
conditions += "AND IFNULL(gst_category, '') in ('Registered Regular', 'Deemed Export', 'SEZ') AND is_return != 1 AND is_debit_note !=1"
|
||||
conditions += "AND IFNULL(gst_category, '') in ('Registered Regular', 'Registered Composition', 'Deemed Export', 'SEZ') AND is_return != 1 AND is_debit_note !=1"
|
||||
|
||||
if self.filters.get("type_of_business") in ("B2C Large", "B2C Small"):
|
||||
b2c_limit = frappe.db.get_single_value('GST Settings', 'b2c_limit')
|
||||
@@ -384,7 +384,7 @@ class Gstr1Report(object):
|
||||
for invoice, items in iteritems(self.invoice_items):
|
||||
if invoice not in self.items_based_on_tax_rate and invoice not in unidentified_gst_accounts_invoice \
|
||||
and self.invoices.get(invoice, {}).get('export_type') == "Without Payment of Tax" \
|
||||
and self.invoices.get(invoice, {}).get('gst_category') == "Overseas":
|
||||
and self.invoices.get(invoice, {}).get('gst_category') in ("Overseas", "SEZ"):
|
||||
self.items_based_on_tax_rate.setdefault(invoice, {}).setdefault(0, items.keys())
|
||||
|
||||
def get_columns(self):
|
||||
@@ -410,7 +410,7 @@ class Gstr1Report(object):
|
||||
if self.filters.get("type_of_business") == "B2B":
|
||||
self.invoice_columns = [
|
||||
{
|
||||
"fieldname": "customer_gstin",
|
||||
"fieldname": "billing_address_gstin",
|
||||
"label": "GSTIN/UIN of Recipient",
|
||||
"fieldtype": "Data",
|
||||
"width": 150
|
||||
@@ -517,7 +517,7 @@ class Gstr1Report(object):
|
||||
elif self.filters.get("type_of_business") == "CDNR-REG":
|
||||
self.invoice_columns = [
|
||||
{
|
||||
"fieldname": "customer_gstin",
|
||||
"fieldname": "billing_address_gstin",
|
||||
"label": "GSTIN/UIN of Recipient",
|
||||
"fieldtype": "Data",
|
||||
"width": 150
|
||||
@@ -818,7 +818,7 @@ def get_json(filters, report_name, data):
|
||||
res = {}
|
||||
if filters["type_of_business"] == "B2B":
|
||||
for item in report_data[:-1]:
|
||||
res.setdefault(item["customer_gstin"], {}).setdefault(item["invoice_number"],[]).append(item)
|
||||
res.setdefault(item["billing_address_gstin"], {}).setdefault(item["invoice_number"],[]).append(item)
|
||||
|
||||
out = get_b2b_json(res, gstin)
|
||||
gst_json["b2b"] = out
|
||||
@@ -842,7 +842,7 @@ def get_json(filters, report_name, data):
|
||||
gst_json["exp"] = out
|
||||
elif filters["type_of_business"] == "CDNR-REG":
|
||||
for item in report_data[:-1]:
|
||||
res.setdefault(item["customer_gstin"], {}).setdefault(item["invoice_number"],[]).append(item)
|
||||
res.setdefault(item["billing_address_gstin"], {}).setdefault(item["invoice_number"],[]).append(item)
|
||||
|
||||
out = get_cdnr_reg_json(res, gstin)
|
||||
gst_json["cdnr"] = out
|
||||
@@ -876,7 +876,7 @@ def get_json(filters, report_name, data):
|
||||
}
|
||||
|
||||
def get_b2b_json(res, gstin):
|
||||
inv_type, out = {"Registered Regular": "R", "Deemed Export": "DE", "URD": "URD", "SEZ": "SEZ"}, []
|
||||
out = []
|
||||
for gst_in in res:
|
||||
b2b_item, inv = {"ctin": gst_in, "inv": []}, []
|
||||
if not gst_in: continue
|
||||
@@ -890,7 +890,7 @@ def get_b2b_json(res, gstin):
|
||||
inv_item = get_basic_invoice_detail(invoice[0])
|
||||
inv_item["pos"] = "%02d" % int(invoice[0]["place_of_supply"].split('-')[0])
|
||||
inv_item["rchrg"] = invoice[0]["reverse_charge"]
|
||||
inv_item["inv_typ"] = inv_type.get(invoice[0].get("gst_category", ""),"")
|
||||
inv_item["inv_typ"] = get_invoice_type(invoice[0])
|
||||
|
||||
if inv_item["pos"]=="00": continue
|
||||
inv_item["itms"] = []
|
||||
@@ -1045,7 +1045,7 @@ def get_cdnr_reg_json(res, gstin):
|
||||
"ntty": invoice[0]["document_type"],
|
||||
"pos": "%02d" % int(invoice[0]["place_of_supply"].split('-')[0]),
|
||||
"rchrg": invoice[0]["reverse_charge"],
|
||||
"inv_typ": get_invoice_type_for_cdnr(invoice[0])
|
||||
"inv_typ": get_invoice_type(invoice[0])
|
||||
}
|
||||
|
||||
inv_item["itms"] = []
|
||||
@@ -1070,7 +1070,7 @@ def get_cdnr_unreg_json(res, gstin):
|
||||
"val": abs(flt(items[0]["invoice_value"])),
|
||||
"ntty": items[0]["document_type"],
|
||||
"pos": "%02d" % int(items[0]["place_of_supply"].split('-')[0]),
|
||||
"typ": get_invoice_type_for_cdnrur(items[0])
|
||||
"typ": get_invoice_type(items[0])
|
||||
}
|
||||
|
||||
inv_item["itms"] = []
|
||||
@@ -1111,29 +1111,21 @@ def get_exempted_json(data):
|
||||
|
||||
return out
|
||||
|
||||
def get_invoice_type_for_cdnr(row):
|
||||
if row.get('gst_category') == 'SEZ':
|
||||
if row.get('export_type') == 'WPAY':
|
||||
invoice_type = 'SEWP'
|
||||
else:
|
||||
invoice_type = 'SEWOP'
|
||||
elif row.get('gst_category') == 'Deemed Export':
|
||||
invoice_type = 'DE'
|
||||
elif row.get('gst_category') == 'Registered Regular':
|
||||
invoice_type = 'R'
|
||||
def get_invoice_type(row):
|
||||
gst_category = row.get('gst_category')
|
||||
|
||||
return invoice_type
|
||||
if gst_category == 'SEZ':
|
||||
return 'SEWP' if row.get('export_type') == 'WPAY' else 'SEWOP'
|
||||
|
||||
def get_invoice_type_for_cdnrur(row):
|
||||
if row.get('gst_category') == 'Overseas':
|
||||
if row.get('export_type') == 'WPAY':
|
||||
invoice_type = 'EXPWP'
|
||||
else:
|
||||
invoice_type = 'EXPWOP'
|
||||
elif row.get('gst_category') == 'Unregistered':
|
||||
invoice_type = 'B2CL'
|
||||
if gst_category == 'Overseas':
|
||||
return 'EXPWP' if row.get('export_type') == 'WPAY' else 'EXPWOP'
|
||||
|
||||
return invoice_type
|
||||
return ({
|
||||
'Deemed Export': 'DE',
|
||||
'Registered Regular': 'R',
|
||||
'Registered Composition': 'R',
|
||||
'Unregistered': 'B2CL'
|
||||
}).get(gst_category)
|
||||
|
||||
def get_basic_invoice_detail(row):
|
||||
return {
|
||||
@@ -1155,7 +1147,7 @@ def get_rate_and_tax_details(row, gstin):
|
||||
# calculate tax amount added
|
||||
tax = flt((row["taxable_value"]*rate)/100.0, 2)
|
||||
frappe.errprint([tax, tax/2])
|
||||
if row.get("customer_gstin") and gstin[0:2] == row["customer_gstin"][0:2]:
|
||||
if row.get("billing_address_gstin") and gstin[0:2] == row["billing_address_gstin"][0:2]:
|
||||
itm_det.update({"camt": flt(tax/2.0, 2), "samt": flt(tax/2.0, 2)})
|
||||
else:
|
||||
itm_det.update({"iamt": tax})
|
||||
@@ -1200,4 +1192,4 @@ def is_inter_state(invoice_detail):
|
||||
if invoice_detail.place_of_supply.split("-")[0] != invoice_detail.company_gstin[:2]:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
return False
|
||||
|
||||
@@ -6,7 +6,7 @@ import json
|
||||
import frappe
|
||||
import frappe.permissions
|
||||
from frappe.core.doctype.user_permission.test_user_permission import create_user
|
||||
from frappe.utils import add_days, flt, getdate, nowdate
|
||||
from frappe.utils import add_days, flt, getdate, nowdate, today
|
||||
|
||||
from erpnext.controllers.accounts_controller import update_child_qty_rate
|
||||
from erpnext.manufacturing.doctype.blanket_order.test_blanket_order import make_blanket_order
|
||||
@@ -1271,6 +1271,72 @@ class TestSalesOrder(ERPNextTestCase):
|
||||
|
||||
automatically_fetch_payment_terms(enable=0)
|
||||
|
||||
def test_zero_amount_sales_order_billing_status(self):
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
|
||||
so = make_sales_order(uom="Nos", do_not_save=1)
|
||||
so.items[0].rate = 0
|
||||
so.save()
|
||||
so.submit()
|
||||
|
||||
self.assertEqual(so.net_total, 0)
|
||||
self.assertEqual(so.billing_status, 'Not Billed')
|
||||
|
||||
si = create_sales_invoice(qty=10, do_not_save=1)
|
||||
si.price_list = '_Test Price List'
|
||||
si.items[0].rate = 0
|
||||
si.items[0].price_list_rate = 0
|
||||
si.items[0].sales_order = so.name
|
||||
si.items[0].so_detail = so.items[0].name
|
||||
si.save()
|
||||
si.submit()
|
||||
|
||||
self.assertEqual(si.net_total, 0)
|
||||
so.load_from_db()
|
||||
self.assertEqual(so.billing_status, 'Fully Billed')
|
||||
|
||||
def test_so_back_updated_from_wo_via_mr(self):
|
||||
"SO -> MR (Manufacture) -> WO. Test if WO Qty is updated in SO."
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import (
|
||||
make_stock_entry as make_se_from_wo,
|
||||
)
|
||||
from erpnext.stock.doctype.material_request.material_request import raise_work_orders
|
||||
|
||||
so = make_sales_order(item_list=[{"item_code": "_Test FG Item","qty": 2, "rate":100}])
|
||||
|
||||
mr = make_material_request(so.name)
|
||||
mr.material_request_type = "Manufacture"
|
||||
mr.schedule_date = today()
|
||||
mr.submit()
|
||||
|
||||
# WO from MR
|
||||
wo_name = raise_work_orders(mr.name)[0]
|
||||
wo = frappe.get_doc("Work Order", wo_name)
|
||||
wo.wip_warehouse = "Work In Progress - _TC"
|
||||
wo.skip_transfer = True
|
||||
|
||||
self.assertEqual(wo.sales_order, so.name)
|
||||
self.assertEqual(wo.sales_order_item, so.items[0].name)
|
||||
|
||||
wo.submit()
|
||||
make_stock_entry(item_code="_Test Item", # Stock RM
|
||||
target="Work In Progress - _TC",
|
||||
qty=4, basic_rate=100
|
||||
)
|
||||
make_stock_entry(item_code="_Test Item Home Desktop 100", # Stock RM
|
||||
target="Work In Progress - _TC",
|
||||
qty=4, basic_rate=100
|
||||
)
|
||||
|
||||
se = frappe.get_doc(make_se_from_wo(wo.name, "Manufacture", 2))
|
||||
se.submit() # Finish WO
|
||||
|
||||
mr.reload()
|
||||
wo.reload()
|
||||
so.reload()
|
||||
self.assertEqual(so.items[0].work_order_qty, wo.produced_qty)
|
||||
self.assertEqual(mr.status, "Manufactured")
|
||||
|
||||
def automatically_fetch_payment_terms(enable=1):
|
||||
accounts_settings = frappe.get_doc("Accounts Settings")
|
||||
accounts_settings.automatically_fetch_payment_terms = enable
|
||||
|
||||
@@ -83,8 +83,8 @@
|
||||
"planned_qty",
|
||||
"column_break_69",
|
||||
"work_order_qty",
|
||||
"delivered_qty",
|
||||
"produced_qty",
|
||||
"delivered_qty",
|
||||
"returned_qty",
|
||||
"shopping_cart_section",
|
||||
"additional_notes",
|
||||
@@ -701,10 +701,8 @@
|
||||
"width": "50px"
|
||||
},
|
||||
{
|
||||
"description": "For Production",
|
||||
"fieldname": "produced_qty",
|
||||
"fieldtype": "Float",
|
||||
"hidden": 1,
|
||||
"label": "Produced Quantity",
|
||||
"oldfieldname": "produced_qty",
|
||||
"oldfieldtype": "Currency",
|
||||
@@ -802,7 +800,7 @@
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2021-10-05 12:27:25.014789",
|
||||
"modified": "2022-02-21 13:55:08.883104",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Sales Order Item",
|
||||
@@ -811,5 +809,6 @@
|
||||
"permissions": [],
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
@@ -24,7 +24,7 @@ def search_by_term(search_term, warehouse, price_list):
|
||||
["name as item_code", "item_name", "description", "stock_uom", "image as item_image", "is_stock_item"],
|
||||
as_dict=1)
|
||||
|
||||
item_stock_qty = get_stock_availability(item_code, warehouse)
|
||||
item_stock_qty, is_stock_item = get_stock_availability(item_code, warehouse)
|
||||
price_list_rate, currency = frappe.db.get_value('Item Price', {
|
||||
'price_list': price_list,
|
||||
'item_code': item_code
|
||||
@@ -99,7 +99,6 @@ def get_items(start, page_length, price_list, item_group, pos_profile, search_te
|
||||
), {'warehouse': warehouse}, as_dict=1)
|
||||
|
||||
if items_data:
|
||||
items_data = filter_service_items(items_data)
|
||||
items = [d.item_code for d in items_data]
|
||||
item_prices_data = frappe.get_all("Item Price",
|
||||
fields = ["item_code", "price_list_rate", "currency"],
|
||||
@@ -112,7 +111,7 @@ def get_items(start, page_length, price_list, item_group, pos_profile, search_te
|
||||
for item in items_data:
|
||||
item_code = item.item_code
|
||||
item_price = item_prices.get(item_code) or {}
|
||||
item_stock_qty = get_stock_availability(item_code, warehouse)
|
||||
item_stock_qty, is_stock_item = get_stock_availability(item_code, warehouse)
|
||||
|
||||
row = {}
|
||||
row.update(item)
|
||||
@@ -144,14 +143,6 @@ def search_for_serial_or_batch_or_barcode_number(search_value):
|
||||
|
||||
return {}
|
||||
|
||||
def filter_service_items(items):
|
||||
for item in items:
|
||||
if not item['is_stock_item']:
|
||||
if not frappe.db.exists('Product Bundle', item['item_code']):
|
||||
items.remove(item)
|
||||
|
||||
return items
|
||||
|
||||
def get_conditions(search_term):
|
||||
condition = "("
|
||||
condition += """item.name like {search_term}
|
||||
|
||||
@@ -248,7 +248,7 @@ erpnext.PointOfSale.Controller = class {
|
||||
|
||||
numpad_event: (value, action) => this.update_item_field(value, action),
|
||||
|
||||
checkout: () => this.payment.checkout(),
|
||||
checkout: () => this.save_and_checkout(),
|
||||
|
||||
edit_cart: () => this.payment.edit_cart(),
|
||||
|
||||
@@ -630,18 +630,24 @@ erpnext.PointOfSale.Controller = class {
|
||||
}
|
||||
|
||||
async check_stock_availability(item_row, qty_needed, warehouse) {
|
||||
const available_qty = (await this.get_available_stock(item_row.item_code, warehouse)).message;
|
||||
const resp = (await this.get_available_stock(item_row.item_code, warehouse)).message;
|
||||
const available_qty = resp[0];
|
||||
const is_stock_item = resp[1];
|
||||
|
||||
frappe.dom.unfreeze();
|
||||
const bold_item_code = item_row.item_code.bold();
|
||||
const bold_warehouse = warehouse.bold();
|
||||
const bold_available_qty = available_qty.toString().bold()
|
||||
if (!(available_qty > 0)) {
|
||||
frappe.model.clear_doc(item_row.doctype, item_row.name);
|
||||
frappe.throw({
|
||||
title: __("Not Available"),
|
||||
message: __('Item Code: {0} is not available under warehouse {1}.', [bold_item_code, bold_warehouse])
|
||||
})
|
||||
if (is_stock_item) {
|
||||
frappe.model.clear_doc(item_row.doctype, item_row.name);
|
||||
frappe.throw({
|
||||
title: __("Not Available"),
|
||||
message: __('Item Code: {0} is not available under warehouse {1}.', [bold_item_code, bold_warehouse])
|
||||
});
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
} else if (available_qty < qty_needed) {
|
||||
frappe.throw({
|
||||
message: __('Stock quantity not enough for Item Code: {0} under warehouse {1}. Available quantity {2}.', [bold_item_code, bold_warehouse, bold_available_qty]),
|
||||
@@ -675,8 +681,8 @@ erpnext.PointOfSale.Controller = class {
|
||||
},
|
||||
callback(res) {
|
||||
if (!me.item_stock_map[item_code])
|
||||
me.item_stock_map[item_code] = {}
|
||||
me.item_stock_map[item_code][warehouse] = res.message;
|
||||
me.item_stock_map[item_code] = {};
|
||||
me.item_stock_map[item_code][warehouse] = res.message[0];
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -707,4 +713,9 @@ erpnext.PointOfSale.Controller = class {
|
||||
})
|
||||
.catch(e => console.log(e));
|
||||
}
|
||||
|
||||
async save_and_checkout() {
|
||||
this.frm.is_dirty() && await this.frm.save();
|
||||
this.payment.checkout();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -191,10 +191,10 @@ erpnext.PointOfSale.ItemCart = class {
|
||||
this.numpad_value = '';
|
||||
});
|
||||
|
||||
this.$component.on('click', '.checkout-btn', function() {
|
||||
this.$component.on('click', '.checkout-btn', async function() {
|
||||
if ($(this).attr('style').indexOf('--blue-500') == -1) return;
|
||||
|
||||
me.events.checkout();
|
||||
await me.events.checkout();
|
||||
me.toggle_checkout_btn(false);
|
||||
|
||||
me.allow_discount_change && me.$add_discount_elem.removeClass("d-none");
|
||||
@@ -985,6 +985,7 @@ erpnext.PointOfSale.ItemCart = class {
|
||||
$(frm.wrapper).off('refresh-fields');
|
||||
$(frm.wrapper).on('refresh-fields', () => {
|
||||
if (frm.doc.items.length) {
|
||||
this.$cart_items_wrapper.html('');
|
||||
frm.doc.items.forEach(item => {
|
||||
this.update_item_html(item);
|
||||
});
|
||||
|
||||
@@ -79,14 +79,20 @@ erpnext.PointOfSale.ItemSelector = class {
|
||||
const me = this;
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { item_image, serial_no, batch_no, barcode, actual_qty, stock_uom, price_list_rate } = item;
|
||||
const indicator_color = actual_qty > 10 ? "green" : actual_qty <= 0 ? "red" : "orange";
|
||||
const precision = flt(price_list_rate, 2) % 1 != 0 ? 2 : 0;
|
||||
|
||||
let indicator_color;
|
||||
let qty_to_display = actual_qty;
|
||||
|
||||
if (Math.round(qty_to_display) > 999) {
|
||||
qty_to_display = Math.round(qty_to_display)/1000;
|
||||
qty_to_display = qty_to_display.toFixed(1) + 'K';
|
||||
if (item.is_stock_item) {
|
||||
indicator_color = (actual_qty > 10 ? "green" : actual_qty <= 0 ? "red" : "orange");
|
||||
|
||||
if (Math.round(qty_to_display) > 999) {
|
||||
qty_to_display = Math.round(qty_to_display)/1000;
|
||||
qty_to_display = qty_to_display.toFixed(1) + 'K';
|
||||
}
|
||||
} else {
|
||||
indicator_color = '';
|
||||
qty_to_display = '';
|
||||
}
|
||||
|
||||
function get_item_image_html() {
|
||||
|
||||
@@ -169,6 +169,21 @@ erpnext.PointOfSale.Payment = class {
|
||||
}
|
||||
});
|
||||
|
||||
frappe.ui.form.on('POS Invoice', 'coupon_code', (frm) => {
|
||||
if (!frm.doc.ignore_pricing_rule) {
|
||||
if (frm.doc.coupon_code) {
|
||||
frappe.run_serially([
|
||||
() => frm.doc.ignore_pricing_rule=1,
|
||||
() => frm.trigger('ignore_pricing_rule'),
|
||||
() => frm.doc.ignore_pricing_rule=0,
|
||||
() => frm.trigger('apply_pricing_rule'),
|
||||
() => frm.save(),
|
||||
() => this.update_totals_section(frm.doc)
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.setup_listener_for_payments();
|
||||
|
||||
this.$payment_modes.on('click', '.shortcut', function() {
|
||||
|
||||
@@ -227,11 +227,11 @@ erpnext.selling.SellingController = erpnext.TransactionController.extend({
|
||||
},
|
||||
callback:function(r){
|
||||
if (in_list(['Delivery Note', 'Sales Invoice'], doc.doctype)) {
|
||||
|
||||
if (doc.doctype === 'Sales Invoice' && (!doc.update_stock)) return;
|
||||
|
||||
me.set_batch_number(cdt, cdn);
|
||||
me.batch_no(doc, cdt, cdn);
|
||||
if (has_batch_no) {
|
||||
me.set_batch_number(cdt, cdn);
|
||||
me.batch_no(doc, cdt, cdn);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -31,23 +31,9 @@ class Bin(Document):
|
||||
def update_reserved_qty_for_production(self):
|
||||
'''Update qty reserved for production from Production Item tables
|
||||
in open work orders'''
|
||||
self.reserved_qty_for_production = frappe.db.sql('''
|
||||
SELECT
|
||||
SUM(CASE WHEN ifnull(skip_transfer, 0) = 0 THEN
|
||||
item.required_qty - item.transferred_qty
|
||||
ELSE
|
||||
item.required_qty - item.consumed_qty END)
|
||||
END
|
||||
FROM `tabWork Order` pro, `tabWork Order Item` item
|
||||
WHERE
|
||||
item.item_code = %s
|
||||
and item.parent = pro.name
|
||||
and pro.docstatus = 1
|
||||
and item.source_warehouse = %s
|
||||
and pro.status not in ("Stopped", "Completed")
|
||||
and (item.required_qty > item.transferred_qty or item.required_qty > item.consumed_qty)
|
||||
''', (self.item_code, self.warehouse))[0][0]
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import get_reserved_qty_for_production
|
||||
|
||||
self.reserved_qty_for_production = get_reserved_qty_for_production(self.item_code, self.warehouse)
|
||||
self.set_projected_qty()
|
||||
|
||||
self.db_set('reserved_qty_for_production', flt(self.reserved_qty_for_production))
|
||||
|
||||
@@ -339,17 +339,31 @@ class DeliveryNote(SellingController):
|
||||
frappe.throw(_("Could not create Credit Note automatically, please uncheck 'Issue Credit Note' and submit again"))
|
||||
|
||||
def update_billed_amount_based_on_so(so_detail, update_modified=True):
|
||||
from frappe.query_builder.functions import Sum
|
||||
|
||||
# Billed against Sales Order directly
|
||||
billed_against_so = frappe.db.sql("""select sum(amount) from `tabSales Invoice Item`
|
||||
where so_detail=%s and (dn_detail is null or dn_detail = '') and docstatus=1""", so_detail)
|
||||
si_item = frappe.qb.DocType("Sales Invoice Item").as_("si_item")
|
||||
sum_amount = Sum(si_item.amount).as_("amount")
|
||||
|
||||
billed_against_so = frappe.qb.from_(si_item).select(sum_amount).where(
|
||||
(si_item.so_detail == so_detail) &
|
||||
((si_item.dn_detail.isnull()) | (si_item.dn_detail == '')) &
|
||||
(si_item.docstatus == 1)
|
||||
).run()
|
||||
billed_against_so = billed_against_so and billed_against_so[0][0] or 0
|
||||
|
||||
# Get all Delivery Note Item rows against the Sales Order Item row
|
||||
dn_details = frappe.db.sql("""select dn_item.name, dn_item.amount, dn_item.si_detail, dn_item.parent
|
||||
from `tabDelivery Note Item` dn_item, `tabDelivery Note` dn
|
||||
where dn.name=dn_item.parent and dn_item.so_detail=%s
|
||||
and dn.docstatus=1 and dn.is_return = 0
|
||||
order by dn.posting_date asc, dn.posting_time asc, dn.name asc""", so_detail, as_dict=1)
|
||||
dn = frappe.qb.DocType("Delivery Note").as_("dn")
|
||||
dn_item = frappe.qb.DocType("Delivery Note Item").as_("dn_item")
|
||||
|
||||
dn_details = frappe.qb.from_(dn).from_(dn_item).select(dn_item.name, dn_item.amount, dn_item.si_detail, dn_item.parent).where(
|
||||
(dn.name == dn_item.parent) &
|
||||
(dn_item.so_detail == so_detail) &
|
||||
(dn.docstatus == 1) &
|
||||
(dn.is_return == 0)
|
||||
).orderby(
|
||||
dn.posting_date, dn.posting_time, dn.name
|
||||
).run(as_dict=True)
|
||||
|
||||
updated_dn = []
|
||||
for dnd in dn_details:
|
||||
|
||||
@@ -545,7 +545,7 @@ $.extend(erpnext.item, {
|
||||
let selected_attributes = {};
|
||||
me.multiple_variant_dialog.$wrapper.find('.form-column').each((i, col) => {
|
||||
if(i===0) return;
|
||||
let attribute_name = $(col).find('label').html();
|
||||
let attribute_name = $(col).find('label').html().trim();
|
||||
selected_attributes[attribute_name] = [];
|
||||
let checked_opts = $(col).find('.checkbox input');
|
||||
checked_opts.each((i, opt) => {
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
|
||||
|
||||
import frappe
|
||||
from frappe.utils import flt
|
||||
from frappe.utils import add_to_date, flt, now
|
||||
|
||||
from erpnext.accounts.doctype.account.test_account import create_account, get_inventory_account
|
||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
||||
from erpnext.accounts.utils import update_gl_entries_after
|
||||
from erpnext.assets.doctype.asset.test_asset import create_asset_category, create_fixed_asset_item
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import (
|
||||
get_gl_entries,
|
||||
@@ -28,7 +29,8 @@ class TestLandedCostVoucher(ERPNextTestCase):
|
||||
"voucher_type": pr.doctype,
|
||||
"voucher_no": pr.name,
|
||||
"item_code": "_Test Item",
|
||||
"warehouse": "Stores - TCP1"
|
||||
"warehouse": "Stores - TCP1",
|
||||
"is_cancelled": 0,
|
||||
},
|
||||
fieldname=["qty_after_transaction", "stock_value"], as_dict=1)
|
||||
|
||||
@@ -41,14 +43,39 @@ class TestLandedCostVoucher(ERPNextTestCase):
|
||||
"voucher_type": pr.doctype,
|
||||
"voucher_no": pr.name,
|
||||
"item_code": "_Test Item",
|
||||
"warehouse": "Stores - TCP1"
|
||||
"warehouse": "Stores - TCP1",
|
||||
"is_cancelled": 0,
|
||||
},
|
||||
fieldname=["qty_after_transaction", "stock_value"], as_dict=1)
|
||||
|
||||
self.assertEqual(last_sle.qty_after_transaction, last_sle_after_landed_cost.qty_after_transaction)
|
||||
|
||||
self.assertEqual(last_sle_after_landed_cost.stock_value - last_sle.stock_value, 25.0)
|
||||
|
||||
# assert after submit
|
||||
self.assertPurchaseReceiptLCVGLEntries(pr)
|
||||
|
||||
# Mess up cancelled SLE modified timestamp to check
|
||||
# if they aren't effective in any business logic.
|
||||
frappe.db.set_value("Stock Ledger Entry",
|
||||
{
|
||||
"is_cancelled": 1,
|
||||
"voucher_type": pr.doctype,
|
||||
"voucher_no": pr.name
|
||||
},
|
||||
"is_cancelled", 1,
|
||||
modified=add_to_date(now(), hours=1, as_datetime=True, as_string=True)
|
||||
)
|
||||
|
||||
items, warehouses = pr.get_items_and_warehouses()
|
||||
update_gl_entries_after(pr.posting_date, pr.posting_time,
|
||||
warehouses, items, company=pr.company)
|
||||
|
||||
# reassert after reposting
|
||||
self.assertPurchaseReceiptLCVGLEntries(pr)
|
||||
|
||||
|
||||
def assertPurchaseReceiptLCVGLEntries(self, pr):
|
||||
|
||||
gl_entries = get_gl_entries("Purchase Receipt", pr.name)
|
||||
|
||||
self.assertTrue(gl_entries)
|
||||
@@ -74,8 +101,8 @@ class TestLandedCostVoucher(ERPNextTestCase):
|
||||
|
||||
for gle in gl_entries:
|
||||
if not gle.get('is_cancelled'):
|
||||
self.assertEqual(expected_values[gle.account][0], gle.debit)
|
||||
self.assertEqual(expected_values[gle.account][1], gle.credit)
|
||||
self.assertEqual(expected_values[gle.account][0], gle.debit, msg=f"incorrect debit for {gle.account}")
|
||||
self.assertEqual(expected_values[gle.account][1], gle.credit, msg=f"incorrect credit for {gle.account}")
|
||||
|
||||
|
||||
def test_landed_cost_voucher_against_purchase_invoice(self):
|
||||
|
||||
@@ -534,6 +534,7 @@ def raise_work_orders(material_request):
|
||||
"stock_uom": d.stock_uom,
|
||||
"expected_delivery_date": d.schedule_date,
|
||||
"sales_order": d.sales_order,
|
||||
"sales_order_item": d.get("sales_order_item"),
|
||||
"bom_no": get_item_details(d.item_code).bom_no,
|
||||
"material_request": mr.name,
|
||||
"material_request_item": d.name,
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
from frappe.utils import add_to_date, nowdate
|
||||
|
||||
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
|
||||
from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import get_gl_entries
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
from erpnext.tests.utils import ERPNextTestCase, change_settings
|
||||
|
||||
|
||||
@@ -12,31 +16,30 @@ class TestPackedItem(ERPNextTestCase):
|
||||
"Test impact on Packed Items table in various scenarios."
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
make_item("_Test Product Bundle X", {"is_stock_item": 0})
|
||||
make_item("_Test Bundle Item 1", {"is_stock_item": 1})
|
||||
make_item("_Test Bundle Item 2", {"is_stock_item": 1})
|
||||
super().setUpClass()
|
||||
cls.bundle = "_Test Product Bundle X"
|
||||
cls.bundle_items = ["_Test Bundle Item 1", "_Test Bundle Item 2"]
|
||||
make_item(cls.bundle, {"is_stock_item": 0})
|
||||
for item in cls.bundle_items:
|
||||
make_item(item, {"is_stock_item": 1})
|
||||
|
||||
make_item("_Test Normal Stock Item", {"is_stock_item": 1})
|
||||
|
||||
make_product_bundle(
|
||||
"_Test Product Bundle X",
|
||||
["_Test Bundle Item 1", "_Test Bundle Item 2"],
|
||||
qty=2
|
||||
)
|
||||
make_product_bundle(cls.bundle, cls.bundle_items, qty=2)
|
||||
|
||||
def test_adding_bundle_item(self):
|
||||
"Test impact on packed items if bundle item row is added."
|
||||
so = make_sales_order(item_code = "_Test Product Bundle X", qty=1,
|
||||
so = make_sales_order(item_code = self.bundle, qty=1,
|
||||
do_not_submit=True)
|
||||
|
||||
self.assertEqual(so.items[0].qty, 1)
|
||||
self.assertEqual(len(so.packed_items), 2)
|
||||
self.assertEqual(so.packed_items[0].item_code, "_Test Bundle Item 1")
|
||||
self.assertEqual(so.packed_items[0].item_code, self.bundle_items[0])
|
||||
self.assertEqual(so.packed_items[0].qty, 2)
|
||||
|
||||
def test_updating_bundle_item(self):
|
||||
"Test impact on packed items if bundle item row is updated."
|
||||
so = make_sales_order(item_code = "_Test Product Bundle X", qty=1,
|
||||
do_not_submit=True)
|
||||
so = make_sales_order(item_code=self.bundle, qty=1, do_not_submit=True)
|
||||
|
||||
so.items[0].qty = 2 # change qty
|
||||
so.save()
|
||||
@@ -55,7 +58,7 @@ class TestPackedItem(ERPNextTestCase):
|
||||
so_items = []
|
||||
for qty in [2, 4, 6, 8]:
|
||||
so_items.append({
|
||||
"item_code": "_Test Product Bundle X",
|
||||
"item_code": self.bundle,
|
||||
"qty": qty,
|
||||
"rate": 400,
|
||||
"warehouse": "_Test Warehouse - _TC"
|
||||
@@ -66,7 +69,7 @@ class TestPackedItem(ERPNextTestCase):
|
||||
|
||||
# check alternate rows for qty
|
||||
self.assertEqual(len(so.packed_items), 8)
|
||||
self.assertEqual(so.packed_items[1].item_code, "_Test Bundle Item 2")
|
||||
self.assertEqual(so.packed_items[1].item_code, self.bundle_items[1])
|
||||
self.assertEqual(so.packed_items[1].qty, 4)
|
||||
self.assertEqual(so.packed_items[3].qty, 8)
|
||||
self.assertEqual(so.packed_items[5].qty, 12)
|
||||
@@ -94,8 +97,7 @@ class TestPackedItem(ERPNextTestCase):
|
||||
@change_settings("Selling Settings", {"editable_bundle_item_rates": 1})
|
||||
def test_bundle_item_cumulative_price(self):
|
||||
"Test if Bundle Item rate is cumulative from packed items."
|
||||
so = make_sales_order(item_code = "_Test Product Bundle X", qty=2,
|
||||
do_not_submit=True)
|
||||
so = make_sales_order(item_code=self.bundle, qty=2, do_not_submit=True)
|
||||
|
||||
so.packed_items[0].rate = 150
|
||||
so.packed_items[1].rate = 200
|
||||
@@ -109,7 +111,7 @@ class TestPackedItem(ERPNextTestCase):
|
||||
so_items = []
|
||||
for qty in [2, 4]:
|
||||
so_items.append({
|
||||
"item_code": "_Test Product Bundle X",
|
||||
"item_code": self.bundle,
|
||||
"qty": qty,
|
||||
"rate": 400,
|
||||
"warehouse": "_Test Warehouse - _TC"
|
||||
@@ -124,4 +126,33 @@ class TestPackedItem(ERPNextTestCase):
|
||||
|
||||
self.assertEqual(len(dn.packed_items), 4)
|
||||
self.assertEqual(dn.packed_items[2].qty, 6)
|
||||
self.assertEqual(dn.packed_items[3].qty, 6)
|
||||
self.assertEqual(dn.packed_items[3].qty, 6)
|
||||
|
||||
def test_reposting_packed_items(self):
|
||||
warehouse = "Stores - TCP1"
|
||||
company = "_Test Company with perpetual inventory"
|
||||
|
||||
today = nowdate()
|
||||
yesterday = add_to_date(today, days=-1, as_string=True)
|
||||
|
||||
for item in self.bundle_items:
|
||||
make_stock_entry(item_code=item, to_warehouse=warehouse, qty=10, rate=100, posting_date=today)
|
||||
|
||||
so = make_sales_order(item_code = self.bundle, qty=1, company=company, warehouse=warehouse)
|
||||
|
||||
dn = make_delivery_note(so.name)
|
||||
dn.save()
|
||||
dn.submit()
|
||||
|
||||
gles = get_gl_entries(dn.doctype, dn.name)
|
||||
credit_before_repost = sum(gle.credit for gle in gles)
|
||||
|
||||
# backdated stock entry
|
||||
for item in self.bundle_items:
|
||||
make_stock_entry(item_code=item, to_warehouse=warehouse, qty=10, rate=200, posting_date=yesterday)
|
||||
|
||||
# assert correct reposting
|
||||
gles = get_gl_entries(dn.doctype, dn.name)
|
||||
credit_after_reposting = sum(gle.credit for gle in gles)
|
||||
self.assertNotEqual(credit_before_repost, credit_after_reposting)
|
||||
self.assertAlmostEqual(credit_after_reposting, 2 * credit_before_repost)
|
||||
|
||||
@@ -281,10 +281,7 @@ class PurchaseReceipt(BuyingController):
|
||||
if warehouse_account.get(d.warehouse):
|
||||
stock_value_diff = frappe.db.get_value("Stock Ledger Entry",
|
||||
{"voucher_type": "Purchase Receipt", "voucher_no": self.name,
|
||||
"voucher_detail_no": d.name, "warehouse": d.warehouse}, "stock_value_difference")
|
||||
|
||||
if not stock_value_diff:
|
||||
continue
|
||||
"voucher_detail_no": d.name, "warehouse": d.warehouse, "is_cancelled": 0}, "stock_value_difference")
|
||||
|
||||
warehouse_account_name = warehouse_account[d.warehouse]["account"]
|
||||
warehouse_account_currency = warehouse_account[d.warehouse]["account_currency"]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import json
|
||||
import unittest
|
||||
from collections import defaultdict
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_days, cint, cstr, flt, today
|
||||
@@ -17,7 +18,7 @@ from erpnext.stock.doctype.purchase_receipt.purchase_receipt import make_purchas
|
||||
from erpnext.stock.doctype.serial_no.serial_no import SerialNoDuplicateError, get_serial_nos
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
from erpnext.stock.stock_ledger import SerialNoExistsInFutureTransaction
|
||||
from erpnext.tests.utils import ERPNextTestCase
|
||||
from erpnext.tests.utils import ERPNextTestCase, change_settings
|
||||
|
||||
|
||||
class TestPurchaseReceipt(ERPNextTestCase):
|
||||
@@ -1367,6 +1368,36 @@ class TestPurchaseReceipt(ERPNextTestCase):
|
||||
|
||||
automatically_fetch_payment_terms(enable=0)
|
||||
|
||||
@change_settings("Stock Settings", {"allow_negative_stock": 1})
|
||||
def test_neg_to_positive(self):
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
item_code = "_TestNegToPosItem"
|
||||
warehouse = "Stores - TCP1"
|
||||
company = "_Test Company with perpetual inventory"
|
||||
account = "Stock Received But Not Billed - TCP1"
|
||||
|
||||
make_item(item_code)
|
||||
se = make_stock_entry(item_code=item_code, from_warehouse=warehouse, qty=50, do_not_save=True, rate=0)
|
||||
se.items[0].allow_zero_valuation_rate = 1
|
||||
se.save()
|
||||
se.submit()
|
||||
|
||||
pr = make_purchase_receipt(
|
||||
qty=50,
|
||||
rate=1,
|
||||
item_code=item_code,
|
||||
warehouse=warehouse,
|
||||
get_taxes_and_charges=True,
|
||||
company=company,
|
||||
)
|
||||
gles = get_gl_entries(pr.doctype, pr.name)
|
||||
|
||||
for gle in gles:
|
||||
if gle.account == account:
|
||||
self.assertEqual(gle.credit, 50)
|
||||
|
||||
|
||||
def get_sl_entries(voucher_type, voucher_no):
|
||||
return frappe.db.sql(""" select actual_qty, warehouse, stock_value_difference
|
||||
from `tabStock Ledger Entry` where voucher_type=%s and voucher_no=%s
|
||||
|
||||
@@ -13,7 +13,7 @@ from erpnext.accounts.utils import (
|
||||
check_if_stock_and_account_balance_synced,
|
||||
update_gl_entries_after,
|
||||
)
|
||||
from erpnext.stock.stock_ledger import repost_future_sle
|
||||
from erpnext.stock.stock_ledger import get_items_to_be_repost, repost_future_sle
|
||||
|
||||
|
||||
class RepostItemValuation(Document):
|
||||
@@ -138,13 +138,20 @@ def repost_gl_entries(doc):
|
||||
|
||||
if doc.based_on == 'Transaction':
|
||||
ref_doc = frappe.get_doc(doc.voucher_type, doc.voucher_no)
|
||||
items, warehouses = ref_doc.get_items_and_warehouses()
|
||||
doc_items, doc_warehouses = ref_doc.get_items_and_warehouses()
|
||||
|
||||
sles = get_items_to_be_repost(doc.voucher_type, doc.voucher_no)
|
||||
sle_items = [sle.item_code for sle in sles]
|
||||
sle_warehouse = [sle.warehouse for sle in sles]
|
||||
|
||||
items = list(set(doc_items).union(set(sle_items)))
|
||||
warehouses = list(set(doc_warehouses).union(set(sle_warehouse)))
|
||||
else:
|
||||
items = [doc.item_code]
|
||||
warehouses = [doc.warehouse]
|
||||
|
||||
update_gl_entries_after(doc.posting_date, doc.posting_time,
|
||||
warehouses, items, company=doc.company)
|
||||
for_warehouses=warehouses, for_items=items, company=doc.company)
|
||||
|
||||
def notify_error_to_stock_managers(doc, traceback):
|
||||
recipients = get_users_with_role("Stock Manager")
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"items_section",
|
||||
"title",
|
||||
"naming_series",
|
||||
"stock_entry_type",
|
||||
"outgoing_stock_entry",
|
||||
@@ -83,14 +82,6 @@
|
||||
"fieldtype": "Section Break",
|
||||
"oldfieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "title",
|
||||
"fieldtype": "Data",
|
||||
"hidden": 1,
|
||||
"label": "Title",
|
||||
"no_copy": 1,
|
||||
"print_hide": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "naming_series",
|
||||
"fieldtype": "Select",
|
||||
@@ -353,9 +344,9 @@
|
||||
},
|
||||
{
|
||||
"fieldname": "scan_barcode",
|
||||
"options": "Barcode",
|
||||
"fieldtype": "Data",
|
||||
"label": "Scan Barcode"
|
||||
"label": "Scan Barcode",
|
||||
"options": "Barcode"
|
||||
},
|
||||
{
|
||||
"allow_bulk_edit": 1,
|
||||
@@ -628,10 +619,11 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2021-08-20 19:19:31.514846",
|
||||
"modified": "2022-02-07 12:55:14.614077",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Stock Entry",
|
||||
"naming_rule": "By \"Naming Series\" field",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
@@ -698,6 +690,7 @@
|
||||
"show_name_in_global_search": 1,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"title_field": "title",
|
||||
"states": [],
|
||||
"title_field": "stock_entry_type",
|
||||
"track_changes": 1
|
||||
}
|
||||
@@ -77,7 +77,6 @@ class StockEntry(StockController):
|
||||
|
||||
self.validate_posting_time()
|
||||
self.validate_purpose()
|
||||
self.set_title()
|
||||
self.validate_item()
|
||||
self.validate_customer_provided_item()
|
||||
self.validate_qty()
|
||||
@@ -1117,7 +1116,7 @@ class StockEntry(StockController):
|
||||
self.set_actual_qty()
|
||||
self.update_items_for_process_loss()
|
||||
self.validate_customer_provided_item()
|
||||
self.calculate_rate_and_amount()
|
||||
self.calculate_rate_and_amount(raise_error_if_no_rate=False)
|
||||
|
||||
def set_scrap_items(self):
|
||||
if self.purpose != "Send to Subcontractor" and self.purpose in ["Manufacture", "Repack"]:
|
||||
@@ -1837,14 +1836,6 @@ class StockEntry(StockController):
|
||||
|
||||
return sorted(list(set(get_serial_nos(self.pro_doc.serial_no)) - set(used_serial_nos)))
|
||||
|
||||
def set_title(self):
|
||||
if frappe.flags.in_import and self.title:
|
||||
# Allow updating title during data import/update
|
||||
return
|
||||
|
||||
self.title = self.purpose
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def move_sample_to_retention_warehouse(company, items):
|
||||
if isinstance(items, string_types):
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"items",
|
||||
"section_break_9",
|
||||
"expense_account",
|
||||
"reconciliation_json",
|
||||
"column_break_13",
|
||||
"difference_amount",
|
||||
"amended_from",
|
||||
@@ -111,15 +110,6 @@
|
||||
"label": "Cost Center",
|
||||
"options": "Cost Center"
|
||||
},
|
||||
{
|
||||
"fieldname": "reconciliation_json",
|
||||
"fieldtype": "Long Text",
|
||||
"hidden": 1,
|
||||
"label": "Reconciliation JSON",
|
||||
"no_copy": 1,
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_13",
|
||||
"fieldtype": "Column Break"
|
||||
@@ -155,7 +145,7 @@
|
||||
"idx": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2021-11-30 01:33:51.437194",
|
||||
"modified": "2022-02-06 14:28:19.043905",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Stock Reconciliation",
|
||||
@@ -178,5 +168,6 @@
|
||||
"search_fields": "posting_date",
|
||||
"show_name_in_global_search": 1,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC"
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
@@ -25,8 +25,8 @@ from erpnext.tests.utils import ERPNextTestCase, change_settings
|
||||
class TestStockReconciliation(ERPNextTestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
create_batch_or_serial_no_items()
|
||||
super().setUpClass()
|
||||
frappe.db.set_value("Stock Settings", None, "allow_negative_stock", 1)
|
||||
|
||||
def tearDown(self):
|
||||
|
||||
@@ -344,6 +344,7 @@ def get_basic_details(args, item, overwrite_warehouse=True):
|
||||
|
||||
args.conversion_factor = out.conversion_factor
|
||||
out.stock_qty = out.qty * out.conversion_factor
|
||||
args.stock_qty = out.stock_qty
|
||||
|
||||
# calculate last purchase rate
|
||||
if args.get('doctype') in purchase_doctypes:
|
||||
|
||||
@@ -12,6 +12,7 @@ from frappe.utils import cint, date_diff, flt
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
|
||||
|
||||
Filters = frappe._dict
|
||||
precision = cint(frappe.db.get_single_value("System Settings", "float_precision"))
|
||||
|
||||
def execute(filters: Filters = None) -> Tuple:
|
||||
to_date = filters["to_date"]
|
||||
@@ -48,10 +49,13 @@ def format_report_data(filters: Filters, item_details: Dict, to_date: str) -> Li
|
||||
if filters.get("show_warehouse_wise_stock"):
|
||||
row.append(details.warehouse)
|
||||
|
||||
row.extend([item_dict.get("total_qty"), average_age,
|
||||
row.extend([
|
||||
flt(item_dict.get("total_qty"), precision),
|
||||
average_age,
|
||||
range1, range2, range3, above_range3,
|
||||
earliest_age, latest_age,
|
||||
details.stock_uom])
|
||||
details.stock_uom
|
||||
])
|
||||
|
||||
data.append(row)
|
||||
|
||||
@@ -79,13 +83,13 @@ def get_range_age(filters: Filters, fifo_queue: List, to_date: str, item_dict: D
|
||||
qty = flt(item[0]) if not item_dict["has_serial_no"] else 1.0
|
||||
|
||||
if age <= filters.range1:
|
||||
range1 += qty
|
||||
range1 = flt(range1 + qty, precision)
|
||||
elif age <= filters.range2:
|
||||
range2 += qty
|
||||
range2 = flt(range2 + qty, precision)
|
||||
elif age <= filters.range3:
|
||||
range3 += qty
|
||||
range3 = flt(range3 + qty, precision)
|
||||
else:
|
||||
above_range3 += qty
|
||||
above_range3 = flt(above_range3 + qty, precision)
|
||||
|
||||
return range1, range2, range3, above_range3
|
||||
|
||||
@@ -252,6 +256,7 @@ class FIFOSlots:
|
||||
key, fifo_queue, transferred_item_key = self.__init_key_stores(d)
|
||||
|
||||
if d.voucher_type == "Stock Reconciliation":
|
||||
# get difference in qty shift as actual qty
|
||||
prev_balance_qty = self.item_details[key].get("qty_after_transaction", 0)
|
||||
d.actual_qty = flt(d.qty_after_transaction) - flt(prev_balance_qty)
|
||||
|
||||
@@ -264,12 +269,16 @@ class FIFOSlots:
|
||||
|
||||
self.__update_balances(d, key)
|
||||
|
||||
if not self.filters.get("show_warehouse_wise_stock"):
|
||||
# (Item 1, WH 1), (Item 1, WH 2) => (Item 1)
|
||||
self.item_details = self.__aggregate_details_by_item(self.item_details)
|
||||
|
||||
return self.item_details
|
||||
|
||||
def __init_key_stores(self, row: Dict) -> Tuple:
|
||||
"Initialise keys and FIFO Queue."
|
||||
|
||||
key = (row.name, row.warehouse) if self.filters.get('show_warehouse_wise_stock') else row.name
|
||||
key = (row.name, row.warehouse)
|
||||
self.item_details.setdefault(key, {"details": row, "fifo_queue": []})
|
||||
fifo_queue = self.item_details[key]["fifo_queue"]
|
||||
|
||||
@@ -281,14 +290,16 @@ class FIFOSlots:
|
||||
def __compute_incoming_stock(self, row: Dict, fifo_queue: List, transfer_key: Tuple, serial_nos: List):
|
||||
"Update FIFO Queue on inward stock."
|
||||
|
||||
if self.transferred_item_details.get(transfer_key):
|
||||
transfer_data = self.transferred_item_details.get(transfer_key)
|
||||
if transfer_data:
|
||||
# inward/outward from same voucher, item & warehouse
|
||||
slot = self.transferred_item_details[transfer_key].pop(0)
|
||||
fifo_queue.append(slot)
|
||||
# eg: Repack with same item, Stock reco for batch item
|
||||
# consume transfer data and add stock to fifo queue
|
||||
self.__adjust_incoming_transfer_qty(transfer_data, fifo_queue, row)
|
||||
else:
|
||||
if not serial_nos:
|
||||
if fifo_queue and flt(fifo_queue[0][0]) < 0:
|
||||
# neutralize negative stock by adding positive stock
|
||||
if fifo_queue and flt(fifo_queue[0][0]) <= 0:
|
||||
# neutralize 0/negative stock by adding positive stock
|
||||
fifo_queue[0][0] += flt(row.actual_qty)
|
||||
fifo_queue[0][1] = row.posting_date
|
||||
else:
|
||||
@@ -319,7 +330,7 @@ class FIFOSlots:
|
||||
elif not fifo_queue:
|
||||
# negative stock, no balance but qty yet to consume
|
||||
fifo_queue.append([-(qty_to_pop), row.posting_date])
|
||||
self.transferred_item_details[transfer_key].append([row.actual_qty, row.posting_date])
|
||||
self.transferred_item_details[transfer_key].append([qty_to_pop, row.posting_date])
|
||||
qty_to_pop = 0
|
||||
else:
|
||||
# qty to pop < slot qty, ample balance
|
||||
@@ -328,6 +339,33 @@ class FIFOSlots:
|
||||
self.transferred_item_details[transfer_key].append([qty_to_pop, slot[1]])
|
||||
qty_to_pop = 0
|
||||
|
||||
def __adjust_incoming_transfer_qty(self, transfer_data: Dict, fifo_queue: List, row: Dict):
|
||||
"Add previously removed stock back to FIFO Queue."
|
||||
transfer_qty_to_pop = flt(row.actual_qty)
|
||||
|
||||
def add_to_fifo_queue(slot):
|
||||
if fifo_queue and flt(fifo_queue[0][0]) <= 0:
|
||||
# neutralize 0/negative stock by adding positive stock
|
||||
fifo_queue[0][0] += flt(slot[0])
|
||||
fifo_queue[0][1] = slot[1]
|
||||
else:
|
||||
fifo_queue.append(slot)
|
||||
|
||||
while transfer_qty_to_pop:
|
||||
if transfer_data and 0 < transfer_data[0][0] <= transfer_qty_to_pop:
|
||||
# bucket qty is not enough, consume whole
|
||||
transfer_qty_to_pop -= transfer_data[0][0]
|
||||
add_to_fifo_queue(transfer_data.pop(0))
|
||||
elif not transfer_data:
|
||||
# transfer bucket is empty, extra incoming qty
|
||||
add_to_fifo_queue([transfer_qty_to_pop, row.posting_date])
|
||||
transfer_qty_to_pop = 0
|
||||
else:
|
||||
# ample bucket qty to consume
|
||||
transfer_data[0][0] -= transfer_qty_to_pop
|
||||
add_to_fifo_queue([transfer_qty_to_pop, transfer_data[0][1]])
|
||||
transfer_qty_to_pop = 0
|
||||
|
||||
def __update_balances(self, row: Dict, key: Union[Tuple, str]):
|
||||
self.item_details[key]["qty_after_transaction"] = row.qty_after_transaction
|
||||
|
||||
@@ -338,6 +376,27 @@ class FIFOSlots:
|
||||
|
||||
self.item_details[key]["has_serial_no"] = row.has_serial_no
|
||||
|
||||
def __aggregate_details_by_item(self, wh_wise_data: Dict) -> Dict:
|
||||
"Aggregate Item-Wh wise data into single Item entry."
|
||||
item_aggregated_data = {}
|
||||
for key,row in wh_wise_data.items():
|
||||
item = key[0]
|
||||
if not item_aggregated_data.get(item):
|
||||
item_aggregated_data.setdefault(item, {
|
||||
"details": frappe._dict(),
|
||||
"fifo_queue": [],
|
||||
"qty_after_transaction": 0.0,
|
||||
"total_qty": 0.0
|
||||
})
|
||||
item_row = item_aggregated_data.get(item)
|
||||
item_row["details"].update(row["details"])
|
||||
item_row["fifo_queue"].extend(row["fifo_queue"])
|
||||
item_row["qty_after_transaction"] += flt(row["qty_after_transaction"])
|
||||
item_row["total_qty"] += flt(row["total_qty"])
|
||||
item_row["has_serial_no"] = row["has_serial_no"]
|
||||
|
||||
return item_aggregated_data
|
||||
|
||||
def __get_stock_ledger_entries(self) -> List[Dict]:
|
||||
sle = frappe.qb.DocType("Stock Ledger Entry")
|
||||
item = self.__get_item_query() # used as derived table in sle query
|
||||
|
||||
@@ -15,6 +15,7 @@ Here, the balance qty is 70.
|
||||
50 qty is (today-the 1st) days old
|
||||
20 qty is (today-the 2nd) days old
|
||||
|
||||
> Note: We generate FIFO slots warehouse wise as stock reconciliations from different warehouses can cause incorrect values.
|
||||
### Calculation of FIFO Slots
|
||||
|
||||
#### Case 1: Outward from sufficient balance qty
|
||||
@@ -70,4 +71,39 @@ Date | Qty | Queue
|
||||
2nd | -60 | [[-10, 1-12-2021]]
|
||||
3rd | +5 | [[-5, 3-12-2021]]
|
||||
4th | +10 | [[5, 4-12-2021]]
|
||||
4th | +20 | [[5, 4-12-2021], [20, 4-12-2021]]
|
||||
4th | +20 | [[5, 4-12-2021], [20, 4-12-2021]]
|
||||
|
||||
### Concept of Transfer Qty Bucket
|
||||
In the case of **Repack**, Quantity that comes in, isn't really incoming. It is just new stock repurposed from old stock, due to incoming-outgoing of the same warehouse.
|
||||
|
||||
Here, stock is consumed from the FIFO Queue. It is then re-added back to the queue.
|
||||
While adding stock back to the queue we need to know how much to add.
|
||||
For this we need to keep track of how much was previously consumed.
|
||||
Hence we use **Transfer Qty Bucket**.
|
||||
|
||||
While re-adding stock, we try to add buckets that were consumed earlier (date intact), to maintain correctness.
|
||||
|
||||
#### Case 1: Same Item-Warehouse in Repack
|
||||
Eg:
|
||||
-------------------------------------------------------------------------------------
|
||||
Date | Qty | Voucher | FIFO Queue | Transfer Qty Buckets
|
||||
-------------------------------------------------------------------------------------
|
||||
1st | +500 | PR | [[500, 1-12-2021]] |
|
||||
2nd | -50 | Repack | [[450, 1-12-2021]] | [[50, 1-12-2021]]
|
||||
2nd | +50 | Repack | [[450, 1-12-2021], [50, 1-12-2021]] | []
|
||||
|
||||
- The balance at the end is restored back to 500
|
||||
- However, the initial 500 qty bucket is now split into 450 and 50, with the same date
|
||||
- The net effect is the same as that before the Repack
|
||||
|
||||
#### Case 2: Same Item-Warehouse in Repack with Split Consumption rows
|
||||
Eg:
|
||||
-------------------------------------------------------------------------------------
|
||||
Date | Qty | Voucher | FIFO Queue | Transfer Qty Buckets
|
||||
-------------------------------------------------------------------------------------
|
||||
1st | +500 | PR | [[500, 1-12-2021]] |
|
||||
2nd | -50 | Repack | [[450, 1-12-2021]] | [[50, 1-12-2021]]
|
||||
2nd | -50 | Repack | [[400, 1-12-2021]] | [[50, 1-12-2021],
|
||||
- | | | |[50, 1-12-2021]]
|
||||
2nd | +100 | Repack | [[400, 1-12-2021], [50, 1-12-2021], | []
|
||||
- | | | [50, 1-12-2021]] |
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots
|
||||
from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots, format_report_data
|
||||
from erpnext.tests.utils import ERPNextTestCase
|
||||
|
||||
|
||||
@@ -11,15 +11,17 @@ class TestStockAgeing(ERPNextTestCase):
|
||||
def setUp(self) -> None:
|
||||
self.filters = frappe._dict(
|
||||
company="_Test Company",
|
||||
to_date="2021-12-10"
|
||||
to_date="2021-12-10",
|
||||
range1=30, range2=60, range3=90
|
||||
)
|
||||
|
||||
def test_normal_inward_outward_queue(self):
|
||||
"Reference: Case 1 in stock_ageing_fifo_logic.md"
|
||||
"Reference: Case 1 in stock_ageing_fifo_logic.md (same wh)"
|
||||
sle = [
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=30, qty_after_transaction=30,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-01", voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False, serial_no=None
|
||||
@@ -27,6 +29,7 @@ class TestStockAgeing(ERPNextTestCase):
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=20, qty_after_transaction=50,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-02", voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False, serial_no=None
|
||||
@@ -34,6 +37,7 @@ class TestStockAgeing(ERPNextTestCase):
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=(-10), qty_after_transaction=40,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-03", voucher_type="Stock Entry",
|
||||
voucher_no="003",
|
||||
has_serial_no=False, serial_no=None
|
||||
@@ -50,11 +54,12 @@ class TestStockAgeing(ERPNextTestCase):
|
||||
self.assertEqual(queue[0][0], 20.0)
|
||||
|
||||
def test_insufficient_balance(self):
|
||||
"Reference: Case 3 in stock_ageing_fifo_logic.md"
|
||||
"Reference: Case 3 in stock_ageing_fifo_logic.md (same wh)"
|
||||
sle = [
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=(-30), qty_after_transaction=(-30),
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-01", voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False, serial_no=None
|
||||
@@ -62,6 +67,7 @@ class TestStockAgeing(ERPNextTestCase):
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=20, qty_after_transaction=(-10),
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-02", voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False, serial_no=None
|
||||
@@ -69,6 +75,7 @@ class TestStockAgeing(ERPNextTestCase):
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=20, qty_after_transaction=10,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-03", voucher_type="Stock Entry",
|
||||
voucher_no="003",
|
||||
has_serial_no=False, serial_no=None
|
||||
@@ -76,6 +83,7 @@ class TestStockAgeing(ERPNextTestCase):
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=10, qty_after_transaction=20,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-03", voucher_type="Stock Entry",
|
||||
voucher_no="004",
|
||||
has_serial_no=False, serial_no=None
|
||||
@@ -91,11 +99,16 @@ class TestStockAgeing(ERPNextTestCase):
|
||||
self.assertEqual(queue[0][0], 10.0)
|
||||
self.assertEqual(queue[1][0], 10.0)
|
||||
|
||||
def test_stock_reconciliation(self):
|
||||
def test_basic_stock_reconciliation(self):
|
||||
"""
|
||||
Ledger (same wh): [+30, reco reset >> 50, -10]
|
||||
Bal: 40
|
||||
"""
|
||||
sle = [
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=30, qty_after_transaction=30,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-01", voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False, serial_no=None
|
||||
@@ -103,6 +116,7 @@ class TestStockAgeing(ERPNextTestCase):
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=0, qty_after_transaction=50,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-02", voucher_type="Stock Reconciliation",
|
||||
voucher_no="002",
|
||||
has_serial_no=False, serial_no=None
|
||||
@@ -110,6 +124,7 @@ class TestStockAgeing(ERPNextTestCase):
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=(-10), qty_after_transaction=40,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-03", voucher_type="Stock Entry",
|
||||
voucher_no="003",
|
||||
has_serial_no=False, serial_no=None
|
||||
@@ -122,5 +137,477 @@ class TestStockAgeing(ERPNextTestCase):
|
||||
queue = result["fifo_queue"]
|
||||
|
||||
self.assertEqual(result["qty_after_transaction"], result["total_qty"])
|
||||
self.assertEqual(result["total_qty"], 40.0)
|
||||
self.assertEqual(queue[0][0], 20.0)
|
||||
self.assertEqual(queue[1][0], 20.0)
|
||||
|
||||
def test_sequential_stock_reco_same_warehouse(self):
|
||||
"""
|
||||
Test back to back stock recos (same warehouse).
|
||||
Ledger: [reco opening >> +1000, reco reset >> 400, -10]
|
||||
Bal: 390
|
||||
"""
|
||||
sle = [
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=0, qty_after_transaction=1000,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-01", voucher_type="Stock Reconciliation",
|
||||
voucher_no="002",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=0, qty_after_transaction=400,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-02", voucher_type="Stock Reconciliation",
|
||||
voucher_no="003",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=(-10), qty_after_transaction=390,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-03", voucher_type="Stock Entry",
|
||||
voucher_no="003",
|
||||
has_serial_no=False, serial_no=None
|
||||
)
|
||||
]
|
||||
slots = FIFOSlots(self.filters, sle).generate()
|
||||
|
||||
result = slots["Flask Item"]
|
||||
queue = result["fifo_queue"]
|
||||
|
||||
self.assertEqual(result["qty_after_transaction"], result["total_qty"])
|
||||
self.assertEqual(result["total_qty"], 390.0)
|
||||
self.assertEqual(queue[0][0], 390.0)
|
||||
|
||||
def test_sequential_stock_reco_different_warehouse(self):
|
||||
"""
|
||||
Ledger:
|
||||
WH | Voucher | Qty
|
||||
-------------------
|
||||
WH1 | Reco | 1000
|
||||
WH2 | Reco | 400
|
||||
WH1 | SE | -10
|
||||
|
||||
Bal: WH1 bal + WH2 bal = 990 + 400 = 1390
|
||||
"""
|
||||
sle = [
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=0, qty_after_transaction=1000,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-01", voucher_type="Stock Reconciliation",
|
||||
voucher_no="002",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=0, qty_after_transaction=400,
|
||||
warehouse="WH 2",
|
||||
posting_date="2021-12-02", voucher_type="Stock Reconciliation",
|
||||
voucher_no="003",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=(-10), qty_after_transaction=990,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-03", voucher_type="Stock Entry",
|
||||
voucher_no="004",
|
||||
has_serial_no=False, serial_no=None
|
||||
)
|
||||
]
|
||||
|
||||
item_wise_slots, item_wh_wise_slots = generate_item_and_item_wh_wise_slots(
|
||||
filters=self.filters,sle=sle
|
||||
)
|
||||
|
||||
# test without 'show_warehouse_wise_stock'
|
||||
item_result = item_wise_slots["Flask Item"]
|
||||
queue = item_result["fifo_queue"]
|
||||
|
||||
self.assertEqual(item_result["qty_after_transaction"], item_result["total_qty"])
|
||||
self.assertEqual(item_result["total_qty"], 1390.0)
|
||||
self.assertEqual(queue[0][0], 990.0)
|
||||
self.assertEqual(queue[1][0], 400.0)
|
||||
|
||||
# test with 'show_warehouse_wise_stock' checked
|
||||
item_wh_balances = [item_wh_wise_slots.get(i).get("qty_after_transaction") for i in item_wh_wise_slots]
|
||||
self.assertEqual(sum(item_wh_balances), item_result["qty_after_transaction"])
|
||||
|
||||
def test_repack_entry_same_item_split_rows(self):
|
||||
"""
|
||||
Split consumption rows and have single repacked item row (same warehouse).
|
||||
Ledger:
|
||||
Item | Qty | Voucher
|
||||
------------------------
|
||||
Item 1 | 500 | 001
|
||||
Item 1 | -50 | 002 (repack)
|
||||
Item 1 | -50 | 002 (repack)
|
||||
Item 1 | 100 | 002 (repack)
|
||||
|
||||
Case most likely for batch items. Test time bucket computation.
|
||||
"""
|
||||
sle = [
|
||||
frappe._dict( # stock up item
|
||||
name="Flask Item",
|
||||
actual_qty=500, qty_after_transaction=500,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-03", voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=(-50), qty_after_transaction=450,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-04", voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=(-50), qty_after_transaction=400,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-04", voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=100, qty_after_transaction=500,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-04", voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
]
|
||||
slots = FIFOSlots(self.filters, sle).generate()
|
||||
item_result = slots["Flask Item"]
|
||||
queue = item_result["fifo_queue"]
|
||||
|
||||
self.assertEqual(item_result["total_qty"], 500.0)
|
||||
self.assertEqual(queue[0][0], 400.0)
|
||||
self.assertEqual(queue[1][0], 50.0)
|
||||
self.assertEqual(queue[2][0], 50.0)
|
||||
# check if time buckets add up to balance qty
|
||||
self.assertEqual(sum([i[0] for i in queue]), 500.0)
|
||||
|
||||
def test_repack_entry_same_item_overconsume(self):
|
||||
"""
|
||||
Over consume item and have less repacked item qty (same warehouse).
|
||||
Ledger:
|
||||
Item | Qty | Voucher
|
||||
------------------------
|
||||
Item 1 | 500 | 001
|
||||
Item 1 | -100 | 002 (repack)
|
||||
Item 1 | 50 | 002 (repack)
|
||||
|
||||
Case most likely for batch items. Test time bucket computation.
|
||||
"""
|
||||
sle = [
|
||||
frappe._dict( # stock up item
|
||||
name="Flask Item",
|
||||
actual_qty=500, qty_after_transaction=500,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-03", voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=(-100), qty_after_transaction=400,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-04", voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=50, qty_after_transaction=450,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-04", voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
]
|
||||
slots = FIFOSlots(self.filters, sle).generate()
|
||||
item_result = slots["Flask Item"]
|
||||
queue = item_result["fifo_queue"]
|
||||
|
||||
self.assertEqual(item_result["total_qty"], 450.0)
|
||||
self.assertEqual(queue[0][0], 400.0)
|
||||
self.assertEqual(queue[1][0], 50.0)
|
||||
# check if time buckets add up to balance qty
|
||||
self.assertEqual(sum([i[0] for i in queue]), 450.0)
|
||||
|
||||
def test_repack_entry_same_item_overconsume_with_split_rows(self):
|
||||
"""
|
||||
Over consume item and have less repacked item qty (same warehouse).
|
||||
Ledger:
|
||||
Item | Qty | Voucher
|
||||
------------------------
|
||||
Item 1 | 20 | 001
|
||||
Item 1 | -50 | 002 (repack)
|
||||
Item 1 | -50 | 002 (repack)
|
||||
Item 1 | 50 | 002 (repack)
|
||||
"""
|
||||
sle = [
|
||||
frappe._dict( # stock up item
|
||||
name="Flask Item",
|
||||
actual_qty=20, qty_after_transaction=20,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-03", voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=(-50), qty_after_transaction=(-30),
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-04", voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=(-50), qty_after_transaction=(-80),
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-04", voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=50, qty_after_transaction=(-30),
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-04", voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
]
|
||||
fifo_slots = FIFOSlots(self.filters, sle)
|
||||
slots = fifo_slots.generate()
|
||||
item_result = slots["Flask Item"]
|
||||
queue = item_result["fifo_queue"]
|
||||
|
||||
self.assertEqual(item_result["total_qty"], -30.0)
|
||||
self.assertEqual(queue[0][0], -30.0)
|
||||
|
||||
# check transfer bucket
|
||||
transfer_bucket = fifo_slots.transferred_item_details[('002', 'Flask Item', 'WH 1')]
|
||||
self.assertEqual(transfer_bucket[0][0], 50)
|
||||
|
||||
def test_repack_entry_same_item_overproduce(self):
|
||||
"""
|
||||
Under consume item and have more repacked item qty (same warehouse).
|
||||
Ledger:
|
||||
Item | Qty | Voucher
|
||||
------------------------
|
||||
Item 1 | 500 | 001
|
||||
Item 1 | -50 | 002 (repack)
|
||||
Item 1 | 100 | 002 (repack)
|
||||
|
||||
Case most likely for batch items. Test time bucket computation.
|
||||
"""
|
||||
sle = [
|
||||
frappe._dict( # stock up item
|
||||
name="Flask Item",
|
||||
actual_qty=500, qty_after_transaction=500,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-03", voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=(-50), qty_after_transaction=450,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-04", voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=100, qty_after_transaction=550,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-04", voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
]
|
||||
slots = FIFOSlots(self.filters, sle).generate()
|
||||
item_result = slots["Flask Item"]
|
||||
queue = item_result["fifo_queue"]
|
||||
|
||||
self.assertEqual(item_result["total_qty"], 550.0)
|
||||
self.assertEqual(queue[0][0], 450.0)
|
||||
self.assertEqual(queue[1][0], 50.0)
|
||||
self.assertEqual(queue[2][0], 50.0)
|
||||
# check if time buckets add up to balance qty
|
||||
self.assertEqual(sum([i[0] for i in queue]), 550.0)
|
||||
|
||||
def test_repack_entry_same_item_overproduce_with_split_rows(self):
|
||||
"""
|
||||
Over consume item and have less repacked item qty (same warehouse).
|
||||
Ledger:
|
||||
Item | Qty | Voucher
|
||||
------------------------
|
||||
Item 1 | 20 | 001
|
||||
Item 1 | -50 | 002 (repack)
|
||||
Item 1 | 50 | 002 (repack)
|
||||
Item 1 | 50 | 002 (repack)
|
||||
"""
|
||||
sle = [
|
||||
frappe._dict( # stock up item
|
||||
name="Flask Item",
|
||||
actual_qty=20, qty_after_transaction=20,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-03", voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=(-50), qty_after_transaction=(-30),
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-04", voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=50, qty_after_transaction=20,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-04", voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=50, qty_after_transaction=70,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-04", voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
]
|
||||
fifo_slots = FIFOSlots(self.filters, sle)
|
||||
slots = fifo_slots.generate()
|
||||
item_result = slots["Flask Item"]
|
||||
queue = item_result["fifo_queue"]
|
||||
|
||||
self.assertEqual(item_result["total_qty"], 70.0)
|
||||
self.assertEqual(queue[0][0], 20.0)
|
||||
self.assertEqual(queue[1][0], 50.0)
|
||||
|
||||
# check transfer bucket
|
||||
transfer_bucket = fifo_slots.transferred_item_details[('002', 'Flask Item', 'WH 1')]
|
||||
self.assertFalse(transfer_bucket)
|
||||
|
||||
def test_negative_stock_same_voucher(self):
|
||||
"""
|
||||
Test negative stock scenario in transfer bucket via repack entry (same wh).
|
||||
Ledger:
|
||||
Item | Qty | Voucher
|
||||
------------------------
|
||||
Item 1 | -50 | 001
|
||||
Item 1 | -50 | 001
|
||||
Item 1 | 30 | 001
|
||||
Item 1 | 80 | 001
|
||||
"""
|
||||
sle = [
|
||||
frappe._dict( # stock up item
|
||||
name="Flask Item",
|
||||
actual_qty=(-50), qty_after_transaction=(-50),
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-01", voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
frappe._dict( # stock up item
|
||||
name="Flask Item",
|
||||
actual_qty=(-50), qty_after_transaction=(-100),
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-01", voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
frappe._dict( # stock up item
|
||||
name="Flask Item",
|
||||
actual_qty=30, qty_after_transaction=(-70),
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-01", voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
]
|
||||
fifo_slots = FIFOSlots(self.filters, sle)
|
||||
slots = fifo_slots.generate()
|
||||
item_result = slots["Flask Item"]
|
||||
|
||||
# check transfer bucket
|
||||
transfer_bucket = fifo_slots.transferred_item_details[('001', 'Flask Item', 'WH 1')]
|
||||
self.assertEqual(transfer_bucket[0][0], 20)
|
||||
self.assertEqual(transfer_bucket[1][0], 50)
|
||||
self.assertEqual(item_result["fifo_queue"][0][0], -70.0)
|
||||
|
||||
sle.append(frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=80, qty_after_transaction=10,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-01", voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False, serial_no=None
|
||||
))
|
||||
|
||||
fifo_slots = FIFOSlots(self.filters, sle)
|
||||
slots = fifo_slots.generate()
|
||||
item_result = slots["Flask Item"]
|
||||
|
||||
transfer_bucket = fifo_slots.transferred_item_details[('001', 'Flask Item', 'WH 1')]
|
||||
self.assertFalse(transfer_bucket)
|
||||
self.assertEqual(item_result["fifo_queue"][0][0], 10.0)
|
||||
|
||||
def test_precision(self):
|
||||
"Test if final balance qty is rounded off correctly."
|
||||
sle = [
|
||||
frappe._dict( # stock up item
|
||||
name="Flask Item",
|
||||
actual_qty=0.3, qty_after_transaction=0.3,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-01", voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
frappe._dict( # stock up item
|
||||
name="Flask Item",
|
||||
actual_qty=0.6, qty_after_transaction=0.9,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-01", voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
]
|
||||
|
||||
slots = FIFOSlots(self.filters, sle).generate()
|
||||
report_data = format_report_data(self.filters, slots, self.filters["to_date"])
|
||||
row = report_data[0] # first row in report
|
||||
bal_qty = row[5]
|
||||
range_qty_sum = sum([i for i in row[7:11]]) # get sum of range balance
|
||||
|
||||
# check if value of Available Qty column matches with range bucket post format
|
||||
self.assertEqual(bal_qty, 0.9)
|
||||
self.assertEqual(bal_qty, range_qty_sum)
|
||||
|
||||
def generate_item_and_item_wh_wise_slots(filters, sle):
|
||||
"Return results with and without 'show_warehouse_wise_stock'"
|
||||
item_wise_slots = FIFOSlots(filters, sle).generate()
|
||||
|
||||
filters.show_warehouse_wise_stock = True
|
||||
item_wh_wise_slots = FIFOSlots(filters, sle).generate()
|
||||
filters.show_warehouse_wise_stock = False
|
||||
|
||||
return item_wise_slots, item_wh_wise_slots
|
||||
@@ -104,6 +104,7 @@ def get_columns():
|
||||
{"label": _("Incoming Rate"), "fieldname": "incoming_rate", "fieldtype": "Currency", "width": 110, "options": "Company:company:default_currency", "convertible": "rate"},
|
||||
{"label": _("Valuation Rate"), "fieldname": "valuation_rate", "fieldtype": "Currency", "width": 110, "options": "Company:company:default_currency", "convertible": "rate"},
|
||||
{"label": _("Balance Value"), "fieldname": "stock_value", "fieldtype": "Currency", "width": 110, "options": "Company:company:default_currency"},
|
||||
{"label": _("Value Change"), "fieldname": "stock_value_difference", "fieldtype": "Currency", "width": 110, "options": "Company:company:default_currency"},
|
||||
{"label": _("Voucher Type"), "fieldname": "voucher_type", "width": 110},
|
||||
{"label": _("Voucher #"), "fieldname": "voucher_no", "fieldtype": "Dynamic Link", "options": "voucher_type", "width": 100},
|
||||
{"label": _("Batch"), "fieldname": "batch_no", "fieldtype": "Link", "options": "Batch", "width": 100},
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
|
||||
import frappe
|
||||
from frappe.utils import cstr, flt, nowdate, nowtime
|
||||
from frappe.utils import cstr, flt, now, nowdate, nowtime
|
||||
|
||||
from erpnext.controllers.stock_controller import create_repost_item_valuation_entry
|
||||
from erpnext.stock.utils import update_bin
|
||||
@@ -175,6 +175,7 @@ def update_bin_qty(item_code, warehouse, qty_dict=None):
|
||||
bin.set(field, flt(value))
|
||||
mismatch = True
|
||||
|
||||
bin.modified = now()
|
||||
if mismatch:
|
||||
bin.set_projected_qty()
|
||||
bin.db_update()
|
||||
|
||||
63
erpnext/tests/test_point_of_sale.py
Normal file
63
erpnext/tests/test_point_of_sale.py
Normal file
@@ -0,0 +1,63 @@
|
||||
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# MIT License. See license.txt
|
||||
|
||||
import unittest
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile
|
||||
from erpnext.selling.page.point_of_sale.point_of_sale import get_items
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
|
||||
class TestPointOfSale(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
frappe.db.savepoint('before_test_point_of_sale')
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls) -> None:
|
||||
frappe.db.rollback(save_point='before_test_point_of_sale')
|
||||
|
||||
def test_item_search(self):
|
||||
"""
|
||||
Test Stock and Service Item Search.
|
||||
"""
|
||||
|
||||
pos_profile = make_pos_profile()
|
||||
item1 = make_item("Test Search Stock Item", {"is_stock_item": 1})
|
||||
make_stock_entry(
|
||||
item_code="Test Search Stock Item",
|
||||
qty=10,
|
||||
to_warehouse="_Test Warehouse - _TC",
|
||||
rate=500,
|
||||
)
|
||||
|
||||
result = get_items(
|
||||
start=0,
|
||||
page_length=20,
|
||||
price_list=None,
|
||||
item_group=item1.item_group,
|
||||
pos_profile=pos_profile.name,
|
||||
search_term="Test Search Stock Item",
|
||||
)
|
||||
filtered_items = result.get("items")
|
||||
|
||||
self.assertEqual(len(filtered_items), 1)
|
||||
self.assertEqual(filtered_items[0]["item_code"], item1.item_code)
|
||||
self.assertEqual(filtered_items[0]["actual_qty"], 10)
|
||||
|
||||
item2 = make_item("Test Search Service Item", {"is_stock_item": 0})
|
||||
result = get_items(
|
||||
start=0,
|
||||
page_length=20,
|
||||
price_list=None,
|
||||
item_group=item2.item_group,
|
||||
pos_profile=pos_profile.name,
|
||||
search_term="Test Search Service Item",
|
||||
)
|
||||
filtered_items = result.get("items")
|
||||
|
||||
self.assertEqual(len(filtered_items), 1)
|
||||
self.assertEqual(filtered_items[0]["item_code"], item2.item_code)
|
||||
@@ -13,6 +13,12 @@ frappe.ui.form.on("Rename Tool", {
|
||||
},
|
||||
refresh: function(frm) {
|
||||
frm.disable_save();
|
||||
|
||||
frm.get_field("file_to_rename").df.options = {
|
||||
restrictions: {
|
||||
allowed_file_types: [".csv"],
|
||||
},
|
||||
};
|
||||
if (!frm.doc.file_to_rename) {
|
||||
frm.get_field("rename_log").$wrapper.html("");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user