Merge branch 'version-13-pre-release' into version-13

This commit is contained in:
Rohit Waghchaure
2022-02-21 20:29:03 +05:30
97 changed files with 2496 additions and 612 deletions

View File

@@ -5,7 +5,7 @@ import frappe
from erpnext.hooks import regional_overrides from erpnext.hooks import regional_overrides
__version__ = '13.20.1' __version__ = '13.21.0'
def get_default_company(user=None): def get_default_company(user=None):
'''Get default company for user''' '''Get default company for user'''

View File

@@ -14,6 +14,10 @@ frappe.ui.form.on("Bank Reconciliation Tool", {
}); });
}, },
onload: function (frm) {
frm.trigger('bank_account');
},
refresh: function (frm) { refresh: function (frm) {
frappe.require("assets/js/bank-reconciliation-tool.min.js", () => frappe.require("assets/js/bank-reconciliation-tool.min.js", () =>
frm.trigger("make_reconciliation_tool") frm.trigger("make_reconciliation_tool")
@@ -51,7 +55,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", {
bank_account: function (frm) { bank_account: function (frm) {
frappe.db.get_value( frappe.db.get_value(
"Bank Account", "Bank Account",
frm.bank_account, frm.doc.bank_account,
"account", "account",
(r) => { (r) => {
frappe.db.get_value( frappe.db.get_value(
@@ -60,6 +64,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", {
"account_currency", "account_currency",
(r) => { (r) => {
frm.currency = r.account_currency; 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( frm.cards_manager = new erpnext.accounts.bank_reconciliation.NumberCardManager(
{ {
$reconciliation_tool_cards: frm.get_field( $reconciliation_tool_cards: frm.get_field(
@@ -136,7 +141,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", {
currency: frm.currency, currency: frm.currency,
} }
); );
}, }, 500),
render(frm) { render(frm) {
if (frm.doc.bank_account) { if (frm.doc.bank_account) {

View File

@@ -2,7 +2,7 @@
"actions": [], "actions": [],
"allow_import": 1, "allow_import": 1,
"allow_rename": 1, "allow_rename": 1,
"creation": "2018-11-22 22:45:00.370913", "creation": "2022-01-19 01:09:13.297137",
"doctype": "DocType", "doctype": "DocType",
"document_type": "Setup", "document_type": "Setup",
"editable_grid": 1, "editable_grid": 1,
@@ -10,6 +10,9 @@
"field_order": [ "field_order": [
"title", "title",
"company", "company",
"column_break_3",
"disabled",
"section_break_5",
"taxes" "taxes"
], ],
"fields": [ "fields": [
@@ -36,10 +39,24 @@
"label": "Company", "label": "Company",
"options": "Company", "options": "Company",
"reqd": 1 "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": [], "links": [],
"modified": "2021-03-08 19:50:21.416513", "modified": "2022-01-18 21:11:23.105589",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Item Tax Template", "name": "Item Tax Template",
@@ -82,6 +99,7 @@
"show_name_in_global_search": 1, "show_name_in_global_search": 1,
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [],
"title_field": "title", "title_field": "title",
"track_changes": 1 "track_changes": 1
} }

View File

@@ -8,6 +8,7 @@ frappe.provide("erpnext.journal_entry");
frappe.ui.form.on("Journal Entry", { frappe.ui.form.on("Journal Entry", {
setup: function(frm) { setup: function(frm) {
frm.add_fetch("bank_account", "account", "account"); frm.add_fetch("bank_account", "account", "account");
frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice'];
}, },
refresh: function(frm) { refresh: function(frm) {

View File

@@ -167,7 +167,8 @@ class OpeningInvoiceCreationTool(Document):
"is_pos": 0, "is_pos": 0,
"doctype": "Sales Invoice" if self.invoice_type == "Sales" else "Purchase Invoice", "doctype": "Sales Invoice" if self.invoice_type == "Sales" else "Purchase Invoice",
"update_stock": 0, "update_stock": 0,
"invoice_number": row.invoice_number "invoice_number": row.invoice_number,
"disable_rounded_total": 1
}) })
accounting_dimension = get_accounting_dimensions() accounting_dimension = get_accounting_dimensions()

View File

@@ -43,7 +43,6 @@ class POSInvoice(SalesInvoice):
self.validate_serialised_or_batched_item() self.validate_serialised_or_batched_item()
self.validate_stock_availablility() self.validate_stock_availablility()
self.validate_return_items_qty() self.validate_return_items_qty()
self.validate_non_stock_items()
self.set_status() self.set_status()
self.set_account_for_mode_of_payment() self.set_account_for_mode_of_payment()
self.validate_pos() 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.") 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")) .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): def validate_stock_availablility(self):
if self.is_return or self.docstatus != 1: if self.is_return or self.docstatus != 1:
return return
allow_negative_stock = frappe.db.get_single_value('Stock Settings', 'allow_negative_stock') allow_negative_stock = frappe.db.get_single_value('Stock Settings', 'allow_negative_stock')
for d in self.get('items'): 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: if d.serial_no:
self.validate_pos_reserved_serial_nos(d) self.validate_pos_reserved_serial_nos(d)
self.validate_delivered_serial_nos(d) self.validate_delivered_serial_nos(d)
self.validate_invalid_serial_nos(d)
elif d.batch_no: elif d.batch_no:
self.validate_pos_reserved_batch_qty(d) self.validate_pos_reserved_batch_qty(d)
else: else:
if allow_negative_stock: if allow_negative_stock:
return 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) item_code, warehouse, qty = frappe.bold(d.item_code), frappe.bold(d.warehouse), frappe.bold(d.qty)
if flt(available_stock) <= 0: if flt(available_stock) <= 0:
@@ -245,14 +261,6 @@ class POSInvoice(SalesInvoice):
.format(d.idx, bold_serial_no, bold_return_against) .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): def validate_mode_of_payment(self):
if len(self.payments) == 0: if len(self.payments) == 0:
frappe.throw(_("At least one mode of payment is required for POS invoice.")) frappe.throw(_("At least one mode of payment is required for POS invoice."))
@@ -492,12 +500,18 @@ class POSInvoice(SalesInvoice):
@frappe.whitelist() @frappe.whitelist()
def get_stock_availability(item_code, warehouse): def get_stock_availability(item_code, warehouse):
if frappe.db.get_value('Item', item_code, 'is_stock_item'): if frappe.db.get_value('Item', item_code, 'is_stock_item'):
is_stock_item = True
bin_qty = get_bin_qty(item_code, warehouse) bin_qty = get_bin_qty(item_code, warehouse)
pos_sales_qty = get_pos_reserved_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: else:
is_stock_item = False
if frappe.db.exists('Product Bundle', item_code): 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): def get_bundle_availability(bundle_item_code, warehouse):
product_bundle = frappe.get_doc('Product Bundle', bundle_item_code) product_bundle = frappe.get_doc('Product Bundle', bundle_item_code)

View File

@@ -354,6 +354,24 @@ class TestPOSInvoice(unittest.TestCase):
pos2.insert() pos2.insert()
self.assertRaises(frappe.ValidationError, pos2.submit) 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): def test_loyalty_points(self):
from erpnext.accounts.doctype.loyalty_program.loyalty_program import ( from erpnext.accounts.doctype.loyalty_program.loyalty_program import (
get_loyalty_program_details_with_points, get_loyalty_program_details_with_points,
@@ -568,23 +586,29 @@ class TestPOSInvoice(unittest.TestCase):
item_price.insert() item_price.insert()
pr = make_pricing_rule(selling=1, priority=5, discount_percentage=10) pr = make_pricing_rule(selling=1, priority=5, discount_percentage=10)
pr.save() 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 try:
pos_inv.items[0].rate = 300 pos_inv = create_pos_invoice(qty=1, do_not_submit=1)
pos_inv.save() pos_inv.items[0].rate = 300
self.assertEquals(pos_inv.ignore_pricing_rule, 1) pos_inv.save()
# rate should change since pricing rules are ignored self.assertEquals(pos_inv.items[0].discount_percentage, 10)
self.assertEquals(pos_inv.items[0].rate, 300) # rate shouldn't change
self.assertEquals(pos_inv.items[0].rate, 405)
item_price.delete() pos_inv.ignore_pricing_rule = 1
pos_inv.delete() pos_inv.save()
pr.delete() 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): def create_pos_invoice(**args):

View File

@@ -85,12 +85,20 @@ class POSInvoiceMergeLog(Document):
sales_invoice.set_posting_time = 1 sales_invoice.set_posting_time = 1
sales_invoice.posting_date = getdate(self.posting_date) sales_invoice.posting_date = getdate(self.posting_date)
sales_invoice.save() sales_invoice.save()
self.write_off_fractional_amount(sales_invoice, data)
sales_invoice.submit() sales_invoice.submit()
self.consolidated_invoice = sales_invoice.name self.consolidated_invoice = sales_invoice.name
return 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): def process_merging_into_credit_note(self, data):
credit_note = self.get_new_sales_invoice() credit_note = self.get_new_sales_invoice()
credit_note.is_return = 1 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? # TODO: return could be against multiple sales invoice which could also have been consolidated?
# credit_note.return_against = self.consolidated_invoice # credit_note.return_against = self.consolidated_invoice
credit_note.save() credit_note.save()
self.write_off_fractional_amount(credit_note, data)
credit_note.submit() credit_note.submit()
self.consolidated_credit_note = credit_note.name 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): i.uom == item.uom and i.net_rate == item.net_rate and i.warehouse == item.warehouse):
found = True found = True
i.qty = i.qty + item.qty 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: if not found:
item.rate = item.net_rate item.rate = item.net_rate
item.amount = item.net_amount
item.base_amount = item.base_net_amount
item.price_list_rate = 0 item.price_list_rate = 0
si_item = map_child_doc(item, invoice, {"doctype": "Sales Invoice Item"}) si_item = map_child_doc(item, invoice, {"doctype": "Sales Invoice Item"})
items.append(si_item) items.append(si_item)
@@ -170,6 +185,7 @@ class POSInvoiceMergeLog(Document):
found = True found = True
if not found: if not found:
payments.append(payment) payments.append(payment)
rounding_adjustment += doc.rounding_adjustment rounding_adjustment += doc.rounding_adjustment
rounded_total += doc.rounded_total rounded_total += doc.rounded_total
base_rounding_adjustment += doc.base_rounding_adjustment base_rounding_adjustment += doc.base_rounding_adjustment

View File

@@ -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 ( from erpnext.accounts.doctype.pos_invoice_merge_log.pos_invoice_merge_log import (
consolidate_pos_invoices, consolidate_pos_invoices,
) )
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
class TestPOSInvoiceMergeLog(unittest.TestCase): class TestPOSInvoiceMergeLog(unittest.TestCase):
@@ -150,3 +151,132 @@ class TestPOSInvoiceMergeLog(unittest.TestCase):
frappe.set_user("Administrator") frappe.set_user("Administrator")
frappe.db.sql("delete from `tabPOS Profile`") frappe.db.sql("delete from `tabPOS Profile`")
frappe.db.sql("delete from `tabPOS Invoice`") 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`")

View File

@@ -250,13 +250,17 @@ def get_pricing_rule_for_item(args, price_list_rate=0, doc=None, for_validate=Fa
"free_item_data": [], "free_item_data": [],
"parent": args.parent, "parent": args.parent,
"parenttype": args.parenttype, "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 args.ignore_pricing_rule or not args.item_code:
if frappe.db.exists(args.doctype, args.name) and args.get("pricing_rules"): 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 = remove_pricing_rule_for_item(
item_details, args.get('item_code')) args.get("pricing_rules"),
item_details,
item_code=args.get("item_code"),
rate=args.get("price_list_rate"),
)
return item_details return item_details
update_args_for_pricing_rule(args) 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 if not doc: return item_details
elif args.get("pricing_rules"): elif args.get("pricing_rules"):
item_details = remove_pricing_rule_for_item(args.get("pricing_rules"), item_details = remove_pricing_rule_for_item(
item_details, args.get('item_code')) args.get("pricing_rules"),
item_details,
item_code=args.get("item_code"),
rate=args.get("price_list_rate"),
)
return item_details 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) item_details[field] += (pricing_rule.get(field, 0)
if pricing_rule else args.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 ( from erpnext.accounts.doctype.pricing_rule.utils import (
get_applied_pricing_rules, get_applied_pricing_rules,
get_pricing_rule_items, 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': if pricing_rule.rate_or_discount == 'Discount Percentage':
item_details.discount_percentage = 0.0 item_details.discount_percentage = 0.0
item_details.discount_amount = 0.0 item_details.discount_amount = 0.0
item_details.rate = rate or 0.0
if pricing_rule.rate_or_discount == 'Discount Amount': if pricing_rule.rate_or_discount == 'Discount Amount':
item_details.discount_amount = 0.0 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.applied_on_items = ','.join(items)
item_details.pricing_rules = '' item_details.pricing_rules = ''
item_details.pricing_rule_removed = True
return item_details return item_details
@@ -433,9 +443,12 @@ def remove_pricing_rules(item_list):
out = [] out = []
for item in item_list: for item in item_list:
item = frappe._dict(item) item = frappe._dict(item)
if item.get('pricing_rules'): if item.get("pricing_rules"):
out.append(remove_pricing_rule_for_item(item.get("pricing_rules"), out.append(
item, item.item_code)) remove_pricing_rule_for_item(
item.get("pricing_rules"), item, item.item_code, item.get("price_list_rate")
)
)
return out return out

View File

@@ -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 - 1")
frappe.delete_doc_if_exists("Pricing Rule", "_Test Pricing Rule with Min Qty - 2") 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"] test_dependencies = ["Campaign"]
def make_pricing_rule(**args): def make_pricing_rule(**args):

View File

@@ -176,8 +176,8 @@ class PurchaseInvoice(BuyingController):
if self.supplier and account.account_type != "Payable": if self.supplier and account.account_type != "Payable":
frappe.throw( frappe.throw(
_("Please ensure {} account is a Payable account. Change the account type to Payable or select a different account.") _("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") .format(frappe.bold("Credit To"), frappe.bold(self.credit_to)), title=_("Invalid Account")
) )
self.party_account_currency = account.account_currency self.party_account_currency = account.account_currency
@@ -535,8 +535,11 @@ class PurchaseInvoice(BuyingController):
voucher_wise_stock_value = {} voucher_wise_stock_value = {}
if self.update_stock: if self.update_stock:
for d in frappe.get_all('Stock Ledger Entry', stock_ledger_entries = frappe.get_all("Stock Ledger Entry",
fields = ["voucher_detail_no", "stock_value_difference", "warehouse"], filters={'voucher_no': self.name}): 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) 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") valuation_tax_accounts = [d.account_head for d in self.get("taxes")

View File

@@ -294,7 +294,7 @@ class SalesInvoice(SellingController):
filters={ invoice_or_credit_note: self.name }, filters={ invoice_or_credit_note: self.name },
pluck="pos_closing_entry" 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( msg = _("To cancel a {} you need to cancel the POS Closing Entry {}.").format(
frappe.bold("Consolidated Sales Invoice"), frappe.bold("Consolidated Sales Invoice"),
get_link_to_form("POS Closing Entry", pos_closing_entry[0]) get_link_to_form("POS Closing Entry", pos_closing_entry[0])
@@ -587,7 +587,10 @@ class SalesInvoice(SellingController):
frappe.throw(msg, title=_("Invalid Account")) frappe.throw(msg, title=_("Invalid Account"))
if self.customer and account.account_type != "Receivable": 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.") msg += _("Change the account type to Receivable or select a different account.")
frappe.throw(msg, title=_("Invalid Account")) frappe.throw(msg, title=_("Invalid Account"))

View File

@@ -2,12 +2,13 @@
"actions": [], "actions": [],
"allow_rename": 1, "allow_rename": 1,
"autoname": "field:title", "autoname": "field:title",
"creation": "2018-11-22 23:38:39.668804", "creation": "2022-01-19 01:09:28.920486",
"doctype": "DocType", "doctype": "DocType",
"editable_grid": 1, "editable_grid": 1,
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"title" "title",
"disabled"
], ],
"fields": [ "fields": [
{ {
@@ -18,14 +19,21 @@
"label": "Title", "label": "Title",
"reqd": 1, "reqd": 1,
"unique": 1 "unique": 1
},
{
"default": "0",
"fieldname": "disabled",
"fieldtype": "Check",
"label": "Disabled"
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2021-03-03 11:50:38.748872", "modified": "2022-01-18 21:13:41.161017",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Tax Category", "name": "Tax Category",
"naming_rule": "By fieldname",
"owner": "Administrator", "owner": "Administrator",
"permissions": [ "permissions": [
{ {
@@ -65,5 +73,6 @@
"quick_entry": 1, "quick_entry": 1,
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [],
"track_changes": 1 "track_changes": 1
} }

View File

@@ -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"
}

View File

@@ -120,11 +120,11 @@ def check_opening_balance(asset, liability, equity):
opening_balance = 0 opening_balance = 0
float_precision = cint(frappe.db.get_default("float_precision")) or 2 float_precision = cint(frappe.db.get_default("float_precision")) or 2
if asset: 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: 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: 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) opening_balance = flt(opening_balance, float_precision)
if opening_balance: if opening_balance:

View File

@@ -285,7 +285,8 @@ def add_total_row(out, root_type, balance_must_be, period_list, company_currency
total_row = { total_row = {
"account_name": _("Total {0} ({1})").format(_(root_type), _(balance_must_be)), "account_name": _("Total {0} ({1})").format(_(root_type), _(balance_must_be)),
"account": _("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: 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.setdefault("total", 0.0)
total_row["total"] += flt(row["total"]) total_row["total"] += flt(row["total"])
total_row["opening_balance"] += row["opening_balance"]
row["total"] = "" row["total"] = ""
if "total" in total_row: if "total" in total_row:

View File

@@ -42,6 +42,11 @@ frappe.query_reports["Gross Profit"] = {
"parent_field": "parent_invoice", "parent_field": "parent_invoice",
"initial_depth": 3, "initial_depth": 3,
"formatter": function(value, row, column, data, default_formatter) { "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); value = default_formatter(value, row, column, data);
if (data && (data.indent == 0.0 || row[1].content == "Total")) { if (data && (data.indent == 0.0 || row[1].content == "Total")) {

View File

@@ -23,7 +23,7 @@ def validate_filters(filters):
def get_result(filters, tds_docs, tds_accounts, tax_category_map): def get_result(filters, tds_docs, tds_accounts, tax_category_map):
supplier_map = get_supplier_pan_map() supplier_map = get_supplier_pan_map()
tax_rate_map = get_tax_rate_map(filters) tax_rate_map = get_tax_rate_map(filters)
gle_map = get_gle_map(filters, tds_docs) gle_map = get_gle_map(tds_docs)
out = [] out = []
for name, details in gle_map.items(): 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: if entry.account in tds_accounts:
tds_deducted += (entry.credit - entry.debit) tds_deducted += (entry.credit - entry.debit)
total_amount_credited += (entry.credit - entry.debit) total_amount_credited += entry.credit
if tds_deducted: if tds_deducted:
row = { row = {
@@ -78,7 +78,7 @@ def get_supplier_pan_map():
return supplier_map return supplier_map
def get_gle_map(filters, documents): def get_gle_map(documents):
# create gle_map of the form # create gle_map of the form
# {"purchase_invoice": list of dict of all gle created for this invoice} # {"purchase_invoice": list of dict of all gle created for this invoice}
gle_map = {} gle_map = {}
@@ -86,7 +86,7 @@ def get_gle_map(filters, documents):
gle = frappe.db.get_all('GL Entry', gle = frappe.db.get_all('GL Entry',
{ {
"voucher_no": ["in", documents], "voucher_no": ["in", documents],
"credit": (">", 0) "is_cancelled": 0
}, },
["credit", "debit", "account", "voucher_no", "posting_date", "voucher_type", "against", "party"], ["credit", "debit", "account", "voucher_no", "posting_date", "voucher_type", "against", "party"],
) )
@@ -184,21 +184,28 @@ def get_tds_docs(filters):
payment_entries = [] payment_entries = []
journal_entries = [] journal_entries = []
tax_category_map = {} 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')}, tds_accounts = frappe.get_all("Tax Withholding Account", {'company': filters.get('company')},
pluck="account") pluck="account")
query_filters = { query_filters = {
"credit": ('>', 0),
"account": ("in", tds_accounts), "account": ("in", tds_accounts),
"posting_date": ("between", [filters.get("from_date"), filters.get("to_date")]), "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'): if filters.get("supplier"):
query_filters.update({'against': 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: for d in tds_docs:
if d.voucher_type == "Purchase Invoice": if d.voucher_type == "Purchase Invoice":

View File

@@ -141,6 +141,7 @@
}, },
{ {
"allow_on_submit": 1, "allow_on_submit": 1,
"fetch_from": "item_code.image",
"fieldname": "image", "fieldname": "image",
"fieldtype": "Attach Image", "fieldtype": "Attach Image",
"hidden": 1, "hidden": 1,
@@ -502,7 +503,7 @@
"link_fieldname": "asset" "link_fieldname": "asset"
} }
], ],
"modified": "2021-06-24 14:58:51.097908", "modified": "2022-01-30 20:19:24.680027",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Assets", "module": "Assets",
"name": "Asset", "name": "Asset",

View File

@@ -37,6 +37,7 @@ class Asset(AccountsController):
self.validate_asset_values() self.validate_asset_values()
self.validate_asset_and_reference() self.validate_asset_and_reference()
self.validate_item() self.validate_item()
self.validate_cost_center()
self.set_missing_values() self.set_missing_values()
self.prepare_depreciation_data() self.prepare_depreciation_data()
self.validate_gross_and_purchase_amount() self.validate_gross_and_purchase_amount()
@@ -96,6 +97,19 @@ class Asset(AccountsController):
elif item.is_stock_item: elif item.is_stock_item:
frappe.throw(_("Item {0} must be a non-stock item").format(self.item_code)) 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): def validate_in_use_date(self):
if not self.available_for_use_date: if not self.available_for_use_date:
frappe.throw(_("Available for use date is required")) frappe.throw(_("Available for use date is required"))

View File

@@ -1131,6 +1131,15 @@ class TestDepreciationBasics(AssetSetup):
self.assertEqual(gle, expected_gle) self.assertEqual(gle, expected_gle)
self.assertEqual(asset.get("value_after_depreciation"), 0) 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(): def create_asset_data():
if not frappe.db.exists("Asset Category", "Computers"): if not frappe.db.exists("Asset Category", "Computers"):
create_asset_category() create_asset_category()

View File

@@ -682,17 +682,18 @@ class TestPurchaseOrder(unittest.TestCase):
bin1 = frappe.db.get_value("Bin", bin1 = frappe.db.get_value("Bin",
filters={"warehouse": "_Test Warehouse - _TC", "item_code": "_Test Item"}, 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 # Submit PO
po = create_purchase_order(item_code="_Test FG Item", is_subcontracted="Yes") po = create_purchase_order(item_code="_Test FG Item", is_subcontracted="Yes")
bin2 = frappe.db.get_value("Bin", bin2 = frappe.db.get_value("Bin",
filters={"warehouse": "_Test Warehouse - _TC", "item_code": "_Test Item"}, 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.reserved_qty_for_sub_contract, bin1.reserved_qty_for_sub_contract + 10)
self.assertEqual(bin2.projected_qty, bin1.projected_qty - 10) self.assertEqual(bin2.projected_qty, bin1.projected_qty - 10)
self.assertNotEqual(bin1.modified, bin2.modified)
# Create stock transfer # Create stock transfer
rm_item = [{"item_code":"_Test FG Item","rm_item_code":"_Test Item","item_name":"_Test Item", rm_item = [{"item_code":"_Test FG Item","rm_item_code":"_Test Item","item_name":"_Test Item",

View 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))

View File

@@ -408,6 +408,22 @@ class AccountsController(TransactionBase):
if item_qty != len(get_serial_nos(item.get('serial_no'))): if item_qty != len(get_serial_nos(item.get('serial_no'))):
item.set(fieldname, value) 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'): 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)) 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_type'] = schedule.discount_type
payment_schedule['discount'] = schedule.discount payment_schedule['discount'] = schedule.discount
if not schedule.invoice_portion:
payment_schedule['payment_amount'] = schedule.payment_amount
self.append("payment_schedule", payment_schedule) self.append("payment_schedule", payment_schedule)
def set_due_date(self): 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) 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): def validate_and_delete_children(parent, data):
deleted_children = [] deleted_children = []

View File

@@ -70,9 +70,18 @@ class BuyingController(StockController, Subcontracting):
# set contact and address details for supplier, if they are not mentioned # set contact and address details for supplier, if they are not mentioned
if getattr(self, "supplier", None): if getattr(self, "supplier", None):
self.update_if_missing(get_party_details(self.supplier, party_type="Supplier", ignore_permissions=self.flags.ignore_permissions, self.update_if_missing(
doctype=self.doctype, company=self.company, party_address=self.supplier_address, shipping_address=self.get('shipping_address'), get_party_details(
fetch_payment_terms_template= not self.get('ignore_default_payment_terms_template'))) 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) self.set_missing_item_details(for_validate)

View File

@@ -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_doc = frappe.get_cached_doc('Item', filters.get('item_code'))
item_group = filters.get('item_group') item_group = filters.get('item_group')
company = filters.get('company')
taxes = item_doc.taxes or [] taxes = item_doc.taxes or []
while item_group: 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 item_group = item_group_doc.parent_item_group
if not taxes: 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: else:
valid_from = filters.get('valid_from') valid_from = filters.get('valid_from')
valid_from = valid_from[1] if isinstance(valid_from, list) else 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'), 'item_code': filters.get('item_code'),
'posting_date': valid_from, 'posting_date': valid_from,
'tax_category': filters.get('tax_category'), 'tax_category': filters.get('tax_category'),
'company': filters.get('company') 'company': company
} }
taxes = _get_item_tax_template(args, taxes, for_validate=True) taxes = _get_item_tax_template(args, taxes, for_validate=True)

View File

@@ -74,7 +74,8 @@ class SellingController(StockController):
doctype=self.doctype, company=self.company, doctype=self.doctype, company=self.company,
posting_date=self.get('posting_date'), posting_date=self.get('posting_date'),
fetch_payment_terms_template=fetch_payment_terms_template, 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"): if not self.meta.get_field("sales_team"):
party_details.pop("sales_team") party_details.pop("sales_team")
self.update_if_missing(party_details) self.update_if_missing(party_details)
@@ -204,7 +205,7 @@ class SellingController(StockController):
valuation_rate_map = {} valuation_rate_map = {}
for item in self.items: for item in self.items:
if not item.item_code: if not item.item_code or item.is_free_item:
continue continue
last_purchase_rate, is_stock_item = frappe.get_cached_value( 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 valuation_rate_map[(rate.item_code, rate.warehouse)] = rate.valuation_rate
for item in self.items: for item in self.items:
if not item.item_code: if not item.item_code or item.is_free_item:
continue continue
last_valuation_rate = valuation_rate_map.get( last_valuation_rate = valuation_rate_map.get(

View File

@@ -400,6 +400,16 @@ class StatusUpdater(Document):
ref_doc = frappe.get_doc(ref_dt, ref_dn) ref_doc = frappe.get_doc(ref_dt, ref_dn)
ref_doc.db_set("per_billed", per_billed) 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) 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"): def get_allowance_for(item_code, item_allowance=None, global_qty_allowance=None, global_amount_allowance=None, qty_or_amount="qty"):

View File

@@ -3,6 +3,7 @@
import json import json
from collections import defaultdict from collections import defaultdict
from typing import List, Tuple
import frappe import frappe
from frappe import _ from frappe import _
@@ -181,33 +182,28 @@ class StockController(AccountsController):
return details return details
def get_items_and_warehouses(self): def get_items_and_warehouses(self) -> Tuple[List[str], List[str]]:
items, warehouses = [], [] """Get list of items and warehouses affected by a transaction"""
if hasattr(self, "items"): if not (hasattr(self, "items") or hasattr(self, "packed_items")):
item_doclist = self.get("items") return [], []
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 item_doclist: item_rows = (self.get("items") or []) + (self.get("packed_items") or [])
for d in item_doclist:
if d.item_code and d.item_code not in items:
items.append(d.item_code)
if d.get("warehouse") and d.warehouse not in warehouses: items = {d.item_code for d in item_rows if d.item_code}
warehouses.append(d.warehouse)
if self.doctype == "Stock Entry": warehouses = set()
if d.get("s_warehouse") and d.s_warehouse not in warehouses: for d in item_rows:
warehouses.append(d.s_warehouse) if d.get("warehouse"):
if d.get("t_warehouse") and d.t_warehouse not in warehouses: warehouses.add(d.warehouse)
warehouses.append(d.t_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): def get_stock_ledger_details(self):
stock_ledger = {} stock_ledger = {}
@@ -219,7 +215,7 @@ class StockController(AccountsController):
from from
`tabStock Ledger Entry` `tabStock Ledger Entry`
where 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) """, (self.doctype, self.name), as_dict=True)
for sle in stock_ledger_entries: for sle in stock_ledger_entries:

View File

@@ -106,6 +106,9 @@ class calculate_taxes_and_totals(object):
self.doc.conversion_rate = flt(self.doc.conversion_rate) self.doc.conversion_rate = flt(self.doc.conversion_rate)
def calculate_item_values(self): def calculate_item_values(self):
if self.doc.get('is_consolidated'):
return
if not self.discount_amount_applied: if not self.discount_amount_applied:
for item in self.doc.get("items"): for item in self.doc.get("items"):
self.doc.round_floats_in(item) self.doc.round_floats_in(item)
@@ -646,12 +649,12 @@ class calculate_taxes_and_totals(object):
def calculate_change_amount(self): def calculate_change_amount(self):
self.doc.change_amount = 0.0 self.doc.change_amount = 0.0
self.doc.base_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" \ 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): 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.change_amount = flt(self.doc.paid_amount - grand_total +
self.doc.write_off_amount, self.doc.precision("change_amount")) self.doc.write_off_amount, self.doc.precision("change_amount"))

View File

@@ -66,26 +66,24 @@ class ItemVariantsCacheManager:
) )
] ]
# join with Website Item # Get Variants and tehir Attributes that are not disabled
item_variants_data = frappe.get_all( iva = frappe.qb.DocType("Item Variant Attribute")
'Item Variant Attribute', item = frappe.qb.DocType("Item")
{'variant_of': parent_item_code}, query = (
['parent', 'attribute', 'attribute_value'], frappe.qb.from_(iva)
order_by='name', .join(item).on(item.name == iva.parent)
as_list=1 .select(
) iva.parent, iva.attribute, iva.attribute_value
).where(
disabled_items = set( (iva.variant_of == parent_item_code)
[i.name for i in frappe.db.get_all('Item', {'disabled': 1})] & (item.disabled == 0)
).orderby(iva.name)
) )
item_variants_data = query.run()
attribute_value_item_map = frappe._dict() attribute_value_item_map = frappe._dict()
item_attribute_value_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: for row in item_variants_data:
item_code, attribute, attribute_value = row item_code, attribute, attribute_value = row
# (attr, value) => [item1, item2] # (attr, value) => [item1, item2]
@@ -124,4 +122,7 @@ def build_cache(item_code):
def enqueue_build_cache(item_code): def enqueue_build_cache(item_code):
if frappe.cache().hget('item_cache_build_in_progress', item_code): if frappe.cache().hget('item_cache_build_in_progress', item_code):
return 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'
)

View File

@@ -106,6 +106,8 @@ class TestVariantSelector(ERPNextTestCase):
}) })
make_web_item_price(item_code="Test-Tshirt-Temp-S-R", price_list_rate=100) 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( next_values = get_next_attribute_and_values(
"Test-Tshirt-Temp", "Test-Tshirt-Temp",
selected_attributes={"Test Size": "Small", "Test Colour": "Red"} selected_attributes={"Test Size": "Small", "Test Colour": "Red"}

View File

@@ -6,6 +6,7 @@ import frappe
from frappe import _, msgprint from frappe import _, msgprint
from frappe.desk.reportview import get_match_cond from frappe.desk.reportview import get_match_cond
from frappe.model.document import Document from frappe.model.document import Document
from frappe.query_builder.functions import Min
from frappe.utils import comma_and, get_link_to_form, getdate from frappe.utils import comma_and, get_link_to_form, getdate
@@ -60,8 +61,15 @@ class ProgramEnrollment(Document):
frappe.throw(_("Student is already enrolled.")) frappe.throw(_("Student is already enrolled."))
def update_student_joining_date(self): def update_student_joining_date(self):
date = frappe.db.sql("select min(enrollment_date) from `tabProgram Enrollment` where student= %s", self.student) table = frappe.qb.DocType('Program Enrollment')
frappe.db.set_value("Student", self.student, "joining_date", date) 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): def make_fee_records(self):
from erpnext.education.api import get_fee_components from erpnext.education.api import get_fee_components

View File

@@ -1,2 +1,10 @@
// Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors // Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt // 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);
}
});

View File

@@ -20,6 +20,7 @@ def send_reminders_in_advance_weekly():
send_advance_holiday_reminders("Weekly") send_advance_holiday_reminders("Weekly")
def send_reminders_in_advance_monthly(): def send_reminders_in_advance_monthly():
to_send_in_advance = int(frappe.db.get_single_value("HR Settings", "send_holiday_reminders")) to_send_in_advance = int(frappe.db.get_single_value("HR Settings", "send_holiday_reminders"))
frequency = frappe.db.get_single_value("HR Settings", "frequency") frequency = frappe.db.get_single_value("HR Settings", "frequency")
@@ -28,6 +29,7 @@ def send_reminders_in_advance_monthly():
send_advance_holiday_reminders("Monthly") send_advance_holiday_reminders("Monthly")
def send_advance_holiday_reminders(frequency): def send_advance_holiday_reminders(frequency):
"""Send Holiday Reminders in Advance to Employees """Send Holiday Reminders in Advance to Employees
`frequency` (str): 'Weekly' or 'Monthly' `frequency` (str): 'Weekly' or 'Monthly'
@@ -42,7 +44,7 @@ def send_advance_holiday_reminders(frequency):
else: else:
return return
employees = frappe.db.get_all('Employee', pluck='name') employees = frappe.db.get_all('Employee', filters={'status': 'Active'}, pluck='name')
for employee in employees: for employee in employees:
holidays = get_holidays_for_employee( holidays = get_holidays_for_employee(
employee, employee,
@@ -51,10 +53,13 @@ def send_advance_holiday_reminders(frequency):
raise_exception=False 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): def send_holidays_reminder_in_advance(employee, holidays):
if not holidays:
return
employee_doc = frappe.get_doc('Employee', employee) employee_doc = frappe.get_doc('Employee', employee)
employee_email = get_employee_email(employee_doc) employee_email = get_employee_email(employee_doc)
frequency = frappe.db.get_single_value("HR Settings", "frequency") 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) reminder_text, message = get_birthday_reminder_text_and_message(others)
send_birthday_reminder(person_email, reminder_text, others, message) send_birthday_reminder(person_email, reminder_text, others, message)
def get_birthday_reminder_text_and_message(birthday_persons): def get_birthday_reminder_text_and_message(birthday_persons):
if len(birthday_persons) == 1: if len(birthday_persons) == 1:
birthday_person_text = birthday_persons[0]['name'] birthday_person_text = birthday_persons[0]['name']
@@ -116,6 +122,7 @@ def get_birthday_reminder_text_and_message(birthday_persons):
return reminder_text, message return reminder_text, message
def send_birthday_reminder(recipients, reminder_text, birthday_persons, message): def send_birthday_reminder(recipients, reminder_text, birthday_persons, message):
frappe.sendmail( frappe.sendmail(
recipients=recipients, recipients=recipients,
@@ -129,10 +136,12 @@ def send_birthday_reminder(recipients, reminder_text, birthday_persons, message)
header=_("Birthday Reminder 🎂") header=_("Birthday Reminder 🎂")
) )
def get_employees_who_are_born_today(): def get_employees_who_are_born_today():
"""Get all employee born today & group them based on their company""" """Get all employee born today & group them based on their company"""
return get_employees_having_an_event_today("birthday") return get_employees_having_an_event_today("birthday")
def get_employees_having_an_event_today(event_type): def get_employees_having_an_event_today(event_type):
"""Get all employee who have `event_type` today """Get all employee who have `event_type` today
& group them based on their company. `event_type` & 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) reminder_text, message = get_work_anniversary_reminder_text_and_message(others)
send_work_anniversary_reminder(person_email, reminder_text, others, message) send_work_anniversary_reminder(person_email, reminder_text, others, message)
def get_work_anniversary_reminder_text_and_message(anniversary_persons): def get_work_anniversary_reminder_text_and_message(anniversary_persons):
if len(anniversary_persons) == 1: if len(anniversary_persons) == 1:
anniversary_person = anniversary_persons[0]['name'] anniversary_person = anniversary_persons[0]['name']
persons_name = anniversary_person persons_name = anniversary_person
# Number of years completed at the company # Number of years completed at the company
completed_years = getdate().year - anniversary_persons[0]['date_of_joining'].year 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: else:
person_names_with_years = [] person_names_with_years = []
names = [] names = []
@@ -225,7 +235,7 @@ def get_work_anniversary_reminder_text_and_message(anniversary_persons):
names.append(person_text) names.append(person_text)
# Number of years completed at the company # Number of years completed at the company
completed_years = getdate().year - person['date_of_joining'].year 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) person_names_with_years.append(person_text)
# converts ["Jim", "Rim", "Dim"] to Jim, Rim & Dim # 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 return reminder_text, message
def send_work_anniversary_reminder(recipients, reminder_text, anniversary_persons, message): def send_work_anniversary_reminder(recipients, reminder_text, anniversary_persons, message):
frappe.sendmail( frappe.sendmail(
recipients=recipients, recipients=recipients,
@@ -249,5 +260,5 @@ def send_work_anniversary_reminder(recipients, reminder_text, anniversary_person
anniversary_persons=anniversary_persons, anniversary_persons=anniversary_persons,
message=message, message=message,
), ),
header=_("🎊️🎊️ Work Anniversary Reminder 🎊️🎊️") header=_("Work Anniversary Reminder")
) )

View File

@@ -36,7 +36,7 @@ class TestEmployee(unittest.TestCase):
employee_doc.reload() employee_doc.reload()
make_holiday_list() 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'""") 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", salary_structure = make_salary_structure("Test Inactive Employee Salary Slip", "Monthly",

View File

@@ -5,10 +5,12 @@ import unittest
from datetime import timedelta from datetime import timedelta
import frappe 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.employee.test_employee import make_employee
from erpnext.hr.doctype.hr_settings.hr_settings import set_proceed_with_frequency_change 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): class TestEmployeeReminders(unittest.TestCase):
@@ -46,6 +48,24 @@ class TestEmployeeReminders(unittest.TestCase):
cls.test_employee = test_employee cls.test_employee = test_employee
cls.test_holiday_dates = test_holiday_dates 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 @classmethod
def get_test_holiday_dates(cls): def get_test_holiday_dates(cls):
today_date = getdate() today_date = getdate()
@@ -61,6 +81,7 @@ class TestEmployeeReminders(unittest.TestCase):
def setUp(self): def setUp(self):
# Clear Email Queue # Clear Email Queue
frappe.db.sql("delete from `tabEmail Queue`") frappe.db.sql("delete from `tabEmail Queue`")
frappe.db.sql("delete from `tabEmail Queue Recipient`")
def test_is_holiday(self): def test_is_holiday(self):
from erpnext.hr.doctype.employee.employee import is_holiday 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) self.assertTrue("Subject: Birthday Reminder" in email_queue[0].message)
def test_work_anniversary_reminders(self): def test_work_anniversary_reminders(self):
employee = frappe.get_doc("Employee", frappe.db.sql_list("select name from tabEmployee limit 1")[0]) make_employee("test_work_anniversary@gmail.com",
employee.date_of_joining = "1998" + frappe.utils.nowdate()[4:] date_of_joining="1998" + frappe.utils.nowdate()[4:],
employee.company_email = "test@example.com" company="_Test Company",
employee.company = "_Test Company" )
employee.save()
from erpnext.hr.doctype.employee.employee_reminders import ( from erpnext.hr.doctype.employee.employee_reminders import (
get_employees_having_an_event_today, 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') 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 = frappe.get_doc("HR Settings", "HR Settings")
hr_settings.send_work_anniversary_reminders = 1 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) email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True)
self.assertTrue("Subject: Work Anniversary Reminder" in email_queue[0].message) self.assertTrue("Subject: Work Anniversary Reminder" in email_queue[0].message)
def test_send_holidays_reminder_in_advance(self): def test_work_anniversary_reminder_not_sent_for_0_years(self):
from erpnext.hr.doctype.employee.employee_reminders import send_holidays_reminder_in_advance make_employee("test_work_anniversary_2@gmail.com",
from erpnext.hr.utils import get_holidays_for_employee date_of_joining=getdate(),
company="_Test Company",
)
# Get HR settings and enable advance holiday reminders from erpnext.hr.doctype.employee.employee_reminders import get_employees_having_an_event_today
hr_settings = frappe.get_doc("HR Settings", "HR Settings")
hr_settings.send_holiday_reminders = 1 employees_having_work_anniversary = get_employees_having_an_event_today('work_anniversary')
set_proceed_with_frequency_change() employees = employees_having_work_anniversary.get("_Test Company") or []
hr_settings.frequency = 'Weekly' user_ids = []
hr_settings.save() 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( holidays = get_holidays_for_employee(
self.test_employee.get('name'), 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) email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True)
self.assertEqual(len(email_queue), 1) self.assertEqual(len(email_queue), 1)
self.assertTrue("Holidays this Week." in email_queue[0].message)
def test_advance_holiday_reminders_monthly(self): def test_advance_holiday_reminders_monthly(self):
from erpnext.hr.doctype.employee.employee_reminders import send_reminders_in_advance_monthly from erpnext.hr.doctype.employee.employee_reminders import send_reminders_in_advance_monthly
# Get HR settings and enable advance holiday reminders setup_hr_settings('Monthly')
hr_settings = frappe.get_doc("HR Settings", "HR Settings")
hr_settings.send_holiday_reminders = 1 # disable emp 2, set same holiday list
set_proceed_with_frequency_change() frappe.db.set_value('Employee', self.test_employee_2.name, {
hr_settings.frequency = 'Monthly' 'status': 'Left',
hr_settings.save() 'holiday_list': self.test_employee.holiday_list
})
send_reminders_in_advance_monthly() send_reminders_in_advance_monthly()
email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True) email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True)
self.assertTrue(len(email_queue) > 0) 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): def test_advance_holiday_reminders_weekly(self):
from erpnext.hr.doctype.employee.employee_reminders import send_reminders_in_advance_weekly from erpnext.hr.doctype.employee.employee_reminders import send_reminders_in_advance_weekly
# Get HR settings and enable advance holiday reminders setup_hr_settings('Weekly')
hr_settings = frappe.get_doc("HR Settings", "HR Settings")
hr_settings.send_holiday_reminders = 1 # disable emp 2, set same holiday list
hr_settings.frequency = 'Weekly' frappe.db.set_value('Employee', self.test_employee_2.name, {
hr_settings.save() 'status': 'Left',
'holiday_list': self.test_employee.holiday_list
})
send_reminders_in_advance_weekly() send_reminders_in_advance_weekly()
email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True) email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True)
self.assertTrue(len(email_queue) > 0) 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()

View File

@@ -27,12 +27,13 @@
"fetch_from": "employee.user_id", "fetch_from": "employee.user_id",
"fieldname": "user_id", "fieldname": "user_id",
"fieldtype": "Data", "fieldtype": "Data",
"in_list_view": 1,
"label": "ERPNext User ID", "label": "ERPNext User ID",
"read_only": 1 "read_only": 1
} }
], ],
"istable": 1, "istable": 1,
"modified": "2019-06-06 10:41:20.313756", "modified": "2022-02-13 19:44:21.302938",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "HR", "module": "HR",
"name": "Employee Group Table", "name": "Employee Group Table",

View File

@@ -75,10 +75,8 @@ class TestLeaveApplication(unittest.TestCase):
frappe.db.sql("DELETE FROM `tab%s`" % dt) #nosec frappe.db.sql("DELETE FROM `tab%s`" % dt) #nosec
frappe.set_user("Administrator") frappe.set_user("Administrator")
@classmethod
def setUpClass(cls):
set_leave_approver() set_leave_approver()
frappe.db.sql("delete from tabAttendance where employee='_T-Employee-00001'") frappe.db.sql("delete from tabAttendance where employee='_T-Employee-00001'")
def tearDown(self): 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)) 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() 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) 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() leave_application.reload()
self.assertEqual(leave_application.total_leave_days, 4) self.assertEqual(leave_application.total_leave_days, 4)
self.assertEqual(frappe.db.count('Attendance', {'leave_application': leave_application.name}), 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)) 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() 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) first_sunday = get_first_sunday(holiday_list)
# already marked attendance on a holiday should be deleted in this case # already marked attendance on a holiday should be deleted in this case
config = { config = {
"doctype": "Attendance", "doctype": "Attendance",
"employee": "_T-Employee-00001", "employee": employee.name,
"status": "Present" "status": "Present"
} }
attendance_on_holiday = frappe.get_doc(config) attendance_on_holiday = frappe.get_doc(config)
attendance_on_holiday.attendance_date = first_sunday attendance_on_holiday.attendance_date = first_sunday
attendance_on_holiday.flags.ignore_validate = True
attendance_on_holiday.save() attendance_on_holiday.save()
# already marked attendance on a non-holiday should be updated # already marked attendance on a non-holiday should be updated
attendance = frappe.get_doc(config) attendance = frappe.get_doc(config)
attendance.attendance_date = add_days(first_sunday, 3) attendance.attendance_date = add_days(first_sunday, 3)
attendance.flags.ignore_validate = True
attendance.save() 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() leave_application.reload()
# holiday should be excluded while marking attendance # holiday should be excluded while marking attendance
self.assertEqual(leave_application.total_leave_days, 3) self.assertEqual(leave_application.total_leave_days, 3)
@@ -325,7 +327,7 @@ class TestLeaveApplication(unittest.TestCase):
employee = get_employee() employee = get_employee()
default_holiday_list = make_holiday_list() 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) first_sunday = get_first_sunday(default_holiday_list)
optional_leave_date = add_days(first_sunday, 1) optional_leave_date = add_days(first_sunday, 1)
@@ -543,7 +545,7 @@ class TestLeaveApplication(unittest.TestCase):
from erpnext.hr.utils import allocate_earned_leaves from erpnext.hr.utils import allocate_earned_leaves
i = 0 i = 0
while(i<14): while(i<14):
allocate_earned_leaves() allocate_earned_leaves(ignore_duplicates=True)
i += 1 i += 1
self.assertEqual(get_leave_balance_on(employee.name, leave_type, nowdate()), 6) 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) frappe.db.set_value('Leave Type', leave_type, 'max_leaves_allowed', 0)
i = 0 i = 0
while(i<6): while(i<6):
allocate_earned_leaves() allocate_earned_leaves(ignore_duplicates=True)
i += 1 i += 1
self.assertEqual(get_leave_balance_on(employee.name, leave_type, nowdate()), 9) self.assertEqual(get_leave_balance_on(employee.name, leave_type, nowdate()), 9)

View File

@@ -8,12 +8,11 @@ from math import ceil
import frappe import frappe
from frappe import _, bold from frappe import _, bold
from frappe.model.document import Document 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 from six import string_types
class LeavePolicyAssignment(Document): class LeavePolicyAssignment(Document):
def validate(self): def validate(self):
self.validate_policy_assignment_overlap() self.validate_policy_assignment_overlap()
self.set_dates() self.set_dates()
@@ -95,10 +94,12 @@ class LeavePolicyAssignment(Document):
new_leaves_allocated = 0 new_leaves_allocated = 0
elif leave_type_details.get(leave_type).is_earned_leave == 1: elif leave_type_details.get(leave_type).is_earned_leave == 1:
if self.assignment_based_on == "Leave Period": if not self.assignment_based_on:
new_leaves_allocated = self.get_leaves_for_passed_months(leave_type, new_leaves_allocated, leave_type_details, date_of_joining)
else:
new_leaves_allocated = 0 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 # 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): 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)) 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): 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 from erpnext.hr.utils import get_monthly_earned_leave
current_month = get_datetime().month current_date = frappe.flags.current_date or getdate()
current_year = get_datetime().year 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") from_date = getdate(self.effective_from)
if getdate(date_of_joining) > getdate(from_date): if getdate(date_of_joining) > from_date:
from_date = date_of_joining from_date = getdate(date_of_joining)
from_date_month = get_datetime(from_date).month
from_date_year = get_datetime(from_date).year
months_passed = 0 months_passed = 0
if current_year == from_date_year and current_month > from_date_month: based_on_doj = leave_type_details.get(leave_type).based_on_date_of_joining
months_passed = current_month - from_date_month
elif current_year > from_date_year: if current_date.year == from_date.year and current_date.month >= from_date.month:
months_passed = (12 - from_date_month) + current_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: if months_passed > 0:
monthly_earned_leave = get_monthly_earned_leave(new_leaves_allocated, monthly_earned_leave = get_monthly_earned_leave(new_leaves_allocated,
@@ -135,6 +139,23 @@ class LeavePolicyAssignment(Document):
return new_leaves_allocated 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() @frappe.whitelist()
def create_assignment_for_multiple_employees(employees, data): 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(): def get_leave_type_details():
leave_type_details = frappe._dict() leave_type_details = frappe._dict()
leave_types = frappe.get_all("Leave Type", 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"]) "is_carry_forward", "expire_carry_forwarded_leaves_after_days", "earned_leave_frequency", "rounding"])
for d in leave_types: for d in leave_types:
leave_type_details.setdefault(d.name, d) leave_type_details.setdefault(d.name, d)

View File

@@ -4,7 +4,7 @@
import unittest import unittest
import frappe 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 ( from erpnext.hr.doctype.leave_application.test_leave_application import (
get_employee, get_employee,
@@ -20,36 +20,31 @@ test_dependencies = ["Employee"]
class TestLeavePolicyAssignment(unittest.TestCase): class TestLeavePolicyAssignment(unittest.TestCase):
def setUp(self): def setUp(self):
for doctype in ["Leave Period", "Leave Application", "Leave Allocation", "Leave Policy Assignment", "Leave Ledger Entry"]: 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): def test_grant_leaves(self):
leave_period = get_leave_period() leave_period = get_leave_period()
employee = get_employee() # allocation = 10
# create the leave policy with leave type "_Test Leave Type", allocation = 10
leave_policy = create_leave_policy() leave_policy = create_leave_policy()
leave_policy.submit() leave_policy.submit()
data = { data = {
"assignment_based_on": "Leave Period", "assignment_based_on": "Leave Period",
"leave_policy": leave_policy.name, "leave_policy": leave_policy.name,
"leave_period": leave_period.name "leave_period": leave_period.name
} }
leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data)) self.assertEqual(frappe.db.get_value("Leave Policy Assignment", leave_policy_assignments[0], "leaves_allocated"), 1)
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_allocation = frappe.get_list("Leave Allocation", filters={ leave_allocation = frappe.get_list("Leave Allocation", filters={
"employee": employee.name, "employee": self.employee.name,
"leave_policy":leave_policy.name, "leave_policy":leave_policy.name,
"leave_policy_assignment": leave_policy_assignments[0], "leave_policy_assignment": leave_policy_assignments[0],
"docstatus": 1})[0] "docstatus": 1})[0]
leave_alloc_doc = frappe.get_doc("Leave Allocation", leave_allocation) leave_alloc_doc = frappe.get_doc("Leave Allocation", leave_allocation)
self.assertEqual(leave_alloc_doc.new_leaves_allocated, 10) 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): def test_allow_to_grant_all_leave_after_cancellation_of_every_leave_allocation(self):
leave_period = get_leave_period() leave_period = get_leave_period()
employee = get_employee()
# create the leave policy with leave type "_Test Leave Type", allocation = 10 # create the leave policy with leave type "_Test Leave Type", allocation = 10
leave_policy = create_leave_policy() leave_policy = create_leave_policy()
leave_policy.submit() leave_policy.submit()
data = { data = {
"assignment_based_on": "Leave Period", "assignment_based_on": "Leave Period",
"leave_policy": leave_policy.name, "leave_policy": leave_policy.name,
"leave_period": leave_period.name "leave_period": leave_period.name
} }
leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
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()
# every leave is allocated no more leave can be granted now # 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={ leave_allocation = frappe.get_list("Leave Allocation", filters={
"employee": employee.name, "employee": self.employee.name,
"leave_policy":leave_policy.name, "leave_policy":leave_policy.name,
"leave_policy_assignment": leave_policy_assignments[0], "leave_policy_assignment": leave_policy_assignments[0],
"docstatus": 1})[0] "docstatus": 1})[0]
leave_alloc_doc = frappe.get_doc("Leave Allocation", leave_allocation) 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.cancel()
leave_alloc_doc.delete() leave_alloc_doc.delete()
self.assertEqual(frappe.db.get_value("Leave Policy Assignment", leave_policy_assignments[0], "leaves_allocated"), 0)
leave_policy_assignment_doc.reload()
# User are now allowed to grant leave
self.assertEqual(leave_policy_assignment_doc.leaves_allocated, 0)
def test_earned_leave_allocation(self): def test_earned_leave_allocation(self):
leave_period = create_leave_period("Test Earned Leave Period") leave_period = create_leave_period("Test Earned Leave Period")
employee = get_employee()
leave_type = create_earned_leave_type("Test Earned Leave") leave_type = create_earned_leave_type("Test Earned Leave")
leave_policy = frappe.get_doc({ leave_policy = frappe.get_doc({
"doctype": "Leave Policy", "doctype": "Leave Policy",
"leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 6}] "leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 6}]
}).insert() }).submit()
data = { data = {
"assignment_based_on": "Leave Period", "assignment_based_on": "Leave Period",
"leave_policy": leave_policy.name, "leave_policy": leave_policy.name,
"leave_period": leave_period.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 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", { leaves_allocated = frappe.db.get_value("Leave Allocation", {
@@ -124,11 +102,200 @@ class TestLeavePolicyAssignment(unittest.TestCase):
}, "total_leaves_allocated") }, "total_leaves_allocated")
self.assertEqual(leaves_allocated, 0) 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): def tearDown(self):
frappe.db.rollback() 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) frappe.delete_doc_if_exists("Leave Type", leave_type, force=1)
return frappe.get_doc(dict( return frappe.get_doc(dict(
@@ -137,13 +304,15 @@ def create_earned_leave_type(leave_type):
is_earned_leave=1, is_earned_leave=1,
earned_leave_frequency="Monthly", earned_leave_frequency="Monthly",
rounding=0.5, rounding=0.5,
max_leaves_allowed=6 is_carry_forward=1,
based_on_date_of_joining=based_on_doj
)).insert() )).insert()
def create_leave_period(name): def create_leave_period(name, start_date=None):
frappe.delete_doc_if_exists("Leave Period", name, force=1) 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( return frappe.get_doc(dict(
name=name, name=name,
@@ -153,3 +322,16 @@ def create_leave_period(name):
company="_Test Company", company="_Test Company",
is_active=1 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

View File

@@ -353,7 +353,7 @@ def generate_leave_encashment():
create_leave_encashment(leave_allocation=leave_allocation) create_leave_encashment(leave_allocation=leave_allocation)
def allocate_earned_leaves(): def allocate_earned_leaves(ignore_duplicates=False):
'''Allocate earned leaves to Employees''' '''Allocate earned leaves to Employees'''
e_leave_types = get_earned_leaves() e_leave_types = get_earned_leaves()
today = getdate() today = getdate()
@@ -377,13 +377,13 @@ def allocate_earned_leaves():
from_date=allocation.from_date 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") 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): 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) 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) 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) 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 new_allocation = e_leave_type.max_leaves_allowed
if new_allocation != allocation.total_leaves_allocated: if new_allocation != allocation.total_leaves_allocated:
allocation.db_set("total_leaves_allocated", new_allocation, update_modified=False)
today_date = today() 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): def get_monthly_earned_leave(annual_leaves, frequency, rounding):
earned_leaves = 0.0 earned_leaves = 0.0
@@ -413,6 +416,28 @@ def get_monthly_earned_leave(annual_leaves, frequency, rounding):
return earned_leaves 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): def get_leave_allocations(date, leave_type):
return frappe.db.sql("""select name, employee, from_date, to_date, leave_policy_assignment, leave_policy return frappe.db.sql("""select name, employee, from_date, to_date, leave_policy_assignment, leave_policy
from `tabLeave Allocation` from `tabLeave Allocation`
@@ -434,7 +459,7 @@ def create_additional_leave_ledger_entry(allocation, leaves, date):
allocation.unused_leaves = 0 allocation.unused_leaves = 0
allocation.create_leave_ledger_entry() 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 import calendar
from dateutil import relativedelta 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 of month
last_day = calendar.monthrange(to_date.year, to_date.month)[1] 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": if frequency == "Monthly":
return True return True
elif frequency == "Quarterly" and rd.months % 3: elif frequency == "Quarterly" and rd.months % 3:

View File

@@ -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: if gle_map:
make_gl_entries(gle_map, cancel=cancel, adv_adj=adv_adj) make_gl_entries(gle_map, cancel=cancel, adv_adj=adv_adj)

View File

@@ -346,7 +346,7 @@ class LoanRepayment(AccountsController):
gle_map.append( gle_map.append(
self.get_gl_dict({ self.get_gl_dict({
"account": loan_details.penalty_income_account, "account": loan_details.penalty_income_account,
"against": payment_account, "against": loan_details.loan_account,
"credit": self.total_penalty_paid, "credit": self.total_penalty_paid,
"credit_in_account_currency": self.total_penalty_paid, "credit_in_account_currency": self.total_penalty_paid,
"against_voucher_type": "Loan", "against_voucher_type": "Loan",
@@ -368,7 +368,9 @@ class LoanRepayment(AccountsController):
"against_voucher": self.against_loan, "against_voucher": self.against_loan,
"remarks": remarks, "remarks": remarks,
"cost_center": self.cost_center, "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 ''
}) })
) )

View File

@@ -70,7 +70,6 @@
{ {
"fieldname": "loan_repayment_entry", "fieldname": "loan_repayment_entry",
"fieldtype": "Link", "fieldtype": "Link",
"hidden": 1,
"label": "Loan Repayment Entry", "label": "Loan Repayment Entry",
"no_copy": 1, "no_copy": 1,
"options": "Loan Repayment", "options": "Loan Repayment",
@@ -88,7 +87,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2021-03-14 20:47:11.725818", "modified": "2022-01-31 14:50:14.823213",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Loan Management", "module": "Loan Management",
"name": "Salary Slip Loan", "name": "Salary Slip Loan",
@@ -97,5 +96,6 @@
"quick_entry": 1, "quick_entry": 1,
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [],
"track_changes": 1 "track_changes": 1
} }

View File

@@ -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.add_custom_button(__("Work Order"), function() {
frm.trigger("make_work_order"); frm.trigger("make_work_order");
}, __("Create")); }, __("Create"));

View File

@@ -29,9 +29,24 @@ from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
class ProductionPlan(Document): class ProductionPlan(Document):
def validate(self): def validate(self):
self.set_pending_qty_in_row_without_reference()
self.calculate_total_planned_qty() self.calculate_total_planned_qty()
self.set_status() 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): def validate_data(self):
for d in self.get('po_items'): for d in self.get('po_items'):
if not d.bom_no: if not d.bom_no:
@@ -264,11 +279,6 @@ class ProductionPlan(Document):
'qty': so_detail['qty'] '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): def calculate_total_produced_qty(self):
self.total_produced_qty = 0 self.total_produced_qty = 0
for d in self.po_items: 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) 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: for data in self.po_items:
if data.name == production_plan_item: if data.name == production_plan_item:
data.produced_qty = produced_qty data.produced_qty = produced_qty
data.pending_qty = flt(data.planned_qty - produced_qty)
data.db_update() data.db_update()
self.calculate_total_produced_qty() self.calculate_total_produced_qty()
@@ -342,6 +353,7 @@ class ProductionPlan(Document):
def get_production_items(self): def get_production_items(self):
item_dict = {} item_dict = {}
for d in self.po_items: for d in self.po_items:
item_details = { item_details = {
"production_item" : d.item_code, "production_item" : d.item_code,
@@ -358,12 +370,12 @@ class ProductionPlan(Document):
"production_plan" : self.name, "production_plan" : self.name,
"production_plan_item" : d.name, "production_plan_item" : d.name,
"product_bundle_item" : d.product_bundle_item, "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({ if not item_details['project'] and d.sales_order:
"project": self.project or frappe.db.get_value("Sales Order", d.sales_order, "project") item_details['project'] = frappe.get_cached_value("Sales Order", d.sales_order, "project")
})
if self.get_items_from == "Material Request": if self.get_items_from == "Material Request":
item_details.update({ item_details.update({
@@ -381,39 +393,59 @@ class ProductionPlan(Document):
@frappe.whitelist() @frappe.whitelist()
def make_work_order(self): def make_work_order(self):
from erpnext.manufacturing.doctype.work_order.work_order import get_default_warehouse
wo_list, po_list = [], [] wo_list, po_list = [], []
subcontracted_po = {} subcontracted_po = {}
default_warehouses = get_default_warehouse()
self.validate_data() self.make_work_order_for_finished_goods(wo_list, default_warehouses)
self.make_work_order_for_finished_goods(wo_list) self.make_work_order_for_subassembly_items(wo_list, subcontracted_po, default_warehouses)
self.make_work_order_for_subassembly_items(wo_list, subcontracted_po)
self.make_subcontracted_purchase_order(subcontracted_po, po_list) self.make_subcontracted_purchase_order(subcontracted_po, po_list)
self.show_list_created_message('Work Order', wo_list) self.show_list_created_message('Work Order', wo_list)
self.show_list_created_message('Purchase Order', po_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() items_data = self.get_production_items()
for key, item in items_data.items(): for key, item in items_data.items():
if self.sub_assembly_items: if self.sub_assembly_items:
item['use_multi_level_bom'] = 0 item['use_multi_level_bom'] = 0
set_default_warehouses(item, default_warehouses)
work_order = self.create_work_order(item) work_order = self.create_work_order(item)
if work_order: if work_order:
wo_list.append(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: for row in self.sub_assembly_items:
if row.type_of_manufacturing == 'Subcontract': if row.type_of_manufacturing == 'Subcontract':
subcontracted_po.setdefault(row.supplier, []).append(row) subcontracted_po.setdefault(row.supplier, []).append(row)
continue continue
args = {} work_order_data = {
self.prepare_args_for_sub_assembly_items(row, args) 'wip_warehouse': default_warehouses.get('wip_warehouse'),
work_order = self.create_work_order(args) '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: if work_order:
wo_list.append(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): def make_subcontracted_purchase_order(self, subcontracted_po, purchase_orders):
if not subcontracted_po: if not subcontracted_po:
return 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.schedule_date = getdate(po_list[0].schedule_date) if po_list[0].schedule_date else nowdate()
po.is_subcontracted = 'Yes' po.is_subcontracted = 'Yes'
for row in po_list: for row in po_list:
args = { po_data = {
'item_code': row.production_item, 'item_code': row.production_item,
'warehouse': row.fg_warehouse, 'warehouse': row.fg_warehouse,
'production_plan_sub_assembly_item': row.name, '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', for field in ['schedule_date', 'qty', 'uom', 'stock_uom', 'item_name',
'description', 'production_plan_item']: '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.set_missing_values()
po.flags.ignore_mandatory = True 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] doc_list = [get_link_to_form(doctype, p) for p in doc_list]
msgprint(_("{0} created").format(comma_and(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): def create_work_order(self, item):
from erpnext.manufacturing.doctype.work_order.work_order import ( from erpnext.manufacturing.doctype.work_order.work_order import OverProductionError
OverProductionError,
get_default_warehouse,
)
warehouse = get_default_warehouse()
wo = frappe.new_doc("Work Order") wo = frappe.new_doc("Work Order")
wo.update(item) wo.update(item)
wo.planned_start_date = item.get('planned_start_date') or item.get('schedule_date') 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.fg_warehouse = item.get("warehouse")
wo.set_work_order_operations() wo.set_work_order_operations()
wo.set_required_items()
if not wo.fg_warehouse:
wo.fg_warehouse = warehouse.get('fg_warehouse')
try: try:
wo.flags.ignore_mandatory = True wo.flags.ignore_mandatory = True
wo.flags.ignore_validate = True
wo.insert() wo.insert()
return wo.name return wo.name
except OverProductionError: except OverProductionError:
@@ -589,7 +606,8 @@ def download_raw_materials(doc, warehouses=None):
if isinstance(doc, str): if isinstance(doc, str):
doc = frappe._dict(json.loads(doc)) 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', 'Projected Qty', 'Available Qty In Hand', 'Ordered Qty', 'Planned Qty',
'Reserved Qty for Production', 'Safety Stock', 'Required 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) items = get_items_for_material_requests(doc, warehouses=warehouses, get_parent_warehouse_data=True)
for d in items: 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('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')]) 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: if d.value:
get_sub_assembly_items(d.value, bom_data, stock_qty, indent=indent+1) 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)

View File

@@ -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.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.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 ( from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
create_stock_reconciliation, create_stock_reconciliation,
) )
@@ -36,15 +37,21 @@ class TestProductionPlan(ERPNextTestCase):
if not frappe.db.get_value('BOM', {'item': item}): if not frappe.db.get_value('BOM', {'item': item}):
make_bom(item = item, raw_materials = raw_materials) 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') pln = create_production_plan(item_code='Test Production Item 1')
self.assertTrue(len(pln.mr_items), 2) 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') 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) self.assertTrue(len(material_requests), 2)
@@ -66,27 +73,42 @@ class TestProductionPlan(ERPNextTestCase):
pln.cancel() pln.cancel()
def test_production_plan_start_date(self): 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) 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() plan.make_work_order()
work_orders = frappe.get_all('Work Order', fields = ['name', 'planned_start_date'], work_orders = frappe.get_all(
filters = {'production_plan': plan.name}) 'Work Order',
fields = ['name', 'planned_start_date'],
filters = {'production_plan': plan.name}
)
self.assertEqual(work_orders[0].planned_start_date, planned_date) self.assertEqual(work_orders[0].planned_start_date, planned_date)
for wo in work_orders: for wo in work_orders:
frappe.delete_doc('Work Order', wo.name) 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): 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", sr1 = create_stock_reconciliation(item_code="Raw Material Item 1",
target="_Test Warehouse - _TC", qty=1, rate=110) target="_Test Warehouse - _TC", qty=1, rate=110)
sr2 = create_stock_reconciliation(item_code="Raw Material Item 2", sr2 = create_stock_reconciliation(item_code="Raw Material Item 2",
target="_Test Warehouse - _TC", qty=1, rate=120) 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(len(pln.mr_items), 1)
self.assertTrue(flt(pln.mr_items[0].quantity), 1.0) self.assertTrue(flt(pln.mr_items[0].quantity), 1.0)
@@ -95,23 +117,39 @@ class TestProductionPlan(ERPNextTestCase):
pln.cancel() pln.cancel()
def test_production_plan_with_non_stock_item(self): 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) self.assertTrue(len(pln.mr_items), 3)
pln.cancel() pln.cancel()
def test_production_plan_without_multi_level(self): 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) self.assertTrue(len(pln.mr_items), 2)
pln.cancel() pln.cancel()
def test_production_plan_without_multi_level_for_existing_ordered_qty(self): 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", sr1 = create_stock_reconciliation(item_code="Raw Material Item 1",
target="_Test Warehouse - _TC", qty=1, rate=130) target="_Test Warehouse - _TC", qty=1, rate=130)
sr2 = create_stock_reconciliation(item_code="Subassembly Item 1", sr2 = create_stock_reconciliation(item_code="Subassembly Item 1",
target="_Test Warehouse - _TC", qty=1, rate=140) target="_Test Warehouse - _TC", qty=1, rate=140)
pln = create_production_plan(item_code='Test Production Item 1', pln = create_production_plan(
use_multi_level_bom=0, ignore_existing_ordered_qty=0) item_code='Test Production Item 1',
use_multi_level_bom=0,
ignore_existing_ordered_qty=0
)
self.assertTrue(len(pln.mr_items), 0) self.assertTrue(len(pln.mr_items), 0)
sr1.cancel() sr1.cancel()
@@ -119,6 +157,7 @@ class TestProductionPlan(ERPNextTestCase):
pln.cancel() pln.cancel()
def test_production_plan_sales_orders(self): def test_production_plan_sales_orders(self):
"Test if previously fulfilled SO (with WO) is pulled into Prod Plan."
item = 'Test Production Item 1' item = 'Test Production Item 1'
so = make_sales_order(item_code=item, qty=1) so = make_sales_order(item_code=item, qty=1)
sales_order = so.name sales_order = so.name
@@ -166,24 +205,25 @@ class TestProductionPlan(ERPNextTestCase):
self.assertEqual(sales_orders, []) self.assertEqual(sales_orders, [])
def test_production_plan_combine_items(self): def test_production_plan_combine_items(self):
"Test combining FG items in Production Plan."
item = 'Test Production Item 1' 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 = frappe.new_doc('Production Plan')
pln.company = so.company pln.company = so1.company
pln.get_items_from = 'Sales Order' pln.get_items_from = 'Sales Order'
pln.append('sales_orders', { pln.append('sales_orders', {
'sales_order': so.name, 'sales_order': so1.name,
'sales_order_date': so.transaction_date, 'sales_order_date': so1.transaction_date,
'customer': so.customer, 'customer': so1.customer,
'grand_total': so.grand_total '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', { pln.append('sales_orders', {
'sales_order': so.name, 'sales_order': so2.name,
'sales_order_date': so.transaction_date, 'sales_order_date': so2.transaction_date,
'customer': so.customer, 'customer': so2.customer,
'grand_total': so.grand_total 'grand_total': so2.grand_total
}) })
pln.combine_items = 1 pln.combine_items = 1
pln.get_items() 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') so_wo_qty = frappe.db.get_value('Sales Order Item', so_item, 'work_order_qty')
self.assertEqual(so_wo_qty, 0.0) self.assertEqual(so_wo_qty, 0.0)
latest_plan = frappe.get_doc('Production Plan', pln.name) pln.reload()
latest_plan.cancel() pln.cancel()
def test_pp_to_mr_customer_provided(self): 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('CUST-0987', is_customer_provided_item = 1, customer = '_Test Customer', is_purchase_item = 0)
create_item('Production Item CUST') create_item('Production Item CUST')
for item, raw_materials in {'Production Item CUST': ['Raw Material Item 1', 'CUST-0987']}.items(): for item, raw_materials in {'Production Item CUST': ['Raw Material Item 1', 'CUST-0987']}.items():
if not frappe.db.get_value('BOM', {'item': item}): if not frappe.db.get_value('BOM', {'item': item}):
make_bom(item = item, raw_materials = raw_materials) make_bom(item = item, raw_materials = raw_materials)
production_plan = create_production_plan(item_code = 'Production Item CUST') production_plan = create_production_plan(item_code = 'Production Item CUST')
production_plan.make_material_request() 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) mr = frappe.get_doc('Material Request', material_request)
self.assertTrue(mr.material_request_type, 'Customer Provided') self.assertTrue(mr.material_request_type, 'Customer Provided')
self.assertTrue(mr.customer, '_Test Customer') self.assertTrue(mr.customer, '_Test Customer')
def test_production_plan_with_multi_level_bom(self): def test_production_plan_with_multi_level_bom(self):
#|Item Code | Qty | """
#|Test BOM 1 | 1 | Item Code | Qty |
#| Test BOM 2 | 2 | |Test BOM 1 | 1 |
#| Test BOM 3 | 3 | |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"]: 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) create_item(item_code, is_stock_item=1)
@@ -264,15 +313,18 @@ class TestProductionPlan(ERPNextTestCase):
pln.make_work_order() pln.make_work_order()
#last level sub-assembly work order produce qty #last level sub-assembly work order produce qty
to_produce_qty = frappe.db.get_value("Work Order", to_produce_qty = frappe.db.get_value(
{"production_plan": pln.name, "production_item": "Test BOM 3"}, "qty") "Work Order",
{"production_plan": pln.name, "production_item": "Test BOM 3"},
"qty"
)
self.assertEqual(to_produce_qty, 18.0) self.assertEqual(to_produce_qty, 18.0)
pln.cancel() pln.cancel()
frappe.delete_doc("Production Plan", pln.name) frappe.delete_doc("Production Plan", pln.name)
def test_get_warehouse_list_group(self): 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\"}]' warehouse_json = '[{\"warehouse\":\"_Test Warehouse Group - _TC\"}]'
warehouses = set(get_warehouse_list(warehouse_json)) warehouses = set(get_warehouse_list(warehouse_json))
@@ -284,6 +336,7 @@ class TestProductionPlan(ERPNextTestCase):
msg=f"Following warehouses were expected {', '.join(missing_warehouse)}") msg=f"Following warehouses were expected {', '.join(missing_warehouse)}")
def test_get_warehouse_list_single(self): 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\"}]' warehouse_json = '[{\"warehouse\":\"_Test Scrap Warehouse - _TC\"}]'
warehouses = set(get_warehouse_list(warehouse_json)) warehouses = set(get_warehouse_list(warehouse_json))
@@ -292,6 +345,7 @@ class TestProductionPlan(ERPNextTestCase):
self.assertEqual(warehouses, expected_warehouses) self.assertEqual(warehouses, expected_warehouses)
def test_get_sales_order_with_variant(self): 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) rm_item = create_item('PIV_RM', valuation_rate = 100)
if not frappe.db.exists('Item', {"item_code": 'PIV'}): if not frappe.db.exists('Item', {"item_code": 'PIV'}):
item = create_item('PIV', valuation_rate = 100) item = create_item('PIV', valuation_rate = 100)
@@ -348,7 +402,7 @@ class TestProductionPlan(ERPNextTestCase):
frappe.db.rollback() frappe.db.rollback()
def test_subassmebly_sorting(self): 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 from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
prefix = "_TestLevel_" prefix = "_TestLevel_"
@@ -385,8 +439,164 @@ class TestProductionPlan(ERPNextTestCase):
# lowest most level of subassembly should be first # lowest most level of subassembly should be first
self.assertIn("SuperSecret", plan.sub_assembly_items[0].production_item) 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): 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) args = frappe._dict(args)
pln = frappe.get_doc({ pln = frappe.get_doc({
@@ -394,20 +604,35 @@ def create_production_plan(**args):
'company': args.company or '_Test Company', 'company': args.company or '_Test Company',
'customer': args.customer or '_Test Customer', 'customer': args.customer or '_Test Customer',
'posting_date': nowdate(), 'posting_date': nowdate(),
'include_non_stock_items': args.include_non_stock_items or 1, 'include_non_stock_items': args.include_non_stock_items or 0,
'include_subcontracted_items': args.include_subcontracted_items or 1, 'include_subcontracted_items': args.include_subcontracted_items or 0,
'ignore_existing_ordered_qty': args.ignore_existing_ordered_qty or 1, 'ignore_existing_ordered_qty': args.ignore_existing_ordered_qty or 0,
'po_items': [{ '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, 'use_multi_level_bom': args.use_multi_level_bom or 1,
'item_code': args.item_code, 'item_code': args.item_code,
'bom_no': frappe.db.get_value('Item', args.item_code, 'default_bom'), 'bom_no': frappe.db.get_value('Item', args.item_code, 'default_bom'),
'planned_qty': args.planned_qty or 1, 'planned_qty': args.planned_qty or 1,
'planned_start_date': args.planned_start_date or now_datetime() 'planned_start_date': args.planned_start_date or now_datetime()
}] })
})
mr_items = get_items_for_material_requests(pln.as_dict()) if args.get("get_items_from") == "Sales Order" and args.get("sales_order"):
for d in mr_items: so = args.get("sales_order")
pln.append('mr_items', d) 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: if not args.do_not_save:
pln.insert() pln.insert()

View File

@@ -198,6 +198,21 @@ class TestWorkOrder(ERPNextTestCase):
self.assertEqual(cint(bin1_on_end_production.reserved_qty_for_production), self.assertEqual(cint(bin1_on_end_production.reserved_qty_for_production),
cint(bin1_on_start_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): def test_backflush_qty_for_overpduction_manufacture(self):
cancel_stock_entry = [] cancel_stock_entry = []
allow_overproduction("overproduction_percentage_for_work_order", 30) 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, wo = make_wo_order_test_record(item=item_name, qty=1, source_warehouse=source_warehouse,
company=company) 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): def test_wo_completion_with_pl_bom(self):
from erpnext.manufacturing.doctype.bom.test_bom import ( from erpnext.manufacturing.doctype.bom.test_bom import (

View File

@@ -8,6 +8,8 @@ from dateutil.relativedelta import relativedelta
from frappe import _ from frappe import _
from frappe.model.document import Document from frappe.model.document import Document
from frappe.model.mapper import get_mapped_doc 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 ( from frappe.utils import (
cint, cint,
date_diff, date_diff,
@@ -74,7 +76,6 @@ class WorkOrder(Document):
self.set_required_items(reset_only_qty = len(self.get("required_items"))) self.set_required_items(reset_only_qty = len(self.get("required_items")))
def validate_sales_order(self): def validate_sales_order(self):
if self.sales_order: if self.sales_order:
self.check_sales_order_on_hold_or_close() 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 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): def before_submit(self):
self.create_serial_no_batch_no() self.create_serial_no_batch_no()
@@ -449,7 +450,13 @@ class WorkOrder(Document):
def update_ordered_qty(self): def update_ordered_qty(self):
if self.production_plan and self.production_plan_item: 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', frappe.db.set_value('Production Plan Item',
self.production_plan_item, 'ordered_qty', qty) self.production_plan_item, 'ordered_qty', qty)
@@ -535,7 +542,7 @@ class WorkOrder(Document):
if node.is_bom: if node.is_bom:
operations.extend(_get_operations(node.name, qty=node.exploded_qty)) 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)) operations.extend(_get_operations(self.bom_no, qty=1.0/bom_qty))
for correct_index, operation in enumerate(operations, start=1): for correct_index, operation in enumerate(operations, start=1):
@@ -615,7 +622,7 @@ class WorkOrder(Document):
frappe.delete_doc("Job Card", d.name) frappe.delete_doc("Job Card", d.name)
def validate_production_item(self): 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) frappe.throw(_("Work Order cannot be raised against a Item Template"), ItemHasVariantError)
if self.production_item: if self.production_item:
@@ -1165,3 +1172,27 @@ def create_pick_list(source_name, target_doc=None, for_qty=None):
doc.set_item_locations() doc.set_item_locations()
return doc 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

View File

@@ -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) GROUP BY bom_item.item_code""".format(qty_field=qty_field, table=table, conditions=conditions, bom=bom), as_dict=1)
def get_manufacturer_records(): 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() manufacture_details = frappe._dict()
for detail in details: 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', []).append(detail.get('manufacturer'))
dic.setdefault('manufacturer_part', []).append(detail.get('manufacturer_part_no')) dic.setdefault('manufacturer_part', []).append(detail.get('manufacturer_part_no'))

View File

@@ -348,3 +348,6 @@ erpnext.patches.v13_0.delete_bank_reconciliation_detail
erpnext.patches.v13_0.update_sane_transfer_against erpnext.patches.v13_0.update_sane_transfer_against
erpnext.patches.v13_0.enable_provisional_accounting 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

View File

@@ -10,6 +10,8 @@ def execute():
FROM `tabBin`""",as_dict=1) FROM `tabBin`""",as_dict=1)
for entry in bin_details: for entry in bin_details:
if not (entry.item_code and entry.warehouse):
continue
update_bin_qty(entry.get("item_code"), entry.get("warehouse"), { update_bin_qty(entry.get("item_code"), entry.get("warehouse"), {
"indented_qty": get_indented_qty(entry.get("item_code"), entry.get("warehouse")) "indented_qty": get_indented_qty(entry.get("item_code"), entry.get("warehouse"))
}) })

View 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",
)

View 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()

View 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()

View File

@@ -29,9 +29,11 @@ def execute():
""") """)
for item_code, warehouse in repost_for: for item_code, warehouse in repost_for:
update_bin_qty(item_code, warehouse, { if not (item_code and warehouse):
"reserved_qty": get_reserved_qty(item_code, warehouse) continue
}) update_bin_qty(item_code, warehouse, {
"reserved_qty": get_reserved_qty(item_code, warehouse)
})
frappe.db.sql("""delete from tabBin frappe.db.sql("""delete from tabBin
where exists( where exists(

View File

@@ -14,6 +14,8 @@ def execute():
union union
select item_code, warehouse from `tabStock Ledger Entry`) a"""): select item_code, warehouse from `tabStock Ledger Entry`) a"""):
try: try:
if not (item_code and warehouse):
continue
count += 1 count += 1
update_bin_qty(item_code, warehouse, { update_bin_qty(item_code, warehouse, {
"indented_qty": get_indented_qty(item_code, warehouse), "indented_qty": get_indented_qty(item_code, warehouse),

View File

@@ -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) """ % 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): def remove_payrolled_employees(emp_list, start_date, end_date):
new_emp_list = []
for employee_details in 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}): if not frappe.db.exists("Salary Slip", {"employee": employee_details.employee, "start_date": start_date, "end_date": end_date, "docstatus": 1}):
emp_list.remove(employee_details) new_emp_list.append(employee_details)
return emp_list return new_emp_list
@frappe.whitelist() @frappe.whitelist()
def get_start_end_dates(payroll_frequency, start_date=None, company=None): def get_start_end_dates(payroll_frequency, start_date=None, company=None):

View File

@@ -125,7 +125,7 @@ class TestPayrollEntry(unittest.TestCase):
if not frappe.db.exists("Account", "_Test Payroll Payable - _TC"): if not frappe.db.exists("Account", "_Test Payroll Payable - _TC"):
create_account(account_name="_Test Payroll Payable", 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 \ 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": frappe.db.get_value("Company", "_Test Company", "default_payroll_payable_account") != "_Test Payroll Payable - _TC":

View File

@@ -147,7 +147,7 @@ class TestSalarySlip(unittest.TestCase):
# Payroll based on attendance # Payroll based on attendance
frappe.db.set_value("Payroll Settings", None, "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"}) frappe.db.set_value("Employee", emp, {"relieving_date": None, "status": "Active"})
# mark attendance # mark attendance
@@ -731,7 +731,7 @@ def get_salary_component_account(sal_comp, company_list=None):
}) })
sal_comp.save() 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') company_abbr = frappe.get_cached_value('Company', company, 'abbr')
account = frappe.db.get_value("Account", account_name + " - " + company_abbr) account = frappe.db.get_value("Account", account_name + " - " + company_abbr)
if not account: if not account:

View File

@@ -58,6 +58,7 @@
"width": "50%" "width": "50%"
}, },
{ {
"allow_on_submit": 1,
"default": "Yes", "default": "Yes",
"fieldname": "is_active", "fieldname": "is_active",
"fieldtype": "Select", "fieldtype": "Select",
@@ -232,10 +233,11 @@
"idx": 1, "idx": 1,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2021-03-31 15:41:12.342380", "modified": "2022-02-03 23:50:10.205676",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Payroll", "module": "Payroll",
"name": "Salary Structure", "name": "Salary Structure",
"naming_rule": "Set by user",
"owner": "Administrator", "owner": "Administrator",
"permissions": [ "permissions": [
{ {
@@ -271,5 +273,6 @@
], ],
"show_name_in_global_search": 1, "show_name_in_global_search": 1,
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC" "sort_order": "DESC",
"states": []
} }

View File

@@ -1443,7 +1443,8 @@ erpnext.TransactionController = erpnext.taxes_and_totals.extend({
"item_code": d.item_code, "item_code": d.item_code,
"pricing_rules": d.pricing_rules, "pricing_rules": d.pricing_rules,
"parenttype": d.parenttype, "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() { coupon_code: function() {
var me = this; var me = this;
if (this.frm.doc.coupon_code) { frappe.run_serially([
frappe.run_serially([ () => this.frm.doc.ignore_pricing_rule=1,
() => this.frm.doc.ignore_pricing_rule=1, () => me.ignore_pricing_rule(),
() => me.ignore_pricing_rule(), () => this.frm.doc.ignore_pricing_rule=0,
() => this.frm.doc.ignore_pricing_rule=0, () => me.apply_pricing_rule()
() => me.apply_pricing_rule() ]);
]);
} else {
frappe.run_serially([
() => this.frm.doc.ignore_pricing_rule=1,
() => me.ignore_pricing_rule()
]);
}
} }
}); });

View File

@@ -296,6 +296,10 @@ class GSTR3BReport(Document):
inter_state_supply_details = {} inter_state_supply_details = {}
for inv, items_based_on_rate in self.items_based_on_tax_rate.items(): 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 rate, items in items_based_on_rate.items():
for item_code, taxable_value in self.invoice_items.get(inv).items(): for item_code, taxable_value in self.invoice_items.get(inv).items():
if item_code in items: if item_code in items:
@@ -303,9 +307,8 @@ class GSTR3BReport(Document):
self.report_dict['sup_details']['osup_nil_exmp']['txval'] += taxable_value self.report_dict['sup_details']['osup_nil_exmp']['txval'] += taxable_value
elif item_code in self.is_non_gst: elif item_code in self.is_non_gst:
self.report_dict['sup_details']['osup_nongst']['txval'] += taxable_value 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']['txval'] += taxable_value
#self.report_dict['sup_details']['osup_zero'][key] += tax_amount
else: else:
if inv in self.cgst_sgst_invoices: if inv in self.cgst_sgst_invoices:
tax_rate = rate/2 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']['iamt'] += (taxable_value * rate /100)
self.report_dict['sup_details']['osup_det']['txval'] += taxable_value 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 \ if gst_category in ['Unregistered', 'Registered Composition', 'UIN Holders'] and \
self.gst_details.get("gst_state") != place_of_supply.split("-")[1]: self.gst_details.get("gst_state") != place_of_supply.split("-")[1]:
inter_state_supply_details.setdefault((gst_category, place_of_supply), { inter_state_supply_details.setdefault((gst_category, place_of_supply), {

View File

@@ -40,7 +40,11 @@ frappe.query_reports["DATEV"] = {
}); });
query_report.page.add_menu_item(__("Download DATEV File"), () => { 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}`); window.open(`/api/method/erpnext.regional.report.datev.datev.download_datev_csv?filters=${filters}`);
}); });

View File

@@ -29,7 +29,7 @@ class Gstr1Report(object):
posting_date, posting_date,
base_grand_total, base_grand_total,
base_rounded_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, place_of_supply,
ecommerce_gstin, ecommerce_gstin,
reverse_charge, reverse_charge,
@@ -260,7 +260,7 @@ class Gstr1Report(object):
if self.filters.get("type_of_business") == "B2B": 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"): if self.filters.get("type_of_business") in ("B2C Large", "B2C Small"):
b2c_limit = frappe.db.get_single_value('GST Settings', 'b2c_limit') 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): 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 \ 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('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()) self.items_based_on_tax_rate.setdefault(invoice, {}).setdefault(0, items.keys())
def get_columns(self): def get_columns(self):
@@ -410,7 +410,7 @@ class Gstr1Report(object):
if self.filters.get("type_of_business") == "B2B": if self.filters.get("type_of_business") == "B2B":
self.invoice_columns = [ self.invoice_columns = [
{ {
"fieldname": "customer_gstin", "fieldname": "billing_address_gstin",
"label": "GSTIN/UIN of Recipient", "label": "GSTIN/UIN of Recipient",
"fieldtype": "Data", "fieldtype": "Data",
"width": 150 "width": 150
@@ -517,7 +517,7 @@ class Gstr1Report(object):
elif self.filters.get("type_of_business") == "CDNR-REG": elif self.filters.get("type_of_business") == "CDNR-REG":
self.invoice_columns = [ self.invoice_columns = [
{ {
"fieldname": "customer_gstin", "fieldname": "billing_address_gstin",
"label": "GSTIN/UIN of Recipient", "label": "GSTIN/UIN of Recipient",
"fieldtype": "Data", "fieldtype": "Data",
"width": 150 "width": 150
@@ -818,7 +818,7 @@ def get_json(filters, report_name, data):
res = {} res = {}
if filters["type_of_business"] == "B2B": if filters["type_of_business"] == "B2B":
for item in report_data[:-1]: 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) out = get_b2b_json(res, gstin)
gst_json["b2b"] = out gst_json["b2b"] = out
@@ -842,7 +842,7 @@ def get_json(filters, report_name, data):
gst_json["exp"] = out gst_json["exp"] = out
elif filters["type_of_business"] == "CDNR-REG": elif filters["type_of_business"] == "CDNR-REG":
for item in report_data[:-1]: 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) out = get_cdnr_reg_json(res, gstin)
gst_json["cdnr"] = out gst_json["cdnr"] = out
@@ -876,7 +876,7 @@ def get_json(filters, report_name, data):
} }
def get_b2b_json(res, gstin): 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: for gst_in in res:
b2b_item, inv = {"ctin": gst_in, "inv": []}, [] b2b_item, inv = {"ctin": gst_in, "inv": []}, []
if not gst_in: continue 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 = get_basic_invoice_detail(invoice[0])
inv_item["pos"] = "%02d" % int(invoice[0]["place_of_supply"].split('-')[0]) inv_item["pos"] = "%02d" % int(invoice[0]["place_of_supply"].split('-')[0])
inv_item["rchrg"] = invoice[0]["reverse_charge"] 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 if inv_item["pos"]=="00": continue
inv_item["itms"] = [] inv_item["itms"] = []
@@ -1045,7 +1045,7 @@ def get_cdnr_reg_json(res, gstin):
"ntty": invoice[0]["document_type"], "ntty": invoice[0]["document_type"],
"pos": "%02d" % int(invoice[0]["place_of_supply"].split('-')[0]), "pos": "%02d" % int(invoice[0]["place_of_supply"].split('-')[0]),
"rchrg": invoice[0]["reverse_charge"], "rchrg": invoice[0]["reverse_charge"],
"inv_typ": get_invoice_type_for_cdnr(invoice[0]) "inv_typ": get_invoice_type(invoice[0])
} }
inv_item["itms"] = [] inv_item["itms"] = []
@@ -1070,7 +1070,7 @@ def get_cdnr_unreg_json(res, gstin):
"val": abs(flt(items[0]["invoice_value"])), "val": abs(flt(items[0]["invoice_value"])),
"ntty": items[0]["document_type"], "ntty": items[0]["document_type"],
"pos": "%02d" % int(items[0]["place_of_supply"].split('-')[0]), "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"] = [] inv_item["itms"] = []
@@ -1111,29 +1111,21 @@ def get_exempted_json(data):
return out return out
def get_invoice_type_for_cdnr(row): def get_invoice_type(row):
if row.get('gst_category') == 'SEZ': gst_category = row.get('gst_category')
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'
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 gst_category == 'Overseas':
if row.get('gst_category') == 'Overseas': return 'EXPWP' if row.get('export_type') == 'WPAY' else 'EXPWOP'
if row.get('export_type') == 'WPAY':
invoice_type = 'EXPWP'
else:
invoice_type = 'EXPWOP'
elif row.get('gst_category') == 'Unregistered':
invoice_type = 'B2CL'
return invoice_type return ({
'Deemed Export': 'DE',
'Registered Regular': 'R',
'Registered Composition': 'R',
'Unregistered': 'B2CL'
}).get(gst_category)
def get_basic_invoice_detail(row): def get_basic_invoice_detail(row):
return { return {
@@ -1155,7 +1147,7 @@ def get_rate_and_tax_details(row, gstin):
# calculate tax amount added # calculate tax amount added
tax = flt((row["taxable_value"]*rate)/100.0, 2) tax = flt((row["taxable_value"]*rate)/100.0, 2)
frappe.errprint([tax, tax/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)}) itm_det.update({"camt": flt(tax/2.0, 2), "samt": flt(tax/2.0, 2)})
else: else:
itm_det.update({"iamt": tax}) itm_det.update({"iamt": tax})

View File

@@ -6,7 +6,7 @@ import json
import frappe import frappe
import frappe.permissions import frappe.permissions
from frappe.core.doctype.user_permission.test_user_permission import create_user 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.controllers.accounts_controller import update_child_qty_rate
from erpnext.manufacturing.doctype.blanket_order.test_blanket_order import make_blanket_order 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) 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): def automatically_fetch_payment_terms(enable=1):
accounts_settings = frappe.get_doc("Accounts Settings") accounts_settings = frappe.get_doc("Accounts Settings")
accounts_settings.automatically_fetch_payment_terms = enable accounts_settings.automatically_fetch_payment_terms = enable

View File

@@ -83,8 +83,8 @@
"planned_qty", "planned_qty",
"column_break_69", "column_break_69",
"work_order_qty", "work_order_qty",
"delivered_qty",
"produced_qty", "produced_qty",
"delivered_qty",
"returned_qty", "returned_qty",
"shopping_cart_section", "shopping_cart_section",
"additional_notes", "additional_notes",
@@ -701,10 +701,8 @@
"width": "50px" "width": "50px"
}, },
{ {
"description": "For Production",
"fieldname": "produced_qty", "fieldname": "produced_qty",
"fieldtype": "Float", "fieldtype": "Float",
"hidden": 1,
"label": "Produced Quantity", "label": "Produced Quantity",
"oldfieldname": "produced_qty", "oldfieldname": "produced_qty",
"oldfieldtype": "Currency", "oldfieldtype": "Currency",
@@ -802,7 +800,7 @@
"idx": 1, "idx": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2021-10-05 12:27:25.014789", "modified": "2022-02-21 13:55:08.883104",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Selling", "module": "Selling",
"name": "Sales Order Item", "name": "Sales Order Item",
@@ -811,5 +809,6 @@
"permissions": [], "permissions": [],
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [],
"track_changes": 1 "track_changes": 1
} }

View File

@@ -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"], ["name as item_code", "item_name", "description", "stock_uom", "image as item_image", "is_stock_item"],
as_dict=1) 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_rate, currency = frappe.db.get_value('Item Price', {
'price_list': price_list, 'price_list': price_list,
'item_code': item_code '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) ), {'warehouse': warehouse}, as_dict=1)
if items_data: if items_data:
items_data = filter_service_items(items_data)
items = [d.item_code for d in items_data] items = [d.item_code for d in items_data]
item_prices_data = frappe.get_all("Item Price", item_prices_data = frappe.get_all("Item Price",
fields = ["item_code", "price_list_rate", "currency"], 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: for item in items_data:
item_code = item.item_code item_code = item.item_code
item_price = item_prices.get(item_code) or {} 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 = {}
row.update(item) row.update(item)
@@ -144,14 +143,6 @@ def search_for_serial_or_batch_or_barcode_number(search_value):
return {} 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): def get_conditions(search_term):
condition = "(" condition = "("
condition += """item.name like {search_term} condition += """item.name like {search_term}

View File

@@ -248,7 +248,7 @@ erpnext.PointOfSale.Controller = class {
numpad_event: (value, action) => this.update_item_field(value, action), 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(), edit_cart: () => this.payment.edit_cart(),
@@ -630,18 +630,24 @@ erpnext.PointOfSale.Controller = class {
} }
async check_stock_availability(item_row, qty_needed, warehouse) { 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(); frappe.dom.unfreeze();
const bold_item_code = item_row.item_code.bold(); const bold_item_code = item_row.item_code.bold();
const bold_warehouse = warehouse.bold(); const bold_warehouse = warehouse.bold();
const bold_available_qty = available_qty.toString().bold() const bold_available_qty = available_qty.toString().bold()
if (!(available_qty > 0)) { if (!(available_qty > 0)) {
frappe.model.clear_doc(item_row.doctype, item_row.name); if (is_stock_item) {
frappe.throw({ frappe.model.clear_doc(item_row.doctype, item_row.name);
title: __("Not Available"), frappe.throw({
message: __('Item Code: {0} is not available under warehouse {1}.', [bold_item_code, bold_warehouse]) 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) { } else if (available_qty < qty_needed) {
frappe.throw({ 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]), 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) { callback(res) {
if (!me.item_stock_map[item_code]) if (!me.item_stock_map[item_code])
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][warehouse] = res.message[0];
} }
}); });
} }
@@ -707,4 +713,9 @@ erpnext.PointOfSale.Controller = class {
}) })
.catch(e => console.log(e)); .catch(e => console.log(e));
} }
async save_and_checkout() {
this.frm.is_dirty() && await this.frm.save();
this.payment.checkout();
}
}; };

View File

@@ -191,10 +191,10 @@ erpnext.PointOfSale.ItemCart = class {
this.numpad_value = ''; 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; if ($(this).attr('style').indexOf('--blue-500') == -1) return;
me.events.checkout(); await me.events.checkout();
me.toggle_checkout_btn(false); me.toggle_checkout_btn(false);
me.allow_discount_change && me.$add_discount_elem.removeClass("d-none"); 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).off('refresh-fields');
$(frm.wrapper).on('refresh-fields', () => { $(frm.wrapper).on('refresh-fields', () => {
if (frm.doc.items.length) { if (frm.doc.items.length) {
this.$cart_items_wrapper.html('');
frm.doc.items.forEach(item => { frm.doc.items.forEach(item => {
this.update_item_html(item); this.update_item_html(item);
}); });

View File

@@ -79,14 +79,20 @@ erpnext.PointOfSale.ItemSelector = class {
const me = this; const me = this;
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
const { item_image, serial_no, batch_no, barcode, actual_qty, stock_uom, price_list_rate } = item; 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; const precision = flt(price_list_rate, 2) % 1 != 0 ? 2 : 0;
let indicator_color;
let qty_to_display = actual_qty; let qty_to_display = actual_qty;
if (Math.round(qty_to_display) > 999) { if (item.is_stock_item) {
qty_to_display = Math.round(qty_to_display)/1000; indicator_color = (actual_qty > 10 ? "green" : actual_qty <= 0 ? "red" : "orange");
qty_to_display = qty_to_display.toFixed(1) + 'K';
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() { function get_item_image_html() {

View File

@@ -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.setup_listener_for_payments();
this.$payment_modes.on('click', '.shortcut', function() { this.$payment_modes.on('click', '.shortcut', function() {

View File

@@ -227,11 +227,11 @@ erpnext.selling.SellingController = erpnext.TransactionController.extend({
}, },
callback:function(r){ callback:function(r){
if (in_list(['Delivery Note', 'Sales Invoice'], doc.doctype)) { if (in_list(['Delivery Note', 'Sales Invoice'], doc.doctype)) {
if (doc.doctype === 'Sales Invoice' && (!doc.update_stock)) return; if (doc.doctype === 'Sales Invoice' && (!doc.update_stock)) return;
if (has_batch_no) {
me.set_batch_number(cdt, cdn); me.set_batch_number(cdt, cdn);
me.batch_no(doc, cdt, cdn); me.batch_no(doc, cdt, cdn);
}
} }
} }
}); });

View File

@@ -31,23 +31,9 @@ class Bin(Document):
def update_reserved_qty_for_production(self): def update_reserved_qty_for_production(self):
'''Update qty reserved for production from Production Item tables '''Update qty reserved for production from Production Item tables
in open work orders''' in open work orders'''
self.reserved_qty_for_production = frappe.db.sql(''' from erpnext.manufacturing.doctype.work_order.work_order import get_reserved_qty_for_production
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]
self.reserved_qty_for_production = get_reserved_qty_for_production(self.item_code, self.warehouse)
self.set_projected_qty() self.set_projected_qty()
self.db_set('reserved_qty_for_production', flt(self.reserved_qty_for_production)) self.db_set('reserved_qty_for_production', flt(self.reserved_qty_for_production))

View File

@@ -339,17 +339,31 @@ class DeliveryNote(SellingController):
frappe.throw(_("Could not create Credit Note automatically, please uncheck 'Issue Credit Note' and submit again")) 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): 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 Sales Order directly
billed_against_so = frappe.db.sql("""select sum(amount) from `tabSales Invoice Item` si_item = frappe.qb.DocType("Sales Invoice Item").as_("si_item")
where so_detail=%s and (dn_detail is null or dn_detail = '') and docstatus=1""", so_detail) 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 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 # 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 dn = frappe.qb.DocType("Delivery Note").as_("dn")
from `tabDelivery Note Item` dn_item, `tabDelivery Note` dn dn_item = frappe.qb.DocType("Delivery Note Item").as_("dn_item")
where dn.name=dn_item.parent and dn_item.so_detail=%s
and dn.docstatus=1 and dn.is_return = 0 dn_details = frappe.qb.from_(dn).from_(dn_item).select(dn_item.name, dn_item.amount, dn_item.si_detail, dn_item.parent).where(
order by dn.posting_date asc, dn.posting_time asc, dn.name asc""", so_detail, as_dict=1) (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 = [] updated_dn = []
for dnd in dn_details: for dnd in dn_details:

View File

@@ -545,7 +545,7 @@ $.extend(erpnext.item, {
let selected_attributes = {}; let selected_attributes = {};
me.multiple_variant_dialog.$wrapper.find('.form-column').each((i, col) => { me.multiple_variant_dialog.$wrapper.find('.form-column').each((i, col) => {
if(i===0) return; if(i===0) return;
let attribute_name = $(col).find('label').html(); let attribute_name = $(col).find('label').html().trim();
selected_attributes[attribute_name] = []; selected_attributes[attribute_name] = [];
let checked_opts = $(col).find('.checkbox input'); let checked_opts = $(col).find('.checkbox input');
checked_opts.each((i, opt) => { checked_opts.each((i, opt) => {

View File

@@ -4,10 +4,11 @@
import frappe 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.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.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.assets.doctype.asset.test_asset import create_asset_category, create_fixed_asset_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import ( from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import (
get_gl_entries, get_gl_entries,
@@ -28,7 +29,8 @@ class TestLandedCostVoucher(ERPNextTestCase):
"voucher_type": pr.doctype, "voucher_type": pr.doctype,
"voucher_no": pr.name, "voucher_no": pr.name,
"item_code": "_Test Item", "item_code": "_Test Item",
"warehouse": "Stores - TCP1" "warehouse": "Stores - TCP1",
"is_cancelled": 0,
}, },
fieldname=["qty_after_transaction", "stock_value"], as_dict=1) fieldname=["qty_after_transaction", "stock_value"], as_dict=1)
@@ -41,14 +43,39 @@ class TestLandedCostVoucher(ERPNextTestCase):
"voucher_type": pr.doctype, "voucher_type": pr.doctype,
"voucher_no": pr.name, "voucher_no": pr.name,
"item_code": "_Test Item", "item_code": "_Test Item",
"warehouse": "Stores - TCP1" "warehouse": "Stores - TCP1",
"is_cancelled": 0,
}, },
fieldname=["qty_after_transaction", "stock_value"], as_dict=1) 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.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) 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) gl_entries = get_gl_entries("Purchase Receipt", pr.name)
self.assertTrue(gl_entries) self.assertTrue(gl_entries)
@@ -74,8 +101,8 @@ class TestLandedCostVoucher(ERPNextTestCase):
for gle in gl_entries: for gle in gl_entries:
if not gle.get('is_cancelled'): if not gle.get('is_cancelled'):
self.assertEqual(expected_values[gle.account][0], gle.debit) 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) 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): def test_landed_cost_voucher_against_purchase_invoice(self):

View File

@@ -534,6 +534,7 @@ def raise_work_orders(material_request):
"stock_uom": d.stock_uom, "stock_uom": d.stock_uom,
"expected_delivery_date": d.schedule_date, "expected_delivery_date": d.schedule_date,
"sales_order": d.sales_order, "sales_order": d.sales_order,
"sales_order_item": d.get("sales_order_item"),
"bom_no": get_item_details(d.item_code).bom_no, "bom_no": get_item_details(d.item_code).bom_no,
"material_request": mr.name, "material_request": mr.name,
"material_request_item": d.name, "material_request_item": d.name,

View File

@@ -1,10 +1,14 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt # 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.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.sales_order import make_delivery_note
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order 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.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 from erpnext.tests.utils import ERPNextTestCase, change_settings
@@ -12,31 +16,30 @@ class TestPackedItem(ERPNextTestCase):
"Test impact on Packed Items table in various scenarios." "Test impact on Packed Items table in various scenarios."
@classmethod @classmethod
def setUpClass(cls) -> None: def setUpClass(cls) -> None:
make_item("_Test Product Bundle X", {"is_stock_item": 0}) super().setUpClass()
make_item("_Test Bundle Item 1", {"is_stock_item": 1}) cls.bundle = "_Test Product Bundle X"
make_item("_Test Bundle Item 2", {"is_stock_item": 1}) 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_item("_Test Normal Stock Item", {"is_stock_item": 1})
make_product_bundle( make_product_bundle(cls.bundle, cls.bundle_items, qty=2)
"_Test Product Bundle X",
["_Test Bundle Item 1", "_Test Bundle Item 2"],
qty=2
)
def test_adding_bundle_item(self): def test_adding_bundle_item(self):
"Test impact on packed items if bundle item row is added." "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) do_not_submit=True)
self.assertEqual(so.items[0].qty, 1) self.assertEqual(so.items[0].qty, 1)
self.assertEqual(len(so.packed_items), 2) 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) self.assertEqual(so.packed_items[0].qty, 2)
def test_updating_bundle_item(self): def test_updating_bundle_item(self):
"Test impact on packed items if bundle item row is updated." "Test impact on packed items if bundle item row is updated."
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)
do_not_submit=True)
so.items[0].qty = 2 # change qty so.items[0].qty = 2 # change qty
so.save() so.save()
@@ -55,7 +58,7 @@ class TestPackedItem(ERPNextTestCase):
so_items = [] so_items = []
for qty in [2, 4, 6, 8]: for qty in [2, 4, 6, 8]:
so_items.append({ so_items.append({
"item_code": "_Test Product Bundle X", "item_code": self.bundle,
"qty": qty, "qty": qty,
"rate": 400, "rate": 400,
"warehouse": "_Test Warehouse - _TC" "warehouse": "_Test Warehouse - _TC"
@@ -66,7 +69,7 @@ class TestPackedItem(ERPNextTestCase):
# check alternate rows for qty # check alternate rows for qty
self.assertEqual(len(so.packed_items), 8) 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[1].qty, 4)
self.assertEqual(so.packed_items[3].qty, 8) self.assertEqual(so.packed_items[3].qty, 8)
self.assertEqual(so.packed_items[5].qty, 12) self.assertEqual(so.packed_items[5].qty, 12)
@@ -94,8 +97,7 @@ class TestPackedItem(ERPNextTestCase):
@change_settings("Selling Settings", {"editable_bundle_item_rates": 1}) @change_settings("Selling Settings", {"editable_bundle_item_rates": 1})
def test_bundle_item_cumulative_price(self): def test_bundle_item_cumulative_price(self):
"Test if Bundle Item rate is cumulative from packed items." "Test if Bundle Item rate is cumulative from packed items."
so = make_sales_order(item_code = "_Test Product Bundle X", qty=2, so = make_sales_order(item_code=self.bundle, qty=2, do_not_submit=True)
do_not_submit=True)
so.packed_items[0].rate = 150 so.packed_items[0].rate = 150
so.packed_items[1].rate = 200 so.packed_items[1].rate = 200
@@ -109,7 +111,7 @@ class TestPackedItem(ERPNextTestCase):
so_items = [] so_items = []
for qty in [2, 4]: for qty in [2, 4]:
so_items.append({ so_items.append({
"item_code": "_Test Product Bundle X", "item_code": self.bundle,
"qty": qty, "qty": qty,
"rate": 400, "rate": 400,
"warehouse": "_Test Warehouse - _TC" "warehouse": "_Test Warehouse - _TC"
@@ -125,3 +127,32 @@ class TestPackedItem(ERPNextTestCase):
self.assertEqual(len(dn.packed_items), 4) self.assertEqual(len(dn.packed_items), 4)
self.assertEqual(dn.packed_items[2].qty, 6) 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)

View File

@@ -281,10 +281,7 @@ class PurchaseReceipt(BuyingController):
if warehouse_account.get(d.warehouse): if warehouse_account.get(d.warehouse):
stock_value_diff = frappe.db.get_value("Stock Ledger Entry", stock_value_diff = frappe.db.get_value("Stock Ledger Entry",
{"voucher_type": "Purchase Receipt", "voucher_no": self.name, {"voucher_type": "Purchase Receipt", "voucher_no": self.name,
"voucher_detail_no": d.name, "warehouse": d.warehouse}, "stock_value_difference") "voucher_detail_no": d.name, "warehouse": d.warehouse, "is_cancelled": 0}, "stock_value_difference")
if not stock_value_diff:
continue
warehouse_account_name = warehouse_account[d.warehouse]["account"] warehouse_account_name = warehouse_account[d.warehouse]["account"]
warehouse_account_currency = warehouse_account[d.warehouse]["account_currency"] warehouse_account_currency = warehouse_account[d.warehouse]["account_currency"]

View File

@@ -4,6 +4,7 @@
import json import json
import unittest import unittest
from collections import defaultdict
import frappe import frappe
from frappe.utils import add_days, cint, cstr, flt, today 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.serial_no.serial_no import SerialNoDuplicateError, get_serial_nos
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
from erpnext.stock.stock_ledger import SerialNoExistsInFutureTransaction from erpnext.stock.stock_ledger import SerialNoExistsInFutureTransaction
from erpnext.tests.utils import ERPNextTestCase from erpnext.tests.utils import ERPNextTestCase, change_settings
class TestPurchaseReceipt(ERPNextTestCase): class TestPurchaseReceipt(ERPNextTestCase):
@@ -1367,6 +1368,36 @@ class TestPurchaseReceipt(ERPNextTestCase):
automatically_fetch_payment_terms(enable=0) 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): def get_sl_entries(voucher_type, voucher_no):
return frappe.db.sql(""" select actual_qty, warehouse, stock_value_difference return frappe.db.sql(""" select actual_qty, warehouse, stock_value_difference
from `tabStock Ledger Entry` where voucher_type=%s and voucher_no=%s from `tabStock Ledger Entry` where voucher_type=%s and voucher_no=%s

View File

@@ -13,7 +13,7 @@ from erpnext.accounts.utils import (
check_if_stock_and_account_balance_synced, check_if_stock_and_account_balance_synced,
update_gl_entries_after, 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): class RepostItemValuation(Document):
@@ -138,13 +138,20 @@ def repost_gl_entries(doc):
if doc.based_on == 'Transaction': if doc.based_on == 'Transaction':
ref_doc = frappe.get_doc(doc.voucher_type, doc.voucher_no) 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: else:
items = [doc.item_code] items = [doc.item_code]
warehouses = [doc.warehouse] warehouses = [doc.warehouse]
update_gl_entries_after(doc.posting_date, doc.posting_time, 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): def notify_error_to_stock_managers(doc, traceback):
recipients = get_users_with_role("Stock Manager") recipients = get_users_with_role("Stock Manager")

View File

@@ -8,7 +8,6 @@
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"items_section", "items_section",
"title",
"naming_series", "naming_series",
"stock_entry_type", "stock_entry_type",
"outgoing_stock_entry", "outgoing_stock_entry",
@@ -83,14 +82,6 @@
"fieldtype": "Section Break", "fieldtype": "Section Break",
"oldfieldtype": "Section Break" "oldfieldtype": "Section Break"
}, },
{
"fieldname": "title",
"fieldtype": "Data",
"hidden": 1,
"label": "Title",
"no_copy": 1,
"print_hide": 1
},
{ {
"fieldname": "naming_series", "fieldname": "naming_series",
"fieldtype": "Select", "fieldtype": "Select",
@@ -353,9 +344,9 @@
}, },
{ {
"fieldname": "scan_barcode", "fieldname": "scan_barcode",
"options": "Barcode",
"fieldtype": "Data", "fieldtype": "Data",
"label": "Scan Barcode" "label": "Scan Barcode",
"options": "Barcode"
}, },
{ {
"allow_bulk_edit": 1, "allow_bulk_edit": 1,
@@ -628,10 +619,11 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2021-08-20 19:19:31.514846", "modified": "2022-02-07 12:55:14.614077",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Stock Entry", "name": "Stock Entry",
"naming_rule": "By \"Naming Series\" field",
"owner": "Administrator", "owner": "Administrator",
"permissions": [ "permissions": [
{ {
@@ -698,6 +690,7 @@
"show_name_in_global_search": 1, "show_name_in_global_search": 1,
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"title_field": "title", "states": [],
"title_field": "stock_entry_type",
"track_changes": 1 "track_changes": 1
} }

View File

@@ -77,7 +77,6 @@ class StockEntry(StockController):
self.validate_posting_time() self.validate_posting_time()
self.validate_purpose() self.validate_purpose()
self.set_title()
self.validate_item() self.validate_item()
self.validate_customer_provided_item() self.validate_customer_provided_item()
self.validate_qty() self.validate_qty()
@@ -1117,7 +1116,7 @@ class StockEntry(StockController):
self.set_actual_qty() self.set_actual_qty()
self.update_items_for_process_loss() self.update_items_for_process_loss()
self.validate_customer_provided_item() 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): def set_scrap_items(self):
if self.purpose != "Send to Subcontractor" and self.purpose in ["Manufacture", "Repack"]: 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))) 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() @frappe.whitelist()
def move_sample_to_retention_warehouse(company, items): def move_sample_to_retention_warehouse(company, items):
if isinstance(items, string_types): if isinstance(items, string_types):

View File

@@ -18,7 +18,6 @@
"items", "items",
"section_break_9", "section_break_9",
"expense_account", "expense_account",
"reconciliation_json",
"column_break_13", "column_break_13",
"difference_amount", "difference_amount",
"amended_from", "amended_from",
@@ -111,15 +110,6 @@
"label": "Cost Center", "label": "Cost Center",
"options": "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", "fieldname": "column_break_13",
"fieldtype": "Column Break" "fieldtype": "Column Break"
@@ -155,7 +145,7 @@
"idx": 1, "idx": 1,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2021-11-30 01:33:51.437194", "modified": "2022-02-06 14:28:19.043905",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Stock Reconciliation", "name": "Stock Reconciliation",
@@ -178,5 +168,6 @@
"search_fields": "posting_date", "search_fields": "posting_date",
"show_name_in_global_search": 1, "show_name_in_global_search": 1,
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC" "sort_order": "DESC",
"states": []
} }

View File

@@ -25,8 +25,8 @@ from erpnext.tests.utils import ERPNextTestCase, change_settings
class TestStockReconciliation(ERPNextTestCase): class TestStockReconciliation(ERPNextTestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
super().setUpClass()
create_batch_or_serial_no_items() create_batch_or_serial_no_items()
super().setUpClass()
frappe.db.set_value("Stock Settings", None, "allow_negative_stock", 1) frappe.db.set_value("Stock Settings", None, "allow_negative_stock", 1)
def tearDown(self): def tearDown(self):

View File

@@ -344,6 +344,7 @@ def get_basic_details(args, item, overwrite_warehouse=True):
args.conversion_factor = out.conversion_factor args.conversion_factor = out.conversion_factor
out.stock_qty = out.qty * out.conversion_factor out.stock_qty = out.qty * out.conversion_factor
args.stock_qty = out.stock_qty
# calculate last purchase rate # calculate last purchase rate
if args.get('doctype') in purchase_doctypes: if args.get('doctype') in purchase_doctypes:

View File

@@ -12,6 +12,7 @@ from frappe.utils import cint, date_diff, flt
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
Filters = frappe._dict Filters = frappe._dict
precision = cint(frappe.db.get_single_value("System Settings", "float_precision"))
def execute(filters: Filters = None) -> Tuple: def execute(filters: Filters = None) -> Tuple:
to_date = filters["to_date"] 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"): if filters.get("show_warehouse_wise_stock"):
row.append(details.warehouse) 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, range1, range2, range3, above_range3,
earliest_age, latest_age, earliest_age, latest_age,
details.stock_uom]) details.stock_uom
])
data.append(row) 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 qty = flt(item[0]) if not item_dict["has_serial_no"] else 1.0
if age <= filters.range1: if age <= filters.range1:
range1 += qty range1 = flt(range1 + qty, precision)
elif age <= filters.range2: elif age <= filters.range2:
range2 += qty range2 = flt(range2 + qty, precision)
elif age <= filters.range3: elif age <= filters.range3:
range3 += qty range3 = flt(range3 + qty, precision)
else: else:
above_range3 += qty above_range3 = flt(above_range3 + qty, precision)
return range1, range2, range3, above_range3 return range1, range2, range3, above_range3
@@ -252,6 +256,7 @@ class FIFOSlots:
key, fifo_queue, transferred_item_key = self.__init_key_stores(d) key, fifo_queue, transferred_item_key = self.__init_key_stores(d)
if d.voucher_type == "Stock Reconciliation": 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) prev_balance_qty = self.item_details[key].get("qty_after_transaction", 0)
d.actual_qty = flt(d.qty_after_transaction) - flt(prev_balance_qty) d.actual_qty = flt(d.qty_after_transaction) - flt(prev_balance_qty)
@@ -264,12 +269,16 @@ class FIFOSlots:
self.__update_balances(d, key) 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 return self.item_details
def __init_key_stores(self, row: Dict) -> Tuple: def __init_key_stores(self, row: Dict) -> Tuple:
"Initialise keys and FIFO Queue." "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": []}) self.item_details.setdefault(key, {"details": row, "fifo_queue": []})
fifo_queue = self.item_details[key]["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): def __compute_incoming_stock(self, row: Dict, fifo_queue: List, transfer_key: Tuple, serial_nos: List):
"Update FIFO Queue on inward stock." "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 # inward/outward from same voucher, item & warehouse
slot = self.transferred_item_details[transfer_key].pop(0) # eg: Repack with same item, Stock reco for batch item
fifo_queue.append(slot) # consume transfer data and add stock to fifo queue
self.__adjust_incoming_transfer_qty(transfer_data, fifo_queue, row)
else: else:
if not serial_nos: if not serial_nos:
if fifo_queue and flt(fifo_queue[0][0]) < 0: if fifo_queue and flt(fifo_queue[0][0]) <= 0:
# neutralize negative stock by adding positive stock # neutralize 0/negative stock by adding positive stock
fifo_queue[0][0] += flt(row.actual_qty) fifo_queue[0][0] += flt(row.actual_qty)
fifo_queue[0][1] = row.posting_date fifo_queue[0][1] = row.posting_date
else: else:
@@ -319,7 +330,7 @@ class FIFOSlots:
elif not fifo_queue: elif not fifo_queue:
# negative stock, no balance but qty yet to consume # negative stock, no balance but qty yet to consume
fifo_queue.append([-(qty_to_pop), row.posting_date]) 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 qty_to_pop = 0
else: else:
# qty to pop < slot qty, ample balance # 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]]) self.transferred_item_details[transfer_key].append([qty_to_pop, slot[1]])
qty_to_pop = 0 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]): def __update_balances(self, row: Dict, key: Union[Tuple, str]):
self.item_details[key]["qty_after_transaction"] = row.qty_after_transaction 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 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]: def __get_stock_ledger_entries(self) -> List[Dict]:
sle = frappe.qb.DocType("Stock Ledger Entry") sle = frappe.qb.DocType("Stock Ledger Entry")
item = self.__get_item_query() # used as derived table in sle query item = self.__get_item_query() # used as derived table in sle query

View File

@@ -15,6 +15,7 @@ Here, the balance qty is 70.
50 qty is (today-the 1st) days old 50 qty is (today-the 1st) days old
20 qty is (today-the 2nd) 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 ### Calculation of FIFO Slots
#### Case 1: Outward from sufficient balance qty #### Case 1: Outward from sufficient balance qty
@@ -71,3 +72,38 @@ Date | Qty | Queue
3rd | +5 | [[-5, 3-12-2021]] 3rd | +5 | [[-5, 3-12-2021]]
4th | +10 | [[5, 4-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]] |

View File

@@ -3,7 +3,7 @@
import frappe 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 from erpnext.tests.utils import ERPNextTestCase
@@ -11,15 +11,17 @@ class TestStockAgeing(ERPNextTestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.filters = frappe._dict( self.filters = frappe._dict(
company="_Test Company", 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): 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 = [ sle = [
frappe._dict( frappe._dict(
name="Flask Item", name="Flask Item",
actual_qty=30, qty_after_transaction=30, actual_qty=30, qty_after_transaction=30,
warehouse="WH 1",
posting_date="2021-12-01", voucher_type="Stock Entry", posting_date="2021-12-01", voucher_type="Stock Entry",
voucher_no="001", voucher_no="001",
has_serial_no=False, serial_no=None has_serial_no=False, serial_no=None
@@ -27,6 +29,7 @@ class TestStockAgeing(ERPNextTestCase):
frappe._dict( frappe._dict(
name="Flask Item", name="Flask Item",
actual_qty=20, qty_after_transaction=50, actual_qty=20, qty_after_transaction=50,
warehouse="WH 1",
posting_date="2021-12-02", voucher_type="Stock Entry", posting_date="2021-12-02", voucher_type="Stock Entry",
voucher_no="002", voucher_no="002",
has_serial_no=False, serial_no=None has_serial_no=False, serial_no=None
@@ -34,6 +37,7 @@ class TestStockAgeing(ERPNextTestCase):
frappe._dict( frappe._dict(
name="Flask Item", name="Flask Item",
actual_qty=(-10), qty_after_transaction=40, actual_qty=(-10), qty_after_transaction=40,
warehouse="WH 1",
posting_date="2021-12-03", voucher_type="Stock Entry", posting_date="2021-12-03", voucher_type="Stock Entry",
voucher_no="003", voucher_no="003",
has_serial_no=False, serial_no=None has_serial_no=False, serial_no=None
@@ -50,11 +54,12 @@ class TestStockAgeing(ERPNextTestCase):
self.assertEqual(queue[0][0], 20.0) self.assertEqual(queue[0][0], 20.0)
def test_insufficient_balance(self): 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 = [ sle = [
frappe._dict( frappe._dict(
name="Flask Item", name="Flask Item",
actual_qty=(-30), qty_after_transaction=(-30), actual_qty=(-30), qty_after_transaction=(-30),
warehouse="WH 1",
posting_date="2021-12-01", voucher_type="Stock Entry", posting_date="2021-12-01", voucher_type="Stock Entry",
voucher_no="001", voucher_no="001",
has_serial_no=False, serial_no=None has_serial_no=False, serial_no=None
@@ -62,6 +67,7 @@ class TestStockAgeing(ERPNextTestCase):
frappe._dict( frappe._dict(
name="Flask Item", name="Flask Item",
actual_qty=20, qty_after_transaction=(-10), actual_qty=20, qty_after_transaction=(-10),
warehouse="WH 1",
posting_date="2021-12-02", voucher_type="Stock Entry", posting_date="2021-12-02", voucher_type="Stock Entry",
voucher_no="002", voucher_no="002",
has_serial_no=False, serial_no=None has_serial_no=False, serial_no=None
@@ -69,6 +75,7 @@ class TestStockAgeing(ERPNextTestCase):
frappe._dict( frappe._dict(
name="Flask Item", name="Flask Item",
actual_qty=20, qty_after_transaction=10, actual_qty=20, qty_after_transaction=10,
warehouse="WH 1",
posting_date="2021-12-03", voucher_type="Stock Entry", posting_date="2021-12-03", voucher_type="Stock Entry",
voucher_no="003", voucher_no="003",
has_serial_no=False, serial_no=None has_serial_no=False, serial_no=None
@@ -76,6 +83,7 @@ class TestStockAgeing(ERPNextTestCase):
frappe._dict( frappe._dict(
name="Flask Item", name="Flask Item",
actual_qty=10, qty_after_transaction=20, actual_qty=10, qty_after_transaction=20,
warehouse="WH 1",
posting_date="2021-12-03", voucher_type="Stock Entry", posting_date="2021-12-03", voucher_type="Stock Entry",
voucher_no="004", voucher_no="004",
has_serial_no=False, serial_no=None has_serial_no=False, serial_no=None
@@ -91,11 +99,16 @@ class TestStockAgeing(ERPNextTestCase):
self.assertEqual(queue[0][0], 10.0) self.assertEqual(queue[0][0], 10.0)
self.assertEqual(queue[1][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 = [ sle = [
frappe._dict( frappe._dict(
name="Flask Item", name="Flask Item",
actual_qty=30, qty_after_transaction=30, actual_qty=30, qty_after_transaction=30,
warehouse="WH 1",
posting_date="2021-12-01", voucher_type="Stock Entry", posting_date="2021-12-01", voucher_type="Stock Entry",
voucher_no="001", voucher_no="001",
has_serial_no=False, serial_no=None has_serial_no=False, serial_no=None
@@ -103,6 +116,7 @@ class TestStockAgeing(ERPNextTestCase):
frappe._dict( frappe._dict(
name="Flask Item", name="Flask Item",
actual_qty=0, qty_after_transaction=50, actual_qty=0, qty_after_transaction=50,
warehouse="WH 1",
posting_date="2021-12-02", voucher_type="Stock Reconciliation", posting_date="2021-12-02", voucher_type="Stock Reconciliation",
voucher_no="002", voucher_no="002",
has_serial_no=False, serial_no=None has_serial_no=False, serial_no=None
@@ -110,6 +124,7 @@ class TestStockAgeing(ERPNextTestCase):
frappe._dict( frappe._dict(
name="Flask Item", name="Flask Item",
actual_qty=(-10), qty_after_transaction=40, actual_qty=(-10), qty_after_transaction=40,
warehouse="WH 1",
posting_date="2021-12-03", voucher_type="Stock Entry", posting_date="2021-12-03", voucher_type="Stock Entry",
voucher_no="003", voucher_no="003",
has_serial_no=False, serial_no=None has_serial_no=False, serial_no=None
@@ -122,5 +137,477 @@ class TestStockAgeing(ERPNextTestCase):
queue = result["fifo_queue"] queue = result["fifo_queue"]
self.assertEqual(result["qty_after_transaction"], result["total_qty"]) 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[0][0], 20.0)
self.assertEqual(queue[1][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

View File

@@ -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": _("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": _("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": _("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 Type"), "fieldname": "voucher_type", "width": 110},
{"label": _("Voucher #"), "fieldname": "voucher_no", "fieldtype": "Dynamic Link", "options": "voucher_type", "width": 100}, {"label": _("Voucher #"), "fieldname": "voucher_no", "fieldtype": "Dynamic Link", "options": "voucher_type", "width": 100},
{"label": _("Batch"), "fieldname": "batch_no", "fieldtype": "Link", "options": "Batch", "width": 100}, {"label": _("Batch"), "fieldname": "batch_no", "fieldtype": "Link", "options": "Batch", "width": 100},

View File

@@ -3,7 +3,7 @@
import frappe 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.controllers.stock_controller import create_repost_item_valuation_entry
from erpnext.stock.utils import update_bin 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)) bin.set(field, flt(value))
mismatch = True mismatch = True
bin.modified = now()
if mismatch: if mismatch:
bin.set_projected_qty() bin.set_projected_qty()
bin.db_update() bin.db_update()

View 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)

View File

@@ -13,6 +13,12 @@ frappe.ui.form.on("Rename Tool", {
}, },
refresh: function(frm) { refresh: function(frm) {
frm.disable_save(); frm.disable_save();
frm.get_field("file_to_rename").df.options = {
restrictions: {
allowed_file_types: [".csv"],
},
};
if (!frm.doc.file_to_rename) { if (!frm.doc.file_to_rename) {
frm.get_field("rename_log").$wrapper.html(""); frm.get_field("rename_log").$wrapper.html("");
} }