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

View File

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

View File

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

View File

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

View File

@@ -43,7 +43,6 @@ class POSInvoice(SalesInvoice):
self.validate_serialised_or_batched_item()
self.validate_stock_availablility()
self.validate_return_items_qty()
self.validate_non_stock_items()
self.set_status()
self.set_account_for_mode_of_payment()
self.validate_pos()
@@ -159,22 +158,39 @@ class POSInvoice(SalesInvoice):
frappe.throw(_("Row #{}: Serial No. {} has already been transacted into another Sales Invoice. Please select valid serial no.")
.format(item.idx, bold_delivered_serial_nos), title=_("Item Unavailable"))
def validate_invalid_serial_nos(self, item):
serial_nos = get_serial_nos(item.serial_no)
error_msg = []
invalid_serials, msg = "", ""
for serial_no in serial_nos:
if not frappe.db.exists('Serial No', serial_no):
invalid_serials = invalid_serials + (", " if invalid_serials else "") + serial_no
msg = (_("Row #{}: Following Serial numbers for item {} are <b>Invalid</b>: {}").format(item.idx, frappe.bold(item.get("item_code")), frappe.bold(invalid_serials)))
if invalid_serials:
error_msg.append(msg)
if error_msg:
frappe.throw(error_msg, title=_("Invalid Item"), as_list=True)
def validate_stock_availablility(self):
if self.is_return or self.docstatus != 1:
return
allow_negative_stock = frappe.db.get_single_value('Stock Settings', 'allow_negative_stock')
for d in self.get('items'):
is_service_item = not (frappe.db.get_value('Item', d.get('item_code'), 'is_stock_item'))
if is_service_item:
return
if d.serial_no:
self.validate_pos_reserved_serial_nos(d)
self.validate_delivered_serial_nos(d)
self.validate_invalid_serial_nos(d)
elif d.batch_no:
self.validate_pos_reserved_batch_qty(d)
else:
if allow_negative_stock:
return
available_stock = get_stock_availability(d.item_code, d.warehouse)
available_stock, is_stock_item = get_stock_availability(d.item_code, d.warehouse)
item_code, warehouse, qty = frappe.bold(d.item_code), frappe.bold(d.warehouse), frappe.bold(d.qty)
if flt(available_stock) <= 0:
@@ -245,14 +261,6 @@ class POSInvoice(SalesInvoice):
.format(d.idx, bold_serial_no, bold_return_against)
)
def validate_non_stock_items(self):
for d in self.get("items"):
is_stock_item = frappe.get_cached_value("Item", d.get("item_code"), "is_stock_item")
if not is_stock_item:
if not frappe.db.exists('Product Bundle', d.item_code):
frappe.throw(_("Row #{}: Item {} is a non stock item. You can only include stock items in a POS Invoice.")
.format(d.idx, frappe.bold(d.item_code)), title=_("Invalid Item"))
def validate_mode_of_payment(self):
if len(self.payments) == 0:
frappe.throw(_("At least one mode of payment is required for POS invoice."))
@@ -492,12 +500,18 @@ class POSInvoice(SalesInvoice):
@frappe.whitelist()
def get_stock_availability(item_code, warehouse):
if frappe.db.get_value('Item', item_code, 'is_stock_item'):
is_stock_item = True
bin_qty = get_bin_qty(item_code, warehouse)
pos_sales_qty = get_pos_reserved_qty(item_code, warehouse)
return bin_qty - pos_sales_qty
return bin_qty - pos_sales_qty, is_stock_item
else:
is_stock_item = False
if frappe.db.exists('Product Bundle', item_code):
return get_bundle_availability(item_code, warehouse)
return get_bundle_availability(item_code, warehouse), is_stock_item
else:
# Is a service item
return 0, is_stock_item
def get_bundle_availability(bundle_item_code, warehouse):
product_bundle = frappe.get_doc('Product Bundle', bundle_item_code)

View File

@@ -354,6 +354,24 @@ class TestPOSInvoice(unittest.TestCase):
pos2.insert()
self.assertRaises(frappe.ValidationError, pos2.submit)
def test_invalid_serial_no_validation(self):
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_serialized_item
se = make_serialized_item(company='_Test Company',
target_warehouse="Stores - _TC", cost_center='Main - _TC', expense_account='Cost of Goods Sold - _TC')
serial_nos = se.get("items")[0].serial_no + 'wrong'
pos = create_pos_invoice(company='_Test Company', debit_to='Debtors - _TC',
account_for_change_amount='Cash - _TC', warehouse='Stores - _TC', income_account='Sales - _TC',
expense_account='Cost of Goods Sold - _TC', cost_center='Main - _TC',
item=se.get("items")[0].item_code, rate=1000, qty=2, do_not_save=1)
pos.get('items')[0].has_serial_no = 1
pos.get('items')[0].serial_no = serial_nos
pos.insert()
self.assertRaises(frappe.ValidationError, pos.submit)
def test_loyalty_points(self):
from erpnext.accounts.doctype.loyalty_program.loyalty_program import (
get_loyalty_program_details_with_points,
@@ -568,23 +586,29 @@ class TestPOSInvoice(unittest.TestCase):
item_price.insert()
pr = make_pricing_rule(selling=1, priority=5, discount_percentage=10)
pr.save()
pos_inv = create_pos_invoice(qty=1, do_not_submit=1)
pos_inv.items[0].rate = 300
pos_inv.save()
self.assertEquals(pos_inv.items[0].discount_percentage, 10)
# rate shouldn't change
self.assertEquals(pos_inv.items[0].rate, 405)
pos_inv.ignore_pricing_rule = 1
pos_inv.items[0].rate = 300
pos_inv.save()
self.assertEquals(pos_inv.ignore_pricing_rule, 1)
# rate should change since pricing rules are ignored
self.assertEquals(pos_inv.items[0].rate, 300)
try:
pos_inv = create_pos_invoice(qty=1, do_not_submit=1)
pos_inv.items[0].rate = 300
pos_inv.save()
self.assertEquals(pos_inv.items[0].discount_percentage, 10)
# rate shouldn't change
self.assertEquals(pos_inv.items[0].rate, 405)
item_price.delete()
pos_inv.delete()
pr.delete()
pos_inv.ignore_pricing_rule = 1
pos_inv.save()
self.assertEquals(pos_inv.ignore_pricing_rule, 1)
# rate should reset since pricing rules are ignored
self.assertEquals(pos_inv.items[0].rate, 450)
pos_inv.items[0].rate = 300
pos_inv.save()
self.assertEquals(pos_inv.items[0].rate, 300)
finally:
item_price.delete()
pos_inv.delete()
pr.delete()
def create_pos_invoice(**args):

View File

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

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 (
consolidate_pos_invoices,
)
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
class TestPOSInvoiceMergeLog(unittest.TestCase):
@@ -150,3 +151,132 @@ class TestPOSInvoiceMergeLog(unittest.TestCase):
frappe.set_user("Administrator")
frappe.db.sql("delete from `tabPOS Profile`")
frappe.db.sql("delete from `tabPOS Invoice`")
def test_consolidation_round_off_error_1(self):
'''
Test round off error in consolidated invoice creation if POS Invoice has inclusive tax
'''
frappe.db.sql("delete from `tabPOS Invoice`")
try:
make_stock_entry(
to_warehouse="_Test Warehouse - _TC",
item_code="_Test Item",
rate=8000,
qty=10,
)
init_user_and_profile()
inv = create_pos_invoice(qty=3, rate=10000, do_not_save=True)
inv.append("taxes", {
"account_head": "_Test Account VAT - _TC",
"charge_type": "On Net Total",
"cost_center": "_Test Cost Center - _TC",
"description": "VAT",
"doctype": "Sales Taxes and Charges",
"rate": 7.5,
"included_in_print_rate": 1
})
inv.append('payments', {
'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 30000
})
inv.insert()
inv.submit()
inv2 = create_pos_invoice(qty=3, rate=10000, do_not_save=True)
inv2.append("taxes", {
"account_head": "_Test Account VAT - _TC",
"charge_type": "On Net Total",
"cost_center": "_Test Cost Center - _TC",
"description": "VAT",
"doctype": "Sales Taxes and Charges",
"rate": 7.5,
"included_in_print_rate": 1
})
inv2.append('payments', {
'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 30000
})
inv2.insert()
inv2.submit()
consolidate_pos_invoices()
inv.load_from_db()
consolidated_invoice = frappe.get_doc('Sales Invoice', inv.consolidated_invoice)
self.assertEqual(consolidated_invoice.outstanding_amount, 0)
self.assertEqual(consolidated_invoice.status, 'Paid')
finally:
frappe.set_user("Administrator")
frappe.db.sql("delete from `tabPOS Profile`")
frappe.db.sql("delete from `tabPOS Invoice`")
def test_consolidation_round_off_error_2(self):
'''
Test the same case as above but with an Unpaid POS Invoice
'''
frappe.db.sql("delete from `tabPOS Invoice`")
try:
make_stock_entry(
to_warehouse="_Test Warehouse - _TC",
item_code="_Test Item",
rate=8000,
qty=10,
)
init_user_and_profile()
inv = create_pos_invoice(qty=6, rate=10000, do_not_save=True)
inv.append("taxes", {
"account_head": "_Test Account VAT - _TC",
"charge_type": "On Net Total",
"cost_center": "_Test Cost Center - _TC",
"description": "VAT",
"doctype": "Sales Taxes and Charges",
"rate": 7.5,
"included_in_print_rate": 1
})
inv.append('payments', {
'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 60000
})
inv.insert()
inv.submit()
inv2 = create_pos_invoice(qty=6, rate=10000, do_not_save=True)
inv2.append("taxes", {
"account_head": "_Test Account VAT - _TC",
"charge_type": "On Net Total",
"cost_center": "_Test Cost Center - _TC",
"description": "VAT",
"doctype": "Sales Taxes and Charges",
"rate": 7.5,
"included_in_print_rate": 1
})
inv2.append('payments', {
'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 60000
})
inv2.insert()
inv2.submit()
inv3 = create_pos_invoice(qty=3, rate=600, do_not_save=True)
inv3.append('payments', {
'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 1000
})
inv3.insert()
inv3.submit()
consolidate_pos_invoices()
inv.load_from_db()
consolidated_invoice = frappe.get_doc('Sales Invoice', inv.consolidated_invoice)
self.assertEqual(consolidated_invoice.outstanding_amount, 800)
self.assertNotEqual(consolidated_invoice.status, 'Paid')
finally:
frappe.set_user("Administrator")
frappe.db.sql("delete from `tabPOS Profile`")
frappe.db.sql("delete from `tabPOS Invoice`")

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

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 - 2")
def test_remove_pricing_rule(self):
item = make_item("Water Flask")
make_item_price("Water Flask", "_Test Price List", 100)
pricing_rule_record = {
"doctype": "Pricing Rule",
"title": "_Test Water Flask Rule",
"apply_on": "Item Code",
"price_or_product_discount": "Price",
"items": [{
"item_code": "Water Flask",
}],
"selling": 1,
"currency": "INR",
"rate_or_discount": "Discount Percentage",
"discount_percentage": 20,
"company": "_Test Company"
}
rule = frappe.get_doc(pricing_rule_record)
rule.insert()
si = create_sales_invoice(do_not_save=True, item_code="Water Flask")
si.selling_price_list = "_Test Price List"
si.save()
self.assertEqual(si.items[0].price_list_rate, 100)
self.assertEqual(si.items[0].discount_percentage, 20)
self.assertEqual(si.items[0].rate, 80)
si.ignore_pricing_rule = 1
si.save()
self.assertEqual(si.items[0].discount_percentage, 0)
self.assertEqual(si.items[0].rate, 100)
si.delete()
rule.delete()
frappe.get_doc("Item Price", {"item_code": "Water Flask"}).delete()
item.delete()
test_dependencies = ["Campaign"]
def make_pricing_rule(**args):

View File

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

View File

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

View File

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

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
float_precision = cint(frappe.db.get_default("float_precision")) or 2
if asset:
opening_balance = flt(asset[0].get("opening_balance", 0), float_precision)
opening_balance = flt(asset[-1].get("opening_balance", 0), float_precision)
if liability:
opening_balance -= flt(liability[0].get("opening_balance", 0), float_precision)
opening_balance -= flt(liability[-1].get("opening_balance", 0), float_precision)
if equity:
opening_balance -= flt(equity[0].get("opening_balance", 0), float_precision)
opening_balance -= flt(equity[-1].get("opening_balance", 0), float_precision)
opening_balance = flt(opening_balance, float_precision)
if opening_balance:

View File

@@ -285,7 +285,8 @@ def add_total_row(out, root_type, balance_must_be, period_list, company_currency
total_row = {
"account_name": _("Total {0} ({1})").format(_(root_type), _(balance_must_be)),
"account": _("Total {0} ({1})").format(_(root_type), _(balance_must_be)),
"currency": company_currency
"currency": company_currency,
"opening_balance": 0.0
}
for row in out:
@@ -297,6 +298,7 @@ def add_total_row(out, root_type, balance_must_be, period_list, company_currency
total_row.setdefault("total", 0.0)
total_row["total"] += flt(row["total"])
total_row["opening_balance"] += row["opening_balance"]
row["total"] = ""
if "total" in total_row:

View File

@@ -42,6 +42,11 @@ frappe.query_reports["Gross Profit"] = {
"parent_field": "parent_invoice",
"initial_depth": 3,
"formatter": function(value, row, column, data, default_formatter) {
if (column.fieldname == "sales_invoice" && column.options == "Item" && data.indent == 0) {
column._options = "Sales Invoice";
} else {
column._options = "Item";
}
value = default_formatter(value, row, column, data);
if (data && (data.indent == 0.0 || row[1].content == "Total")) {

View File

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

View File

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

View File

@@ -37,6 +37,7 @@ class Asset(AccountsController):
self.validate_asset_values()
self.validate_asset_and_reference()
self.validate_item()
self.validate_cost_center()
self.set_missing_values()
self.prepare_depreciation_data()
self.validate_gross_and_purchase_amount()
@@ -96,6 +97,19 @@ class Asset(AccountsController):
elif item.is_stock_item:
frappe.throw(_("Item {0} must be a non-stock item").format(self.item_code))
def validate_cost_center(self):
if not self.cost_center: return
cost_center_company = frappe.db.get_value('Cost Center', self.cost_center, 'company')
if cost_center_company != self.company:
frappe.throw(
_("Selected Cost Center {} doesn't belongs to {}").format(
frappe.bold(self.cost_center),
frappe.bold(self.company)
),
title=_("Invalid Cost Center")
)
def validate_in_use_date(self):
if not self.available_for_use_date:
frappe.throw(_("Available for use date is required"))

View File

@@ -1131,6 +1131,15 @@ class TestDepreciationBasics(AssetSetup):
self.assertEqual(gle, expected_gle)
self.assertEqual(asset.get("value_after_depreciation"), 0)
def test_asset_cost_center(self):
asset = create_asset(is_existing_asset = 1, do_not_save=1)
asset.cost_center = "Main - WP"
self.assertRaises(frappe.ValidationError, asset.submit)
asset.cost_center = "Main - _TC"
asset.submit()
def create_asset_data():
if not frappe.db.exists("Asset Category", "Computers"):
create_asset_category()

View File

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

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'))):
item.set(fieldname, value)
elif (
ret.get("pricing_rule_removed")
and value is not None
and fieldname
in [
"discount_percentage",
"discount_amount",
"rate",
"margin_rate_or_amount",
"margin_type",
"remove_free_item",
]
):
# reset pricing rule fields if pricing_rule_removed
item.set(fieldname, value)
if self.doctype in ["Purchase Invoice", "Sales Invoice"] and item.meta.get_field('is_fixed_asset'):
item.set('is_fixed_asset', ret.get('is_fixed_asset', 0))
@@ -1319,6 +1335,9 @@ class AccountsController(TransactionBase):
payment_schedule['discount_type'] = schedule.discount_type
payment_schedule['discount'] = schedule.discount
if not schedule.invoice_portion:
payment_schedule['payment_amount'] = schedule.payment_amount
self.append("payment_schedule", payment_schedule)
def set_due_date(self):
@@ -1937,7 +1956,8 @@ def update_bin_on_delete(row, doctype):
qty_dict["ordered_qty"] = get_ordered_qty(row.item_code, row.warehouse)
update_bin_qty(row.item_code, row.warehouse, qty_dict)
if row.warehouse:
update_bin_qty(row.item_code, row.warehouse, qty_dict)
def validate_and_delete_children(parent, data):
deleted_children = []

View File

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

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_group = filters.get('item_group')
company = filters.get('company')
taxes = item_doc.taxes or []
while item_group:
@@ -748,7 +749,7 @@ def get_tax_template(doctype, txt, searchfield, start, page_len, filters):
item_group = item_group_doc.parent_item_group
if not taxes:
return frappe.db.sql(""" SELECT name FROM `tabItem Tax Template` """)
return frappe.get_all('Item Tax Template', filters={'disabled': 0, 'company': company}, as_list=True)
else:
valid_from = filters.get('valid_from')
valid_from = valid_from[1] if isinstance(valid_from, list) else valid_from
@@ -757,7 +758,7 @@ def get_tax_template(doctype, txt, searchfield, start, page_len, filters):
'item_code': filters.get('item_code'),
'posting_date': valid_from,
'tax_category': filters.get('tax_category'),
'company': filters.get('company')
'company': company
}
taxes = _get_item_tax_template(args, taxes, for_validate=True)

View File

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

View File

@@ -400,6 +400,16 @@ class StatusUpdater(Document):
ref_doc = frappe.get_doc(ref_dt, ref_dn)
ref_doc.db_set("per_billed", per_billed)
# set billling status
if hasattr(ref_doc, 'billing_status'):
if ref_doc.per_billed < 0.001:
ref_doc.db_set("billing_status", "Not Billed")
elif ref_doc.per_billed > 99.999999:
ref_doc.db_set("billing_status", "Fully Billed")
else:
ref_doc.db_set("billing_status", "Partly Billed")
ref_doc.set_status(update=True)
def get_allowance_for(item_code, item_allowance=None, global_qty_allowance=None, global_amount_allowance=None, qty_or_amount="qty"):

View File

@@ -3,6 +3,7 @@
import json
from collections import defaultdict
from typing import List, Tuple
import frappe
from frappe import _
@@ -181,33 +182,28 @@ class StockController(AccountsController):
return details
def get_items_and_warehouses(self):
items, warehouses = [], []
def get_items_and_warehouses(self) -> Tuple[List[str], List[str]]:
"""Get list of items and warehouses affected by a transaction"""
if hasattr(self, "items"):
item_doclist = self.get("items")
elif self.doctype == "Stock Reconciliation":
item_doclist = []
data = json.loads(self.reconciliation_json)
for row in data[data.index(self.head_row)+1:]:
d = frappe._dict(zip(["item_code", "warehouse", "qty", "valuation_rate"], row))
item_doclist.append(d)
if not (hasattr(self, "items") or hasattr(self, "packed_items")):
return [], []
if item_doclist:
for d in item_doclist:
if d.item_code and d.item_code not in items:
items.append(d.item_code)
item_rows = (self.get("items") or []) + (self.get("packed_items") or [])
if d.get("warehouse") and d.warehouse not in warehouses:
warehouses.append(d.warehouse)
items = {d.item_code for d in item_rows if d.item_code}
if self.doctype == "Stock Entry":
if d.get("s_warehouse") and d.s_warehouse not in warehouses:
warehouses.append(d.s_warehouse)
if d.get("t_warehouse") and d.t_warehouse not in warehouses:
warehouses.append(d.t_warehouse)
warehouses = set()
for d in item_rows:
if d.get("warehouse"):
warehouses.add(d.warehouse)
return items, warehouses
if self.doctype == "Stock Entry":
if d.get("s_warehouse"):
warehouses.add(d.s_warehouse)
if d.get("t_warehouse"):
warehouses.add(d.t_warehouse)
return list(items), list(warehouses)
def get_stock_ledger_details(self):
stock_ledger = {}
@@ -219,7 +215,7 @@ class StockController(AccountsController):
from
`tabStock Ledger Entry`
where
voucher_type=%s and voucher_no=%s
voucher_type=%s and voucher_no=%s and is_cancelled = 0
""", (self.doctype, self.name), as_dict=True)
for sle in stock_ledger_entries:

View File

@@ -106,6 +106,9 @@ class calculate_taxes_and_totals(object):
self.doc.conversion_rate = flt(self.doc.conversion_rate)
def calculate_item_values(self):
if self.doc.get('is_consolidated'):
return
if not self.discount_amount_applied:
for item in self.doc.get("items"):
self.doc.round_floats_in(item)
@@ -646,12 +649,12 @@ class calculate_taxes_and_totals(object):
def calculate_change_amount(self):
self.doc.change_amount = 0.0
self.doc.base_change_amount = 0.0
grand_total = self.doc.rounded_total or self.doc.grand_total
base_grand_total = self.doc.base_rounded_total or self.doc.base_grand_total
if self.doc.doctype == "Sales Invoice" \
and self.doc.paid_amount > self.doc.grand_total and not self.doc.is_return \
and self.doc.paid_amount > grand_total and not self.doc.is_return \
and any(d.type == "Cash" for d in self.doc.payments):
grand_total = self.doc.rounded_total or self.doc.grand_total
base_grand_total = self.doc.base_rounded_total or self.doc.base_grand_total
self.doc.change_amount = flt(self.doc.paid_amount - grand_total +
self.doc.write_off_amount, self.doc.precision("change_amount"))

View File

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

View File

@@ -106,6 +106,8 @@ class TestVariantSelector(ERPNextTestCase):
})
make_web_item_price(item_code="Test-Tshirt-Temp-S-R", price_list_rate=100)
frappe.local.shopping_cart_settings = None # clear cached settings values
next_values = get_next_attribute_and_values(
"Test-Tshirt-Temp",
selected_attributes={"Test Size": "Small", "Test Colour": "Red"}

View File

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

View File

@@ -1,2 +1,10 @@
// Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
frappe.ui.form.on('Amazon MWS Settings', {
refresh: function (frm) {
let app_link = "<a href='https://github.com/frappe/ecommerce_integrations' target='_blank'>Ecommerce Integrations</a>"
frm.dashboard.add_comment(__("Amazon MWS Integration will be removed from ERPNext in Version 14. Please install {0} app to continue using it.", [app_link]), "yellow", true);
}
});

View File

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

View File

@@ -36,7 +36,7 @@ class TestEmployee(unittest.TestCase):
employee_doc.reload()
make_holiday_list()
frappe.db.set_value("Company", erpnext.get_default_company(), "default_holiday_list", "Salary Slip Test Holiday List")
frappe.db.set_value("Company", employee_doc.company, "default_holiday_list", "Salary Slip Test Holiday List")
frappe.db.sql("""delete from `tabSalary Structure` where name='Test Inactive Employee Salary Slip'""")
salary_structure = make_salary_structure("Test Inactive Employee Salary Slip", "Monthly",

View File

@@ -5,10 +5,12 @@ import unittest
from datetime import timedelta
import frappe
from frappe.utils import getdate
from frappe.utils import add_months, getdate
from erpnext.hr.doctype.employee.employee_reminders import send_holidays_reminder_in_advance
from erpnext.hr.doctype.employee.test_employee import make_employee
from erpnext.hr.doctype.hr_settings.hr_settings import set_proceed_with_frequency_change
from erpnext.hr.utils import get_holidays_for_employee
class TestEmployeeReminders(unittest.TestCase):
@@ -46,6 +48,24 @@ class TestEmployeeReminders(unittest.TestCase):
cls.test_employee = test_employee
cls.test_holiday_dates = test_holiday_dates
# Employee without holidays in this month/week
test_employee_2 = make_employee('test@empwithoutholiday.io', company="_Test Company")
test_employee_2 = frappe.get_doc('Employee', test_employee_2)
test_holiday_list = make_holiday_list(
'TestHolidayRemindersList2',
holiday_dates=[
{'holiday_date': add_months(getdate(), 1), 'description': 'test holiday1'},
],
from_date=add_months(getdate(), -2),
to_date=add_months(getdate(), 2)
)
test_employee_2.holiday_list = test_holiday_list.name
test_employee_2.save()
cls.test_employee_2 = test_employee_2
cls.holiday_list_2 = test_holiday_list
@classmethod
def get_test_holiday_dates(cls):
today_date = getdate()
@@ -61,6 +81,7 @@ class TestEmployeeReminders(unittest.TestCase):
def setUp(self):
# Clear Email Queue
frappe.db.sql("delete from `tabEmail Queue`")
frappe.db.sql("delete from `tabEmail Queue Recipient`")
def test_is_holiday(self):
from erpnext.hr.doctype.employee.employee import is_holiday
@@ -103,11 +124,10 @@ class TestEmployeeReminders(unittest.TestCase):
self.assertTrue("Subject: Birthday Reminder" in email_queue[0].message)
def test_work_anniversary_reminders(self):
employee = frappe.get_doc("Employee", frappe.db.sql_list("select name from tabEmployee limit 1")[0])
employee.date_of_joining = "1998" + frappe.utils.nowdate()[4:]
employee.company_email = "test@example.com"
employee.company = "_Test Company"
employee.save()
make_employee("test_work_anniversary@gmail.com",
date_of_joining="1998" + frappe.utils.nowdate()[4:],
company="_Test Company",
)
from erpnext.hr.doctype.employee.employee_reminders import (
get_employees_having_an_event_today,
@@ -115,7 +135,12 @@ class TestEmployeeReminders(unittest.TestCase):
)
employees_having_work_anniversary = get_employees_having_an_event_today('work_anniversary')
self.assertTrue(employees_having_work_anniversary.get("_Test Company"))
employees = employees_having_work_anniversary.get("_Test Company") or []
user_ids = []
for entry in employees:
user_ids.append(entry.user_id)
self.assertTrue("test_work_anniversary@gmail.com" in user_ids)
hr_settings = frappe.get_doc("HR Settings", "HR Settings")
hr_settings.send_work_anniversary_reminders = 1
@@ -126,16 +151,24 @@ class TestEmployeeReminders(unittest.TestCase):
email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True)
self.assertTrue("Subject: Work Anniversary Reminder" in email_queue[0].message)
def test_send_holidays_reminder_in_advance(self):
from erpnext.hr.doctype.employee.employee_reminders import send_holidays_reminder_in_advance
from erpnext.hr.utils import get_holidays_for_employee
def test_work_anniversary_reminder_not_sent_for_0_years(self):
make_employee("test_work_anniversary_2@gmail.com",
date_of_joining=getdate(),
company="_Test Company",
)
# Get HR settings and enable advance holiday reminders
hr_settings = frappe.get_doc("HR Settings", "HR Settings")
hr_settings.send_holiday_reminders = 1
set_proceed_with_frequency_change()
hr_settings.frequency = 'Weekly'
hr_settings.save()
from erpnext.hr.doctype.employee.employee_reminders import get_employees_having_an_event_today
employees_having_work_anniversary = get_employees_having_an_event_today('work_anniversary')
employees = employees_having_work_anniversary.get("_Test Company") or []
user_ids = []
for entry in employees:
user_ids.append(entry.user_id)
self.assertTrue("test_work_anniversary_2@gmail.com" not in user_ids)
def test_send_holidays_reminder_in_advance(self):
setup_hr_settings('Weekly')
holidays = get_holidays_for_employee(
self.test_employee.get('name'),
@@ -151,32 +184,80 @@ class TestEmployeeReminders(unittest.TestCase):
email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True)
self.assertEqual(len(email_queue), 1)
self.assertTrue("Holidays this Week." in email_queue[0].message)
def test_advance_holiday_reminders_monthly(self):
from erpnext.hr.doctype.employee.employee_reminders import send_reminders_in_advance_monthly
# Get HR settings and enable advance holiday reminders
hr_settings = frappe.get_doc("HR Settings", "HR Settings")
hr_settings.send_holiday_reminders = 1
set_proceed_with_frequency_change()
hr_settings.frequency = 'Monthly'
hr_settings.save()
setup_hr_settings('Monthly')
# disable emp 2, set same holiday list
frappe.db.set_value('Employee', self.test_employee_2.name, {
'status': 'Left',
'holiday_list': self.test_employee.holiday_list
})
send_reminders_in_advance_monthly()
email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True)
self.assertTrue(len(email_queue) > 0)
# even though emp 2 has holiday, non-active employees should not be recipients
recipients = frappe.db.get_all('Email Queue Recipient', pluck='recipient')
self.assertTrue(self.test_employee_2.user_id not in recipients)
# teardown: enable emp 2
frappe.db.set_value('Employee', self.test_employee_2.name, {
'status': 'Active',
'holiday_list': self.holiday_list_2.name
})
def test_advance_holiday_reminders_weekly(self):
from erpnext.hr.doctype.employee.employee_reminders import send_reminders_in_advance_weekly
# Get HR settings and enable advance holiday reminders
hr_settings = frappe.get_doc("HR Settings", "HR Settings")
hr_settings.send_holiday_reminders = 1
hr_settings.frequency = 'Weekly'
hr_settings.save()
setup_hr_settings('Weekly')
# disable emp 2, set same holiday list
frappe.db.set_value('Employee', self.test_employee_2.name, {
'status': 'Left',
'holiday_list': self.test_employee.holiday_list
})
send_reminders_in_advance_weekly()
email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True)
self.assertTrue(len(email_queue) > 0)
# even though emp 2 has holiday, non-active employees should not be recipients
recipients = frappe.db.get_all('Email Queue Recipient', pluck='recipient')
self.assertTrue(self.test_employee_2.user_id not in recipients)
# teardown: enable emp 2
frappe.db.set_value('Employee', self.test_employee_2.name, {
'status': 'Active',
'holiday_list': self.holiday_list_2.name
})
def test_reminder_not_sent_if_no_holdays(self):
setup_hr_settings('Monthly')
# reminder not sent if there are no holidays
holidays = get_holidays_for_employee(
self.test_employee_2.get('name'),
getdate(), getdate() + timedelta(days=3),
only_non_weekly=True,
raise_exception=False
)
send_holidays_reminder_in_advance(
self.test_employee_2.get('name'),
holidays
)
email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True)
self.assertEqual(len(email_queue), 0)
def setup_hr_settings(frequency=None):
# Get HR settings and enable advance holiday reminders
hr_settings = frappe.get_doc("HR Settings", "HR Settings")
hr_settings.send_holiday_reminders = 1
set_proceed_with_frequency_change()
hr_settings.frequency = frequency or 'Weekly'
hr_settings.save()

View File

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

View File

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

View File

@@ -8,12 +8,11 @@ from math import ceil
import frappe
from frappe import _, bold
from frappe.model.document import Document
from frappe.utils import date_diff, flt, formatdate, get_datetime, getdate
from frappe.utils import date_diff, flt, formatdate, get_last_day, getdate
from six import string_types
class LeavePolicyAssignment(Document):
def validate(self):
self.validate_policy_assignment_overlap()
self.set_dates()
@@ -95,10 +94,12 @@ class LeavePolicyAssignment(Document):
new_leaves_allocated = 0
elif leave_type_details.get(leave_type).is_earned_leave == 1:
if self.assignment_based_on == "Leave Period":
new_leaves_allocated = self.get_leaves_for_passed_months(leave_type, new_leaves_allocated, leave_type_details, date_of_joining)
else:
if not self.assignment_based_on:
new_leaves_allocated = 0
else:
# get leaves for past months if assignment is based on Leave Period / Joining Date
new_leaves_allocated = self.get_leaves_for_passed_months(leave_type, new_leaves_allocated, leave_type_details, date_of_joining)
# Calculate leaves at pro-rata basis for employees joining after the beginning of the given leave period
elif getdate(date_of_joining) > getdate(self.effective_from):
remaining_period = ((date_diff(self.effective_to, date_of_joining) + 1) / (date_diff(self.effective_to, self.effective_from) + 1))
@@ -109,21 +110,24 @@ class LeavePolicyAssignment(Document):
def get_leaves_for_passed_months(self, leave_type, new_leaves_allocated, leave_type_details, date_of_joining):
from erpnext.hr.utils import get_monthly_earned_leave
current_month = get_datetime().month
current_year = get_datetime().year
current_date = frappe.flags.current_date or getdate()
if current_date > getdate(self.effective_to):
current_date = getdate(self.effective_to)
from_date = frappe.db.get_value("Leave Period", self.leave_period, "from_date")
if getdate(date_of_joining) > getdate(from_date):
from_date = date_of_joining
from_date_month = get_datetime(from_date).month
from_date_year = get_datetime(from_date).year
from_date = getdate(self.effective_from)
if getdate(date_of_joining) > from_date:
from_date = getdate(date_of_joining)
months_passed = 0
if current_year == from_date_year and current_month > from_date_month:
months_passed = current_month - from_date_month
elif current_year > from_date_year:
months_passed = (12 - from_date_month) + current_month
based_on_doj = leave_type_details.get(leave_type).based_on_date_of_joining
if current_date.year == from_date.year and current_date.month >= from_date.month:
months_passed = current_date.month - from_date.month
months_passed = add_current_month_if_applicable(months_passed, date_of_joining, based_on_doj)
elif current_date.year > from_date.year:
months_passed = (12 - from_date.month) + current_date.month
months_passed = add_current_month_if_applicable(months_passed, date_of_joining, based_on_doj)
if months_passed > 0:
monthly_earned_leave = get_monthly_earned_leave(new_leaves_allocated,
@@ -135,6 +139,23 @@ class LeavePolicyAssignment(Document):
return new_leaves_allocated
def add_current_month_if_applicable(months_passed, date_of_joining, based_on_doj):
date = getdate(frappe.flags.current_date) or getdate()
if based_on_doj:
# if leave type allocation is based on DOJ, and the date of assignment creation is same as DOJ,
# then the month should be considered
if date.day == date_of_joining.day:
months_passed += 1
else:
last_day_of_month = get_last_day(date)
# if its the last day of the month, then that month should be considered
if last_day_of_month == date:
months_passed += 1
return months_passed
@frappe.whitelist()
def create_assignment_for_multiple_employees(employees, data):
@@ -169,7 +190,7 @@ def create_assignment_for_multiple_employees(employees, data):
def get_leave_type_details():
leave_type_details = frappe._dict()
leave_types = frappe.get_all("Leave Type",
fields=["name", "is_lwp", "is_earned_leave", "is_compensatory",
fields=["name", "is_lwp", "is_earned_leave", "is_compensatory", "based_on_date_of_joining",
"is_carry_forward", "expire_carry_forwarded_leaves_after_days", "earned_leave_frequency", "rounding"])
for d in leave_types:
leave_type_details.setdefault(d.name, d)

View File

@@ -4,7 +4,7 @@
import unittest
import frappe
from frappe.utils import add_months, get_first_day, getdate
from frappe.utils import add_months, get_first_day, get_last_day, getdate
from erpnext.hr.doctype.leave_application.test_leave_application import (
get_employee,
@@ -20,36 +20,31 @@ test_dependencies = ["Employee"]
class TestLeavePolicyAssignment(unittest.TestCase):
def setUp(self):
for doctype in ["Leave Period", "Leave Application", "Leave Allocation", "Leave Policy Assignment", "Leave Ledger Entry"]:
frappe.db.sql("delete from `tab{0}`".format(doctype)) #nosec
frappe.db.delete(doctype)
employee = get_employee()
self.original_doj = employee.date_of_joining
self.employee = employee
def test_grant_leaves(self):
leave_period = get_leave_period()
employee = get_employee()
# create the leave policy with leave type "_Test Leave Type", allocation = 10
# allocation = 10
leave_policy = create_leave_policy()
leave_policy.submit()
data = {
"assignment_based_on": "Leave Period",
"leave_policy": leave_policy.name,
"leave_period": leave_period.name
}
leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data))
leave_policy_assignment_doc = frappe.get_doc("Leave Policy Assignment", leave_policy_assignments[0])
leave_policy_assignment_doc.reload()
self.assertEqual(leave_policy_assignment_doc.leaves_allocated, 1)
leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
self.assertEqual(frappe.db.get_value("Leave Policy Assignment", leave_policy_assignments[0], "leaves_allocated"), 1)
leave_allocation = frappe.get_list("Leave Allocation", filters={
"employee": employee.name,
"employee": self.employee.name,
"leave_policy":leave_policy.name,
"leave_policy_assignment": leave_policy_assignments[0],
"docstatus": 1})[0]
leave_alloc_doc = frappe.get_doc("Leave Allocation", leave_allocation)
self.assertEqual(leave_alloc_doc.new_leaves_allocated, 10)
@@ -61,62 +56,45 @@ class TestLeavePolicyAssignment(unittest.TestCase):
def test_allow_to_grant_all_leave_after_cancellation_of_every_leave_allocation(self):
leave_period = get_leave_period()
employee = get_employee()
# create the leave policy with leave type "_Test Leave Type", allocation = 10
leave_policy = create_leave_policy()
leave_policy.submit()
data = {
"assignment_based_on": "Leave Period",
"leave_policy": leave_policy.name,
"leave_period": leave_period.name
}
leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data))
leave_policy_assignment_doc = frappe.get_doc("Leave Policy Assignment", leave_policy_assignments[0])
leave_policy_assignment_doc.reload()
leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
# every leave is allocated no more leave can be granted now
self.assertEqual(leave_policy_assignment_doc.leaves_allocated, 1)
self.assertEqual(frappe.db.get_value("Leave Policy Assignment", leave_policy_assignments[0], "leaves_allocated"), 1)
leave_allocation = frappe.get_list("Leave Allocation", filters={
"employee": employee.name,
"employee": self.employee.name,
"leave_policy":leave_policy.name,
"leave_policy_assignment": leave_policy_assignments[0],
"docstatus": 1})[0]
leave_alloc_doc = frappe.get_doc("Leave Allocation", leave_allocation)
# User all allowed to grant leave when there is no allocation against assignment
leave_alloc_doc.cancel()
leave_alloc_doc.delete()
leave_policy_assignment_doc.reload()
# User are now allowed to grant leave
self.assertEqual(leave_policy_assignment_doc.leaves_allocated, 0)
self.assertEqual(frappe.db.get_value("Leave Policy Assignment", leave_policy_assignments[0], "leaves_allocated"), 0)
def test_earned_leave_allocation(self):
leave_period = create_leave_period("Test Earned Leave Period")
employee = get_employee()
leave_type = create_earned_leave_type("Test Earned Leave")
leave_policy = frappe.get_doc({
"doctype": "Leave Policy",
"leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 6}]
}).insert()
}).submit()
data = {
"assignment_based_on": "Leave Period",
"leave_policy": leave_policy.name,
"leave_period": leave_period.name
}
leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data))
leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
# leaves allocated should be 0 since it is an earned leave and allocation happens via scheduler based on set frequency
leaves_allocated = frappe.db.get_value("Leave Allocation", {
@@ -124,11 +102,200 @@ class TestLeavePolicyAssignment(unittest.TestCase):
}, "total_leaves_allocated")
self.assertEqual(leaves_allocated, 0)
def test_earned_leave_alloc_for_passed_months_based_on_leave_period(self):
leave_period, leave_policy = setup_leave_period_and_policy(get_first_day(add_months(getdate(), -1)))
# Case 1: assignment created one month after the leave period, should allocate 1 leave
frappe.flags.current_date = get_first_day(getdate())
data = {
"assignment_based_on": "Leave Period",
"leave_policy": leave_policy.name,
"leave_period": leave_period.name
}
leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
leaves_allocated = frappe.db.get_value("Leave Allocation", {
"leave_policy_assignment": leave_policy_assignments[0]
}, "total_leaves_allocated")
self.assertEqual(leaves_allocated, 1)
def test_earned_leave_alloc_for_passed_months_on_month_end_based_on_leave_period(self):
leave_period, leave_policy = setup_leave_period_and_policy(get_first_day(add_months(getdate(), -2)))
# Case 2: assignment created on the last day of the leave period's latter month
# should allocate 1 leave for current month even though the month has not ended
# since the daily job might have already executed
frappe.flags.current_date = get_last_day(getdate())
data = {
"assignment_based_on": "Leave Period",
"leave_policy": leave_policy.name,
"leave_period": leave_period.name
}
leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
leaves_allocated = frappe.db.get_value("Leave Allocation", {
"leave_policy_assignment": leave_policy_assignments[0]
}, "total_leaves_allocated")
self.assertEqual(leaves_allocated, 3)
# if the daily job is not completed yet, there is another check present
# to ensure leave is not already allocated to avoid duplication
from erpnext.hr.utils import allocate_earned_leaves
allocate_earned_leaves()
leaves_allocated = frappe.db.get_value("Leave Allocation", {
"leave_policy_assignment": leave_policy_assignments[0]
}, "total_leaves_allocated")
self.assertEqual(leaves_allocated, 3)
def test_earned_leave_alloc_for_passed_months_with_cf_leaves_based_on_leave_period(self):
from erpnext.hr.doctype.leave_allocation.test_leave_allocation import create_leave_allocation
leave_period, leave_policy = setup_leave_period_and_policy(get_first_day(add_months(getdate(), -2)))
# initial leave allocation = 5
leave_allocation = create_leave_allocation(employee=self.employee.name, employee_name=self.employee.employee_name, leave_type="Test Earned Leave",
from_date=add_months(getdate(), -12), to_date=add_months(getdate(), -3), new_leaves_allocated=5, carry_forward=0)
leave_allocation.submit()
# Case 3: assignment created on the last day of the leave period's latter month with carry forwarding
frappe.flags.current_date = get_last_day(add_months(getdate(), -1))
data = {
"assignment_based_on": "Leave Period",
"leave_policy": leave_policy.name,
"leave_period": leave_period.name,
"carry_forward": 1
}
# carry forwarded leaves = 5, 3 leaves allocated for passed months
leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
details = frappe.db.get_value("Leave Allocation", {
"leave_policy_assignment": leave_policy_assignments[0]
}, ["total_leaves_allocated", "new_leaves_allocated", "unused_leaves", "name"], as_dict=True)
self.assertEqual(details.new_leaves_allocated, 2)
self.assertEqual(details.unused_leaves, 5)
self.assertEqual(details.total_leaves_allocated, 7)
# if the daily job is not completed yet, there is another check present
# to ensure leave is not already allocated to avoid duplication
from erpnext.hr.utils import is_earned_leave_already_allocated
frappe.flags.current_date = get_last_day(getdate())
allocation = frappe.get_doc("Leave Allocation", details.name)
# 1 leave is still pending to be allocated, irrespective of carry forwarded leaves
self.assertFalse(is_earned_leave_already_allocated(allocation, leave_policy.leave_policy_details[0].annual_allocation))
def test_earned_leave_alloc_for_passed_months_based_on_joining_date(self):
# tests leave alloc for earned leaves for assignment based on joining date in policy assignment
leave_type = create_earned_leave_type("Test Earned Leave")
leave_policy = frappe.get_doc({
"doctype": "Leave Policy",
"title": "Test Leave Policy",
"leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 12}]
}).submit()
# joining date set to 2 months back
self.employee.date_of_joining = get_first_day(add_months(getdate(), -2))
self.employee.save()
# assignment created on the last day of the current month
frappe.flags.current_date = get_last_day(getdate())
data = {
"assignment_based_on": "Joining Date",
"leave_policy": leave_policy.name
}
leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
leaves_allocated = frappe.db.get_value("Leave Allocation", {"leave_policy_assignment": leave_policy_assignments[0]},
"total_leaves_allocated")
effective_from = frappe.db.get_value("Leave Policy Assignment", leave_policy_assignments[0], "effective_from")
self.assertEqual(effective_from, self.employee.date_of_joining)
self.assertEqual(leaves_allocated, 3)
# to ensure leave is not already allocated to avoid duplication
from erpnext.hr.utils import allocate_earned_leaves
frappe.flags.current_date = get_last_day(getdate())
allocate_earned_leaves()
leaves_allocated = frappe.db.get_value("Leave Allocation", {"leave_policy_assignment": leave_policy_assignments[0]},
"total_leaves_allocated")
self.assertEqual(leaves_allocated, 3)
def test_grant_leaves_on_doj_for_earned_leaves_based_on_leave_period(self):
# tests leave alloc based on leave period for earned leaves with "based on doj" configuration in leave type
leave_period, leave_policy = setup_leave_period_and_policy(get_first_day(add_months(getdate(), -2)), based_on_doj=True)
# joining date set to 2 months back
self.employee.date_of_joining = get_first_day(add_months(getdate(), -2))
self.employee.save()
# assignment created on the same day of the current month, should allocate leaves including the current month
frappe.flags.current_date = get_first_day(getdate())
data = {
"assignment_based_on": "Leave Period",
"leave_policy": leave_policy.name,
"leave_period": leave_period.name
}
leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
leaves_allocated = frappe.db.get_value("Leave Allocation", {
"leave_policy_assignment": leave_policy_assignments[0]
}, "total_leaves_allocated")
self.assertEqual(leaves_allocated, 3)
# if the daily job is not completed yet, there is another check present
# to ensure leave is not already allocated to avoid duplication
from erpnext.hr.utils import allocate_earned_leaves
frappe.flags.current_date = get_first_day(getdate())
allocate_earned_leaves()
leaves_allocated = frappe.db.get_value("Leave Allocation", {
"leave_policy_assignment": leave_policy_assignments[0]
}, "total_leaves_allocated")
self.assertEqual(leaves_allocated, 3)
def test_grant_leaves_on_doj_for_earned_leaves_based_on_joining_date(self):
# tests leave alloc based on joining date for earned leaves with "based on doj" configuration in leave type
leave_type = create_earned_leave_type("Test Earned Leave", based_on_doj=True)
leave_policy = frappe.get_doc({
"doctype": "Leave Policy",
"title": "Test Leave Policy",
"leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 12}]
}).submit()
# joining date set to 2 months back
# leave should be allocated for current month too since this day is same as the joining day
self.employee.date_of_joining = get_first_day(add_months(getdate(), -2))
self.employee.save()
# assignment created on the first day of the current month
frappe.flags.current_date = get_first_day(getdate())
data = {
"assignment_based_on": "Joining Date",
"leave_policy": leave_policy.name
}
leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
leaves_allocated = frappe.db.get_value("Leave Allocation", {"leave_policy_assignment": leave_policy_assignments[0]},
"total_leaves_allocated")
effective_from = frappe.db.get_value("Leave Policy Assignment", leave_policy_assignments[0], "effective_from")
self.assertEqual(effective_from, self.employee.date_of_joining)
self.assertEqual(leaves_allocated, 3)
# to ensure leave is not already allocated to avoid duplication
from erpnext.hr.utils import allocate_earned_leaves
frappe.flags.current_date = get_first_day(getdate())
allocate_earned_leaves()
leaves_allocated = frappe.db.get_value("Leave Allocation", {"leave_policy_assignment": leave_policy_assignments[0]},
"total_leaves_allocated")
self.assertEqual(leaves_allocated, 3)
def tearDown(self):
frappe.db.rollback()
frappe.db.set_value("Employee", self.employee.name, "date_of_joining", self.original_doj)
frappe.flags.current_date = None
def create_earned_leave_type(leave_type):
def create_earned_leave_type(leave_type, based_on_doj=False):
frappe.delete_doc_if_exists("Leave Type", leave_type, force=1)
return frappe.get_doc(dict(
@@ -137,13 +304,15 @@ def create_earned_leave_type(leave_type):
is_earned_leave=1,
earned_leave_frequency="Monthly",
rounding=0.5,
max_leaves_allowed=6
is_carry_forward=1,
based_on_date_of_joining=based_on_doj
)).insert()
def create_leave_period(name):
def create_leave_period(name, start_date=None):
frappe.delete_doc_if_exists("Leave Period", name, force=1)
start_date = get_first_day(getdate())
if not start_date:
start_date = get_first_day(getdate())
return frappe.get_doc(dict(
name=name,
@@ -152,4 +321,17 @@ def create_leave_period(name):
to_date=add_months(start_date, 12),
company="_Test Company",
is_active=1
)).insert()
)).insert()
def setup_leave_period_and_policy(start_date, based_on_doj=False):
leave_type = create_earned_leave_type("Test Earned Leave", based_on_doj)
leave_period = create_leave_period("Test Earned Leave Period",
start_date=start_date)
leave_policy = frappe.get_doc({
"doctype": "Leave Policy",
"title": "Test Leave Policy",
"leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 12}]
}).insert()
return leave_period, leave_policy

View File

@@ -353,7 +353,7 @@ def generate_leave_encashment():
create_leave_encashment(leave_allocation=leave_allocation)
def allocate_earned_leaves():
def allocate_earned_leaves(ignore_duplicates=False):
'''Allocate earned leaves to Employees'''
e_leave_types = get_earned_leaves()
today = getdate()
@@ -377,13 +377,13 @@ def allocate_earned_leaves():
from_date=allocation.from_date
if e_leave_type.based_on_date_of_joining_date:
if e_leave_type.based_on_date_of_joining:
from_date = frappe.db.get_value("Employee", allocation.employee, "date_of_joining")
if check_effective_date(from_date, today, e_leave_type.earned_leave_frequency, e_leave_type.based_on_date_of_joining_date):
update_previous_leave_allocation(allocation, annual_allocation, e_leave_type)
if check_effective_date(from_date, today, e_leave_type.earned_leave_frequency, e_leave_type.based_on_date_of_joining):
update_previous_leave_allocation(allocation, annual_allocation, e_leave_type, ignore_duplicates)
def update_previous_leave_allocation(allocation, annual_allocation, e_leave_type):
def update_previous_leave_allocation(allocation, annual_allocation, e_leave_type, ignore_duplicates=False):
earned_leaves = get_monthly_earned_leave(annual_allocation, e_leave_type.earned_leave_frequency, e_leave_type.rounding)
allocation = frappe.get_doc('Leave Allocation', allocation.name)
@@ -393,9 +393,12 @@ def update_previous_leave_allocation(allocation, annual_allocation, e_leave_type
new_allocation = e_leave_type.max_leaves_allowed
if new_allocation != allocation.total_leaves_allocated:
allocation.db_set("total_leaves_allocated", new_allocation, update_modified=False)
today_date = today()
create_additional_leave_ledger_entry(allocation, earned_leaves, today_date)
if ignore_duplicates or not is_earned_leave_already_allocated(allocation, annual_allocation):
allocation.db_set("total_leaves_allocated", new_allocation, update_modified=False)
create_additional_leave_ledger_entry(allocation, earned_leaves, today_date)
def get_monthly_earned_leave(annual_leaves, frequency, rounding):
earned_leaves = 0.0
@@ -413,6 +416,28 @@ def get_monthly_earned_leave(annual_leaves, frequency, rounding):
return earned_leaves
def is_earned_leave_already_allocated(allocation, annual_allocation):
from erpnext.hr.doctype.leave_policy_assignment.leave_policy_assignment import (
get_leave_type_details,
)
leave_type_details = get_leave_type_details()
date_of_joining = frappe.db.get_value("Employee", allocation.employee, "date_of_joining")
assignment = frappe.get_doc("Leave Policy Assignment", allocation.leave_policy_assignment)
leaves_for_passed_months = assignment.get_leaves_for_passed_months(allocation.leave_type,
annual_allocation, leave_type_details, date_of_joining)
# exclude carry-forwarded leaves while checking for leave allocation for passed months
num_allocations = allocation.total_leaves_allocated
if allocation.unused_leaves:
num_allocations -= allocation.unused_leaves
if num_allocations >= leaves_for_passed_months:
return True
return False
def get_leave_allocations(date, leave_type):
return frappe.db.sql("""select name, employee, from_date, to_date, leave_policy_assignment, leave_policy
from `tabLeave Allocation`
@@ -434,7 +459,7 @@ def create_additional_leave_ledger_entry(allocation, leaves, date):
allocation.unused_leaves = 0
allocation.create_leave_ledger_entry()
def check_effective_date(from_date, to_date, frequency, based_on_date_of_joining_date):
def check_effective_date(from_date, to_date, frequency, based_on_date_of_joining):
import calendar
from dateutil import relativedelta
@@ -445,7 +470,7 @@ def check_effective_date(from_date, to_date, frequency, based_on_date_of_joining
#last day of month
last_day = calendar.monthrange(to_date.year, to_date.month)[1]
if (from_date.day == to_date.day and based_on_date_of_joining_date) or (not based_on_date_of_joining_date and to_date.day == last_day):
if (from_date.day == to_date.day and based_on_date_of_joining) or (not based_on_date_of_joining and to_date.day == last_day):
if frequency == "Monthly":
return True
elif frequency == "Quarterly" and rd.months % 3:

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

View File

@@ -346,7 +346,7 @@ class LoanRepayment(AccountsController):
gle_map.append(
self.get_gl_dict({
"account": loan_details.penalty_income_account,
"against": payment_account,
"against": loan_details.loan_account,
"credit": self.total_penalty_paid,
"credit_in_account_currency": self.total_penalty_paid,
"against_voucher_type": "Loan",
@@ -368,7 +368,9 @@ class LoanRepayment(AccountsController):
"against_voucher": self.against_loan,
"remarks": remarks,
"cost_center": self.cost_center,
"posting_date": getdate(self.posting_date)
"posting_date": getdate(self.posting_date),
"party_type": loan_details.applicant_type if self.repay_from_salary else '',
"party": loan_details.applicant if self.repay_from_salary else ''
})
)

View File

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

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.trigger("make_work_order");
}, __("Create"));

View File

@@ -29,9 +29,24 @@ from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
class ProductionPlan(Document):
def validate(self):
self.set_pending_qty_in_row_without_reference()
self.calculate_total_planned_qty()
self.set_status()
def set_pending_qty_in_row_without_reference(self):
"Set Pending Qty in independent rows (not from SO or MR)."
if self.docstatus > 0: # set only to initialise value before submit
return
for item in self.po_items:
if not item.get("sales_order") or not item.get("material_request"):
item.pending_qty = item.planned_qty
def calculate_total_planned_qty(self):
self.total_planned_qty = 0
for d in self.po_items:
self.total_planned_qty += flt(d.planned_qty)
def validate_data(self):
for d in self.get('po_items'):
if not d.bom_no:
@@ -264,11 +279,6 @@ class ProductionPlan(Document):
'qty': so_detail['qty']
})
def calculate_total_planned_qty(self):
self.total_planned_qty = 0
for d in self.po_items:
self.total_planned_qty += flt(d.planned_qty)
def calculate_total_produced_qty(self):
self.total_produced_qty = 0
for d in self.po_items:
@@ -276,10 +286,11 @@ class ProductionPlan(Document):
self.db_set("total_produced_qty", self.total_produced_qty, update_modified=False)
def update_produced_qty(self, produced_qty, production_plan_item):
def update_produced_pending_qty(self, produced_qty, production_plan_item):
for data in self.po_items:
if data.name == production_plan_item:
data.produced_qty = produced_qty
data.pending_qty = flt(data.planned_qty - produced_qty)
data.db_update()
self.calculate_total_produced_qty()
@@ -342,6 +353,7 @@ class ProductionPlan(Document):
def get_production_items(self):
item_dict = {}
for d in self.po_items:
item_details = {
"production_item" : d.item_code,
@@ -358,12 +370,12 @@ class ProductionPlan(Document):
"production_plan" : self.name,
"production_plan_item" : d.name,
"product_bundle_item" : d.product_bundle_item,
"planned_start_date" : d.planned_start_date
"planned_start_date" : d.planned_start_date,
"project" : self.project
}
item_details.update({
"project": self.project or frappe.db.get_value("Sales Order", d.sales_order, "project")
})
if not item_details['project'] and d.sales_order:
item_details['project'] = frappe.get_cached_value("Sales Order", d.sales_order, "project")
if self.get_items_from == "Material Request":
item_details.update({
@@ -381,39 +393,59 @@ class ProductionPlan(Document):
@frappe.whitelist()
def make_work_order(self):
from erpnext.manufacturing.doctype.work_order.work_order import get_default_warehouse
wo_list, po_list = [], []
subcontracted_po = {}
default_warehouses = get_default_warehouse()
self.validate_data()
self.make_work_order_for_finished_goods(wo_list)
self.make_work_order_for_subassembly_items(wo_list, subcontracted_po)
self.make_work_order_for_finished_goods(wo_list, default_warehouses)
self.make_work_order_for_subassembly_items(wo_list, subcontracted_po, default_warehouses)
self.make_subcontracted_purchase_order(subcontracted_po, po_list)
self.show_list_created_message('Work Order', wo_list)
self.show_list_created_message('Purchase Order', po_list)
def make_work_order_for_finished_goods(self, wo_list):
def make_work_order_for_finished_goods(self, wo_list, default_warehouses):
items_data = self.get_production_items()
for key, item in items_data.items():
if self.sub_assembly_items:
item['use_multi_level_bom'] = 0
set_default_warehouses(item, default_warehouses)
work_order = self.create_work_order(item)
if work_order:
wo_list.append(work_order)
def make_work_order_for_subassembly_items(self, wo_list, subcontracted_po):
def make_work_order_for_subassembly_items(self, wo_list, subcontracted_po, default_warehouses):
for row in self.sub_assembly_items:
if row.type_of_manufacturing == 'Subcontract':
subcontracted_po.setdefault(row.supplier, []).append(row)
continue
args = {}
self.prepare_args_for_sub_assembly_items(row, args)
work_order = self.create_work_order(args)
work_order_data = {
'wip_warehouse': default_warehouses.get('wip_warehouse'),
'fg_warehouse': default_warehouses.get('fg_warehouse')
}
self.prepare_data_for_sub_assembly_items(row, work_order_data)
work_order = self.create_work_order(work_order_data)
if work_order:
wo_list.append(work_order)
def prepare_data_for_sub_assembly_items(self, row, wo_data):
for field in ["production_item", "item_name", "qty", "fg_warehouse",
"description", "bom_no", "stock_uom", "bom_level",
"production_plan_item", "schedule_date"]:
if row.get(field):
wo_data[field] = row.get(field)
wo_data.update({
"use_multi_level_bom": 0,
"production_plan": self.name,
"production_plan_sub_assembly_item": row.name
})
def make_subcontracted_purchase_order(self, subcontracted_po, purchase_orders):
if not subcontracted_po:
return
@@ -424,7 +456,7 @@ class ProductionPlan(Document):
po.schedule_date = getdate(po_list[0].schedule_date) if po_list[0].schedule_date else nowdate()
po.is_subcontracted = 'Yes'
for row in po_list:
args = {
po_data = {
'item_code': row.production_item,
'warehouse': row.fg_warehouse,
'production_plan_sub_assembly_item': row.name,
@@ -434,9 +466,9 @@ class ProductionPlan(Document):
for field in ['schedule_date', 'qty', 'uom', 'stock_uom', 'item_name',
'description', 'production_plan_item']:
args[field] = row.get(field)
po_data[field] = row.get(field)
po.append('items', args)
po.append('items', po_data)
po.set_missing_values()
po.flags.ignore_mandatory = True
@@ -453,24 +485,9 @@ class ProductionPlan(Document):
doc_list = [get_link_to_form(doctype, p) for p in doc_list]
msgprint(_("{0} created").format(comma_and(doc_list)))
def prepare_args_for_sub_assembly_items(self, row, args):
for field in ["production_item", "item_name", "qty", "fg_warehouse",
"description", "bom_no", "stock_uom", "bom_level",
"production_plan_item", "schedule_date"]:
args[field] = row.get(field)
args.update({
"use_multi_level_bom": 0,
"production_plan": self.name,
"production_plan_sub_assembly_item": row.name
})
def create_work_order(self, item):
from erpnext.manufacturing.doctype.work_order.work_order import (
OverProductionError,
get_default_warehouse,
)
warehouse = get_default_warehouse()
from erpnext.manufacturing.doctype.work_order.work_order import OverProductionError
wo = frappe.new_doc("Work Order")
wo.update(item)
wo.planned_start_date = item.get('planned_start_date') or item.get('schedule_date')
@@ -479,11 +496,11 @@ class ProductionPlan(Document):
wo.fg_warehouse = item.get("warehouse")
wo.set_work_order_operations()
wo.set_required_items()
if not wo.fg_warehouse:
wo.fg_warehouse = warehouse.get('fg_warehouse')
try:
wo.flags.ignore_mandatory = True
wo.flags.ignore_validate = True
wo.insert()
return wo.name
except OverProductionError:
@@ -589,7 +606,8 @@ def download_raw_materials(doc, warehouses=None):
if isinstance(doc, str):
doc = frappe._dict(json.loads(doc))
item_list = [['Item Code', 'Description', 'Stock UOM', 'Warehouse', 'Required Qty as per BOM',
item_list = [['Item Code', 'Item Name', 'Description',
'Stock UOM', 'Warehouse', 'Required Qty as per BOM',
'Projected Qty', 'Available Qty In Hand', 'Ordered Qty', 'Planned Qty',
'Reserved Qty for Production', 'Safety Stock', 'Required Qty']]
@@ -598,7 +616,8 @@ def download_raw_materials(doc, warehouses=None):
items = get_items_for_material_requests(doc, warehouses=warehouses, get_parent_warehouse_data=True)
for d in items:
item_list.append([d.get('item_code'), d.get('description'), d.get('stock_uom'), d.get('warehouse'),
item_list.append([d.get('item_code'), d.get('item_name'),
d.get('description'), d.get('stock_uom'), d.get('warehouse'),
d.get('required_bom_qty'), d.get('projected_qty'), d.get('actual_qty'), d.get('ordered_qty'),
d.get('planned_qty'), d.get('reserved_qty_for_production'), d.get('safety_stock'), d.get('quantity')])
@@ -1024,3 +1043,8 @@ def get_sub_assembly_items(bom_no, bom_data, to_produce_qty, indent=0):
if d.value:
get_sub_assembly_items(d.value, bom_data, stock_qty, indent=indent+1)
def set_default_warehouses(row, default_warehouses):
for field in ['wip_warehouse', 'fg_warehouse']:
if not row.get(field):
row[field] = default_warehouses.get(field)

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.stock.doctype.item.test_item import create_item
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
create_stock_reconciliation,
)
@@ -36,15 +37,21 @@ class TestProductionPlan(ERPNextTestCase):
if not frappe.db.get_value('BOM', {'item': item}):
make_bom(item = item, raw_materials = raw_materials)
def test_production_plan(self):
def test_production_plan_mr_creation(self):
"Test if MRs are created for unavailable raw materials."
pln = create_production_plan(item_code='Test Production Item 1')
self.assertTrue(len(pln.mr_items), 2)
pln.make_material_request()
pln = frappe.get_doc('Production Plan', pln.name)
pln.make_material_request()
pln.reload()
self.assertTrue(pln.status, 'Material Requested')
material_requests = frappe.get_all('Material Request Item', fields = ['distinct parent'],
filters = {'production_plan': pln.name}, as_list=1)
material_requests = frappe.get_all(
'Material Request Item',
fields = ['distinct parent'],
filters = {'production_plan': pln.name},
as_list=1
)
self.assertTrue(len(material_requests), 2)
@@ -66,27 +73,42 @@ class TestProductionPlan(ERPNextTestCase):
pln.cancel()
def test_production_plan_start_date(self):
"Test if Work Order has same Planned Start Date as Prod Plan."
planned_date = add_to_date(date=None, days=3)
plan = create_production_plan(item_code='Test Production Item 1', planned_start_date=planned_date)
plan = create_production_plan(
item_code='Test Production Item 1',
planned_start_date=planned_date
)
plan.make_work_order()
work_orders = frappe.get_all('Work Order', fields = ['name', 'planned_start_date'],
filters = {'production_plan': plan.name})
work_orders = frappe.get_all(
'Work Order',
fields = ['name', 'planned_start_date'],
filters = {'production_plan': plan.name}
)
self.assertEqual(work_orders[0].planned_start_date, planned_date)
for wo in work_orders:
frappe.delete_doc('Work Order', wo.name)
frappe.get_doc('Production Plan', plan.name).cancel()
plan.reload()
plan.cancel()
def test_production_plan_for_existing_ordered_qty(self):
"""
- Enable 'ignore_existing_ordered_qty'.
- Test if MR Planning table pulls Raw Material Qty even if it is in stock.
"""
sr1 = create_stock_reconciliation(item_code="Raw Material Item 1",
target="_Test Warehouse - _TC", qty=1, rate=110)
sr2 = create_stock_reconciliation(item_code="Raw Material Item 2",
target="_Test Warehouse - _TC", qty=1, rate=120)
pln = create_production_plan(item_code='Test Production Item 1', ignore_existing_ordered_qty=0)
pln = create_production_plan(
item_code='Test Production Item 1',
ignore_existing_ordered_qty=1
)
self.assertTrue(len(pln.mr_items), 1)
self.assertTrue(flt(pln.mr_items[0].quantity), 1.0)
@@ -95,23 +117,39 @@ class TestProductionPlan(ERPNextTestCase):
pln.cancel()
def test_production_plan_with_non_stock_item(self):
pln = create_production_plan(item_code='Test Production Item 1', include_non_stock_items=0)
"Test if MR Planning table includes Non Stock RM."
pln = create_production_plan(
item_code='Test Production Item 1',
include_non_stock_items=1
)
self.assertTrue(len(pln.mr_items), 3)
pln.cancel()
def test_production_plan_without_multi_level(self):
pln = create_production_plan(item_code='Test Production Item 1', use_multi_level_bom=0)
"Test MR Planning table for non exploded BOM."
pln = create_production_plan(
item_code='Test Production Item 1',
use_multi_level_bom=0
)
self.assertTrue(len(pln.mr_items), 2)
pln.cancel()
def test_production_plan_without_multi_level_for_existing_ordered_qty(self):
"""
- Disable 'ignore_existing_ordered_qty'.
- Test if MR Planning table avoids pulling Raw Material Qty as it is in stock for
non exploded BOM.
"""
sr1 = create_stock_reconciliation(item_code="Raw Material Item 1",
target="_Test Warehouse - _TC", qty=1, rate=130)
sr2 = create_stock_reconciliation(item_code="Subassembly Item 1",
target="_Test Warehouse - _TC", qty=1, rate=140)
pln = create_production_plan(item_code='Test Production Item 1',
use_multi_level_bom=0, ignore_existing_ordered_qty=0)
pln = create_production_plan(
item_code='Test Production Item 1',
use_multi_level_bom=0,
ignore_existing_ordered_qty=0
)
self.assertTrue(len(pln.mr_items), 0)
sr1.cancel()
@@ -119,6 +157,7 @@ class TestProductionPlan(ERPNextTestCase):
pln.cancel()
def test_production_plan_sales_orders(self):
"Test if previously fulfilled SO (with WO) is pulled into Prod Plan."
item = 'Test Production Item 1'
so = make_sales_order(item_code=item, qty=1)
sales_order = so.name
@@ -166,24 +205,25 @@ class TestProductionPlan(ERPNextTestCase):
self.assertEqual(sales_orders, [])
def test_production_plan_combine_items(self):
"Test combining FG items in Production Plan."
item = 'Test Production Item 1'
so = make_sales_order(item_code=item, qty=1)
so1 = make_sales_order(item_code=item, qty=1)
pln = frappe.new_doc('Production Plan')
pln.company = so.company
pln.company = so1.company
pln.get_items_from = 'Sales Order'
pln.append('sales_orders', {
'sales_order': so.name,
'sales_order_date': so.transaction_date,
'customer': so.customer,
'grand_total': so.grand_total
'sales_order': so1.name,
'sales_order_date': so1.transaction_date,
'customer': so1.customer,
'grand_total': so1.grand_total
})
so = make_sales_order(item_code=item, qty=2)
so2 = make_sales_order(item_code=item, qty=2)
pln.append('sales_orders', {
'sales_order': so.name,
'sales_order_date': so.transaction_date,
'customer': so.customer,
'grand_total': so.grand_total
'sales_order': so2.name,
'sales_order_date': so2.transaction_date,
'customer': so2.customer,
'grand_total': so2.grand_total
})
pln.combine_items = 1
pln.get_items()
@@ -214,28 +254,37 @@ class TestProductionPlan(ERPNextTestCase):
so_wo_qty = frappe.db.get_value('Sales Order Item', so_item, 'work_order_qty')
self.assertEqual(so_wo_qty, 0.0)
latest_plan = frappe.get_doc('Production Plan', pln.name)
latest_plan.cancel()
pln.reload()
pln.cancel()
def test_pp_to_mr_customer_provided(self):
#Material Request from Production Plan for Customer Provided
" Test Material Request from Production Plan for Customer Provided Item."
create_item('CUST-0987', is_customer_provided_item = 1, customer = '_Test Customer', is_purchase_item = 0)
create_item('Production Item CUST')
for item, raw_materials in {'Production Item CUST': ['Raw Material Item 1', 'CUST-0987']}.items():
if not frappe.db.get_value('BOM', {'item': item}):
make_bom(item = item, raw_materials = raw_materials)
production_plan = create_production_plan(item_code = 'Production Item CUST')
production_plan.make_material_request()
material_request = frappe.db.get_value('Material Request Item', {'production_plan': production_plan.name, 'item_code': 'CUST-0987'}, 'parent')
material_request = frappe.db.get_value(
'Material Request Item',
{'production_plan': production_plan.name, 'item_code': 'CUST-0987'},
'parent'
)
mr = frappe.get_doc('Material Request', material_request)
self.assertTrue(mr.material_request_type, 'Customer Provided')
self.assertTrue(mr.customer, '_Test Customer')
def test_production_plan_with_multi_level_bom(self):
#|Item Code | Qty |
#|Test BOM 1 | 1 |
#| Test BOM 2 | 2 |
#| Test BOM 3 | 3 |
"""
Item Code | Qty |
|Test BOM 1 | 1 |
|Test BOM 2 | 2 |
|Test BOM 3 | 3 |
"""
for item_code in ["Test BOM 1", "Test BOM 2", "Test BOM 3", "Test RM BOM 1"]:
create_item(item_code, is_stock_item=1)
@@ -264,15 +313,18 @@ class TestProductionPlan(ERPNextTestCase):
pln.make_work_order()
#last level sub-assembly work order produce qty
to_produce_qty = frappe.db.get_value("Work Order",
{"production_plan": pln.name, "production_item": "Test BOM 3"}, "qty")
to_produce_qty = frappe.db.get_value(
"Work Order",
{"production_plan": pln.name, "production_item": "Test BOM 3"},
"qty"
)
self.assertEqual(to_produce_qty, 18.0)
pln.cancel()
frappe.delete_doc("Production Plan", pln.name)
def test_get_warehouse_list_group(self):
"""Check if required warehouses are returned"""
"Check if required child warehouses are returned."
warehouse_json = '[{\"warehouse\":\"_Test Warehouse Group - _TC\"}]'
warehouses = set(get_warehouse_list(warehouse_json))
@@ -284,6 +336,7 @@ class TestProductionPlan(ERPNextTestCase):
msg=f"Following warehouses were expected {', '.join(missing_warehouse)}")
def test_get_warehouse_list_single(self):
"Check if same warehouse is returned in absence of child warehouses."
warehouse_json = '[{\"warehouse\":\"_Test Scrap Warehouse - _TC\"}]'
warehouses = set(get_warehouse_list(warehouse_json))
@@ -292,6 +345,7 @@ class TestProductionPlan(ERPNextTestCase):
self.assertEqual(warehouses, expected_warehouses)
def test_get_sales_order_with_variant(self):
"Check if Template BOM is fetched in absence of Variant BOM."
rm_item = create_item('PIV_RM', valuation_rate = 100)
if not frappe.db.exists('Item', {"item_code": 'PIV'}):
item = create_item('PIV', valuation_rate = 100)
@@ -348,7 +402,7 @@ class TestProductionPlan(ERPNextTestCase):
frappe.db.rollback()
def test_subassmebly_sorting(self):
""" Test subassembly sorting in case of multiple items with nested BOMs"""
"Test subassembly sorting in case of multiple items with nested BOMs."
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
prefix = "_TestLevel_"
@@ -385,8 +439,164 @@ class TestProductionPlan(ERPNextTestCase):
# lowest most level of subassembly should be first
self.assertIn("SuperSecret", plan.sub_assembly_items[0].production_item)
def test_multiple_work_order_for_production_plan_item(self):
"Test producing Prod Plan (making WO) in parts."
def create_work_order(item, pln, qty):
# Get Production Items
items_data = pln.get_production_items()
# Update qty
items_data[(item, None, None)]["qty"] = qty
# Create and Submit Work Order for each item in items_data
for key, item in items_data.items():
if pln.sub_assembly_items:
item['use_multi_level_bom'] = 0
wo_name = pln.create_work_order(item)
wo_doc = frappe.get_doc("Work Order", wo_name)
wo_doc.update({
'wip_warehouse': 'Work In Progress - _TC',
'fg_warehouse': 'Finished Goods - _TC'
})
wo_doc.submit()
wo_list.append(wo_name)
item = "Test Production Item 1"
raw_materials = ["Raw Material Item 1", "Raw Material Item 2"]
# Create BOM
bom = make_bom(item=item, raw_materials=raw_materials)
# Create Production Plan
pln = create_production_plan(item_code=bom.item, planned_qty=10)
# All the created Work Orders
wo_list = []
# Create and Submit 1st Work Order for 5 qty
create_work_order(item, pln, 5)
pln.reload()
self.assertEqual(pln.po_items[0].ordered_qty, 5)
# Create and Submit 2nd Work Order for 3 qty
create_work_order(item, pln, 3)
pln.reload()
self.assertEqual(pln.po_items[0].ordered_qty, 8)
# Cancel 1st Work Order
wo1 = frappe.get_doc("Work Order", wo_list[0])
wo1.cancel()
pln.reload()
self.assertEqual(pln.po_items[0].ordered_qty, 3)
# Cancel 2nd Work Order
wo2 = frappe.get_doc("Work Order", wo_list[1])
wo2.cancel()
pln.reload()
self.assertEqual(pln.po_items[0].ordered_qty, 0)
def test_production_plan_pending_qty_with_sales_order(self):
"""
Test Prod Plan impact via: SO -> Prod Plan -> WO -> SE -> SE (cancel)
"""
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
from erpnext.manufacturing.doctype.work_order.work_order import (
make_stock_entry as make_se_from_wo,
)
make_stock_entry(item_code="Raw Material Item 1",
target="Work In Progress - _TC",
qty=2, basic_rate=100
)
make_stock_entry(item_code="Raw Material Item 2",
target="Work In Progress - _TC",
qty=2, basic_rate=100
)
item = 'Test Production Item 1'
so = make_sales_order(item_code=item, qty=1)
pln = create_production_plan(
company=so.company,
get_items_from="Sales Order",
sales_order=so,
skip_getting_mr_items=True
)
self.assertEqual(pln.po_items[0].pending_qty, 1)
wo = make_wo_order_test_record(
item_code=item, qty=1,
company=so.company,
wip_warehouse='Work In Progress - _TC',
fg_warehouse='Finished Goods - _TC',
skip_transfer=1,
do_not_submit=True
)
wo.production_plan = pln.name
wo.production_plan_item = pln.po_items[0].name
wo.submit()
se = frappe.get_doc(make_se_from_wo(wo.name, "Manufacture", 1))
se.submit()
pln.reload()
self.assertEqual(pln.po_items[0].pending_qty, 0)
se.cancel()
pln.reload()
self.assertEqual(pln.po_items[0].pending_qty, 1)
def test_production_plan_pending_qty_independent_items(self):
"Test Prod Plan impact if items are added independently (no from SO or MR)."
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
from erpnext.manufacturing.doctype.work_order.work_order import (
make_stock_entry as make_se_from_wo,
)
make_stock_entry(item_code="Raw Material Item 1",
target="Work In Progress - _TC",
qty=2, basic_rate=100
)
make_stock_entry(item_code="Raw Material Item 2",
target="Work In Progress - _TC",
qty=2, basic_rate=100
)
pln = create_production_plan(
item_code='Test Production Item 1',
skip_getting_mr_items=True
)
self.assertEqual(pln.po_items[0].pending_qty, 1)
wo = make_wo_order_test_record(
item_code='Test Production Item 1', qty=1,
company=pln.company,
wip_warehouse='Work In Progress - _TC',
fg_warehouse='Finished Goods - _TC',
skip_transfer=1,
do_not_submit=True
)
wo.production_plan = pln.name
wo.production_plan_item = pln.po_items[0].name
wo.submit()
se = frappe.get_doc(make_se_from_wo(wo.name, "Manufacture", 1))
se.submit()
pln.reload()
self.assertEqual(pln.po_items[0].pending_qty, 0)
se.cancel()
pln.reload()
self.assertEqual(pln.po_items[0].pending_qty, 1)
def create_production_plan(**args):
"""
sales_order (obj): Sales Order Doc Object
get_items_from (str): Sales Order/Material Request
skip_getting_mr_items (bool): Whether or not to plan for new MRs
"""
args = frappe._dict(args)
pln = frappe.get_doc({
@@ -394,20 +604,35 @@ def create_production_plan(**args):
'company': args.company or '_Test Company',
'customer': args.customer or '_Test Customer',
'posting_date': nowdate(),
'include_non_stock_items': args.include_non_stock_items or 1,
'include_subcontracted_items': args.include_subcontracted_items or 1,
'ignore_existing_ordered_qty': args.ignore_existing_ordered_qty or 1,
'po_items': [{
'include_non_stock_items': args.include_non_stock_items or 0,
'include_subcontracted_items': args.include_subcontracted_items or 0,
'ignore_existing_ordered_qty': args.ignore_existing_ordered_qty or 0,
'get_items_from': 'Sales Order'
})
if not args.get("sales_order"):
pln.append('po_items', {
'use_multi_level_bom': args.use_multi_level_bom or 1,
'item_code': args.item_code,
'bom_no': frappe.db.get_value('Item', args.item_code, 'default_bom'),
'planned_qty': args.planned_qty or 1,
'planned_start_date': args.planned_start_date or now_datetime()
}]
})
mr_items = get_items_for_material_requests(pln.as_dict())
for d in mr_items:
pln.append('mr_items', d)
})
if args.get("get_items_from") == "Sales Order" and args.get("sales_order"):
so = args.get("sales_order")
pln.append('sales_orders', {
'sales_order': so.name,
'sales_order_date': so.transaction_date,
'customer': so.customer,
'grand_total': so.grand_total
})
pln.get_items()
if not args.get("skip_getting_mr_items"):
mr_items = get_items_for_material_requests(pln.as_dict())
for d in mr_items:
pln.append('mr_items', d)
if not args.do_not_save:
pln.insert()

View File

@@ -198,6 +198,21 @@ class TestWorkOrder(ERPNextTestCase):
self.assertEqual(cint(bin1_on_end_production.reserved_qty_for_production),
cint(bin1_on_start_production.reserved_qty_for_production))
def test_reserved_qty_for_production_closed(self):
wo1 = make_wo_order_test_record(item="_Test FG Item", qty=2,
source_warehouse=self.warehouse)
item = wo1.required_items[0].item_code
bin_before = get_bin(item, self.warehouse)
bin_before.update_reserved_qty_for_production()
make_wo_order_test_record(item="_Test FG Item", qty=2,
source_warehouse=self.warehouse)
close_work_order(wo1.name, "Closed")
bin_after = get_bin(item, self.warehouse)
self.assertEqual(bin_before.reserved_qty_for_production, bin_after.reserved_qty_for_production)
def test_backflush_qty_for_overpduction_manufacture(self):
cancel_stock_entry = []
allow_overproduction("overproduction_percentage_for_work_order", 30)
@@ -700,7 +715,8 @@ class TestWorkOrder(ERPNextTestCase):
wo = make_wo_order_test_record(item=item_name, qty=1, source_warehouse=source_warehouse,
company=company)
self.assertRaises(frappe.ValidationError, make_stock_entry, wo.name, 'Material Transfer for Manufacture')
stock_entry = frappe.get_doc(make_stock_entry(wo.name, 'Material Transfer for Manufacture'))
self.assertRaises(frappe.ValidationError, stock_entry.save)
def test_wo_completion_with_pl_bom(self):
from erpnext.manufacturing.doctype.bom.test_bom import (

View File

@@ -8,6 +8,8 @@ from dateutil.relativedelta import relativedelta
from frappe import _
from frappe.model.document import Document
from frappe.model.mapper import get_mapped_doc
from frappe.query_builder import Case
from frappe.query_builder.functions import Sum
from frappe.utils import (
cint,
date_diff,
@@ -74,7 +76,6 @@ class WorkOrder(Document):
self.set_required_items(reset_only_qty = len(self.get("required_items")))
def validate_sales_order(self):
if self.sales_order:
self.check_sales_order_on_hold_or_close()
@@ -271,7 +272,7 @@ class WorkOrder(Document):
produced_qty = total_qty[0][0] if total_qty else 0
production_plan.run_method("update_produced_qty", produced_qty, self.production_plan_item)
production_plan.run_method("update_produced_pending_qty", produced_qty, self.production_plan_item)
def before_submit(self):
self.create_serial_no_batch_no()
@@ -449,7 +450,13 @@ class WorkOrder(Document):
def update_ordered_qty(self):
if self.production_plan and self.production_plan_item:
qty = self.qty if self.docstatus == 1 else 0
qty = frappe.get_value("Production Plan Item", self.production_plan_item, "ordered_qty") or 0.0
if self.docstatus == 1:
qty += self.qty
elif self.docstatus == 2:
qty -= self.qty
frappe.db.set_value('Production Plan Item',
self.production_plan_item, 'ordered_qty', qty)
@@ -535,7 +542,7 @@ class WorkOrder(Document):
if node.is_bom:
operations.extend(_get_operations(node.name, qty=node.exploded_qty))
bom_qty = frappe.db.get_value("BOM", self.bom_no, "quantity")
bom_qty = frappe.get_cached_value("BOM", self.bom_no, "quantity")
operations.extend(_get_operations(self.bom_no, qty=1.0/bom_qty))
for correct_index, operation in enumerate(operations, start=1):
@@ -615,7 +622,7 @@ class WorkOrder(Document):
frappe.delete_doc("Job Card", d.name)
def validate_production_item(self):
if frappe.db.get_value("Item", self.production_item, "has_variants"):
if frappe.get_cached_value("Item", self.production_item, "has_variants"):
frappe.throw(_("Work Order cannot be raised against a Item Template"), ItemHasVariantError)
if self.production_item:
@@ -1165,3 +1172,27 @@ def create_pick_list(source_name, target_doc=None, for_qty=None):
doc.set_item_locations()
return doc
def get_reserved_qty_for_production(item_code: str, warehouse: str) -> float:
"""Get total reserved quantity for any item in specified warehouse"""
wo = frappe.qb.DocType("Work Order")
wo_item = frappe.qb.DocType("Work Order Item")
return (
frappe.qb
.from_(wo)
.from_(wo_item)
.select(Sum(Case()
.when(wo.skip_transfer == 0, wo_item.required_qty - wo_item.transferred_qty)
.else_(wo_item.required_qty - wo_item.consumed_qty))
)
.where(
(wo_item.item_code == item_code)
& (wo_item.parent == wo.name)
& (wo.docstatus == 1)
& (wo_item.source_warehouse == warehouse)
& (wo.status.notin(["Stopped", "Completed", "Closed"]))
& ((wo_item.required_qty > wo_item.transferred_qty)
| (wo_item.required_qty > wo_item.consumed_qty))
)
).run()[0][0] or 0.0

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)
def get_manufacturer_records():
details = frappe.get_all('Item Manufacturer', fields = ["manufacturer", "manufacturer_part_no", "parent"])
details = frappe.get_all('Item Manufacturer', fields = ["manufacturer", "manufacturer_part_no", "item_code"])
manufacture_details = frappe._dict()
for detail in details:
dic = manufacture_details.setdefault(detail.get('parent'), {})
dic = manufacture_details.setdefault(detail.get('item_code'), {})
dic.setdefault('manufacturer', []).append(detail.get('manufacturer'))
dic.setdefault('manufacturer_part', []).append(detail.get('manufacturer_part_no'))

View File

@@ -347,4 +347,7 @@ erpnext.patches.v13_0.hospitality_deprecation_warning
erpnext.patches.v13_0.delete_bank_reconciliation_detail
erpnext.patches.v13_0.update_sane_transfer_against
erpnext.patches.v13_0.enable_provisional_accounting
erpnext.patches.v13_0.update_disbursement_account
erpnext.patches.v13_0.update_disbursement_account
erpnext.patches.v13_0.update_reserved_qty_closed_wo
erpnext.patches.v13_0.amazon_mws_deprecation_warning
erpnext.patches.v13_0.set_work_order_qty_in_so_from_mr

View File

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

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:
update_bin_qty(item_code, warehouse, {
"reserved_qty": get_reserved_qty(item_code, warehouse)
})
if not (item_code and warehouse):
continue
update_bin_qty(item_code, warehouse, {
"reserved_qty": get_reserved_qty(item_code, warehouse)
})
frappe.db.sql("""delete from tabBin
where exists(

View File

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

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

View File

@@ -125,7 +125,7 @@ class TestPayrollEntry(unittest.TestCase):
if not frappe.db.exists("Account", "_Test Payroll Payable - _TC"):
create_account(account_name="_Test Payroll Payable",
company="_Test Company", parent_account="Current Liabilities - _TC")
company="_Test Company", parent_account="Current Liabilities - _TC", account_type="Payable")
if not frappe.db.get_value("Company", "_Test Company", "default_payroll_payable_account") or \
frappe.db.get_value("Company", "_Test Company", "default_payroll_payable_account") != "_Test Payroll Payable - _TC":

View File

@@ -147,7 +147,7 @@ class TestSalarySlip(unittest.TestCase):
# Payroll based on attendance
frappe.db.set_value("Payroll Settings", None, "payroll_based_on", "Attendance")
emp = make_employee("test_employee_timesheet@salary.com", company="_Test Company")
emp = make_employee("test_employee_timesheet@salary.com", company="_Test Company", holiday_list="Salary Slip Test Holiday List")
frappe.db.set_value("Employee", emp, {"relieving_date": None, "status": "Active"})
# mark attendance
@@ -731,7 +731,7 @@ def get_salary_component_account(sal_comp, company_list=None):
})
sal_comp.save()
def create_account(account_name, company, parent_account):
def create_account(account_name, company, parent_account, account_type=None):
company_abbr = frappe.get_cached_value('Company', company, 'abbr')
account = frappe.db.get_value("Account", account_name + " - " + company_abbr)
if not account:

View File

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

View File

@@ -1443,7 +1443,8 @@ erpnext.TransactionController = erpnext.taxes_and_totals.extend({
"item_code": d.item_code,
"pricing_rules": d.pricing_rules,
"parenttype": d.parenttype,
"parent": d.parent
"parent": d.parent,
"price_list_rate": d.price_list_rate
})
}
});
@@ -2264,19 +2265,12 @@ erpnext.TransactionController = erpnext.taxes_and_totals.extend({
coupon_code: function() {
var me = this;
if (this.frm.doc.coupon_code) {
frappe.run_serially([
() => this.frm.doc.ignore_pricing_rule=1,
() => me.ignore_pricing_rule(),
() => this.frm.doc.ignore_pricing_rule=0,
() => me.apply_pricing_rule()
]);
} else {
frappe.run_serially([
() => this.frm.doc.ignore_pricing_rule=1,
() => me.ignore_pricing_rule()
]);
}
frappe.run_serially([
() => this.frm.doc.ignore_pricing_rule=1,
() => me.ignore_pricing_rule(),
() => this.frm.doc.ignore_pricing_rule=0,
() => me.apply_pricing_rule()
]);
}
});

View File

@@ -296,6 +296,10 @@ class GSTR3BReport(Document):
inter_state_supply_details = {}
for inv, items_based_on_rate in self.items_based_on_tax_rate.items():
gst_category = self.invoice_detail_map.get(inv, {}).get('gst_category')
place_of_supply = self.invoice_detail_map.get(inv, {}).get('place_of_supply') or '00-Other Territory'
export_type = self.invoice_detail_map.get(inv, {}).get('export_type')
for rate, items in items_based_on_rate.items():
for item_code, taxable_value in self.invoice_items.get(inv).items():
if item_code in items:
@@ -303,9 +307,8 @@ class GSTR3BReport(Document):
self.report_dict['sup_details']['osup_nil_exmp']['txval'] += taxable_value
elif item_code in self.is_non_gst:
self.report_dict['sup_details']['osup_nongst']['txval'] += taxable_value
elif rate == 0:
elif rate == 0 or (gst_category == 'Overseas' and export_type == 'Without Payment of Tax'):
self.report_dict['sup_details']['osup_zero']['txval'] += taxable_value
#self.report_dict['sup_details']['osup_zero'][key] += tax_amount
else:
if inv in self.cgst_sgst_invoices:
tax_rate = rate/2
@@ -316,9 +319,6 @@ class GSTR3BReport(Document):
self.report_dict['sup_details']['osup_det']['iamt'] += (taxable_value * rate /100)
self.report_dict['sup_details']['osup_det']['txval'] += taxable_value
gst_category = self.invoice_detail_map.get(inv, {}).get('gst_category')
place_of_supply = self.invoice_detail_map.get(inv, {}).get('place_of_supply') or '00-Other Territory'
if gst_category in ['Unregistered', 'Registered Composition', 'UIN Holders'] and \
self.gst_details.get("gst_state") != place_of_supply.split("-")[1]:
inter_state_supply_details.setdefault((gst_category, place_of_supply), {

View File

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

View File

@@ -29,7 +29,7 @@ class Gstr1Report(object):
posting_date,
base_grand_total,
base_rounded_total,
COALESCE(NULLIF(customer_gstin,''), NULLIF(billing_address_gstin, '')) as customer_gstin,
NULLIF(billing_address_gstin, '') as billing_address_gstin,
place_of_supply,
ecommerce_gstin,
reverse_charge,
@@ -260,7 +260,7 @@ class Gstr1Report(object):
if self.filters.get("type_of_business") == "B2B":
conditions += "AND IFNULL(gst_category, '') in ('Registered Regular', 'Deemed Export', 'SEZ') AND is_return != 1 AND is_debit_note !=1"
conditions += "AND IFNULL(gst_category, '') in ('Registered Regular', 'Registered Composition', 'Deemed Export', 'SEZ') AND is_return != 1 AND is_debit_note !=1"
if self.filters.get("type_of_business") in ("B2C Large", "B2C Small"):
b2c_limit = frappe.db.get_single_value('GST Settings', 'b2c_limit')
@@ -384,7 +384,7 @@ class Gstr1Report(object):
for invoice, items in iteritems(self.invoice_items):
if invoice not in self.items_based_on_tax_rate and invoice not in unidentified_gst_accounts_invoice \
and self.invoices.get(invoice, {}).get('export_type') == "Without Payment of Tax" \
and self.invoices.get(invoice, {}).get('gst_category') == "Overseas":
and self.invoices.get(invoice, {}).get('gst_category') in ("Overseas", "SEZ"):
self.items_based_on_tax_rate.setdefault(invoice, {}).setdefault(0, items.keys())
def get_columns(self):
@@ -410,7 +410,7 @@ class Gstr1Report(object):
if self.filters.get("type_of_business") == "B2B":
self.invoice_columns = [
{
"fieldname": "customer_gstin",
"fieldname": "billing_address_gstin",
"label": "GSTIN/UIN of Recipient",
"fieldtype": "Data",
"width": 150
@@ -517,7 +517,7 @@ class Gstr1Report(object):
elif self.filters.get("type_of_business") == "CDNR-REG":
self.invoice_columns = [
{
"fieldname": "customer_gstin",
"fieldname": "billing_address_gstin",
"label": "GSTIN/UIN of Recipient",
"fieldtype": "Data",
"width": 150
@@ -818,7 +818,7 @@ def get_json(filters, report_name, data):
res = {}
if filters["type_of_business"] == "B2B":
for item in report_data[:-1]:
res.setdefault(item["customer_gstin"], {}).setdefault(item["invoice_number"],[]).append(item)
res.setdefault(item["billing_address_gstin"], {}).setdefault(item["invoice_number"],[]).append(item)
out = get_b2b_json(res, gstin)
gst_json["b2b"] = out
@@ -842,7 +842,7 @@ def get_json(filters, report_name, data):
gst_json["exp"] = out
elif filters["type_of_business"] == "CDNR-REG":
for item in report_data[:-1]:
res.setdefault(item["customer_gstin"], {}).setdefault(item["invoice_number"],[]).append(item)
res.setdefault(item["billing_address_gstin"], {}).setdefault(item["invoice_number"],[]).append(item)
out = get_cdnr_reg_json(res, gstin)
gst_json["cdnr"] = out
@@ -876,7 +876,7 @@ def get_json(filters, report_name, data):
}
def get_b2b_json(res, gstin):
inv_type, out = {"Registered Regular": "R", "Deemed Export": "DE", "URD": "URD", "SEZ": "SEZ"}, []
out = []
for gst_in in res:
b2b_item, inv = {"ctin": gst_in, "inv": []}, []
if not gst_in: continue
@@ -890,7 +890,7 @@ def get_b2b_json(res, gstin):
inv_item = get_basic_invoice_detail(invoice[0])
inv_item["pos"] = "%02d" % int(invoice[0]["place_of_supply"].split('-')[0])
inv_item["rchrg"] = invoice[0]["reverse_charge"]
inv_item["inv_typ"] = inv_type.get(invoice[0].get("gst_category", ""),"")
inv_item["inv_typ"] = get_invoice_type(invoice[0])
if inv_item["pos"]=="00": continue
inv_item["itms"] = []
@@ -1045,7 +1045,7 @@ def get_cdnr_reg_json(res, gstin):
"ntty": invoice[0]["document_type"],
"pos": "%02d" % int(invoice[0]["place_of_supply"].split('-')[0]),
"rchrg": invoice[0]["reverse_charge"],
"inv_typ": get_invoice_type_for_cdnr(invoice[0])
"inv_typ": get_invoice_type(invoice[0])
}
inv_item["itms"] = []
@@ -1070,7 +1070,7 @@ def get_cdnr_unreg_json(res, gstin):
"val": abs(flt(items[0]["invoice_value"])),
"ntty": items[0]["document_type"],
"pos": "%02d" % int(items[0]["place_of_supply"].split('-')[0]),
"typ": get_invoice_type_for_cdnrur(items[0])
"typ": get_invoice_type(items[0])
}
inv_item["itms"] = []
@@ -1111,29 +1111,21 @@ def get_exempted_json(data):
return out
def get_invoice_type_for_cdnr(row):
if row.get('gst_category') == 'SEZ':
if row.get('export_type') == 'WPAY':
invoice_type = 'SEWP'
else:
invoice_type = 'SEWOP'
elif row.get('gst_category') == 'Deemed Export':
invoice_type = 'DE'
elif row.get('gst_category') == 'Registered Regular':
invoice_type = 'R'
def get_invoice_type(row):
gst_category = row.get('gst_category')
return invoice_type
if gst_category == 'SEZ':
return 'SEWP' if row.get('export_type') == 'WPAY' else 'SEWOP'
def get_invoice_type_for_cdnrur(row):
if row.get('gst_category') == 'Overseas':
if row.get('export_type') == 'WPAY':
invoice_type = 'EXPWP'
else:
invoice_type = 'EXPWOP'
elif row.get('gst_category') == 'Unregistered':
invoice_type = 'B2CL'
if gst_category == 'Overseas':
return 'EXPWP' if row.get('export_type') == 'WPAY' else 'EXPWOP'
return invoice_type
return ({
'Deemed Export': 'DE',
'Registered Regular': 'R',
'Registered Composition': 'R',
'Unregistered': 'B2CL'
}).get(gst_category)
def get_basic_invoice_detail(row):
return {
@@ -1155,7 +1147,7 @@ def get_rate_and_tax_details(row, gstin):
# calculate tax amount added
tax = flt((row["taxable_value"]*rate)/100.0, 2)
frappe.errprint([tax, tax/2])
if row.get("customer_gstin") and gstin[0:2] == row["customer_gstin"][0:2]:
if row.get("billing_address_gstin") and gstin[0:2] == row["billing_address_gstin"][0:2]:
itm_det.update({"camt": flt(tax/2.0, 2), "samt": flt(tax/2.0, 2)})
else:
itm_det.update({"iamt": tax})
@@ -1200,4 +1192,4 @@ def is_inter_state(invoice_detail):
if invoice_detail.place_of_supply.split("-")[0] != invoice_detail.company_gstin[:2]:
return True
else:
return False
return False

View File

@@ -6,7 +6,7 @@ import json
import frappe
import frappe.permissions
from frappe.core.doctype.user_permission.test_user_permission import create_user
from frappe.utils import add_days, flt, getdate, nowdate
from frappe.utils import add_days, flt, getdate, nowdate, today
from erpnext.controllers.accounts_controller import update_child_qty_rate
from erpnext.manufacturing.doctype.blanket_order.test_blanket_order import make_blanket_order
@@ -1271,6 +1271,72 @@ class TestSalesOrder(ERPNextTestCase):
automatically_fetch_payment_terms(enable=0)
def test_zero_amount_sales_order_billing_status(self):
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
so = make_sales_order(uom="Nos", do_not_save=1)
so.items[0].rate = 0
so.save()
so.submit()
self.assertEqual(so.net_total, 0)
self.assertEqual(so.billing_status, 'Not Billed')
si = create_sales_invoice(qty=10, do_not_save=1)
si.price_list = '_Test Price List'
si.items[0].rate = 0
si.items[0].price_list_rate = 0
si.items[0].sales_order = so.name
si.items[0].so_detail = so.items[0].name
si.save()
si.submit()
self.assertEqual(si.net_total, 0)
so.load_from_db()
self.assertEqual(so.billing_status, 'Fully Billed')
def test_so_back_updated_from_wo_via_mr(self):
"SO -> MR (Manufacture) -> WO. Test if WO Qty is updated in SO."
from erpnext.manufacturing.doctype.work_order.work_order import (
make_stock_entry as make_se_from_wo,
)
from erpnext.stock.doctype.material_request.material_request import raise_work_orders
so = make_sales_order(item_list=[{"item_code": "_Test FG Item","qty": 2, "rate":100}])
mr = make_material_request(so.name)
mr.material_request_type = "Manufacture"
mr.schedule_date = today()
mr.submit()
# WO from MR
wo_name = raise_work_orders(mr.name)[0]
wo = frappe.get_doc("Work Order", wo_name)
wo.wip_warehouse = "Work In Progress - _TC"
wo.skip_transfer = True
self.assertEqual(wo.sales_order, so.name)
self.assertEqual(wo.sales_order_item, so.items[0].name)
wo.submit()
make_stock_entry(item_code="_Test Item", # Stock RM
target="Work In Progress - _TC",
qty=4, basic_rate=100
)
make_stock_entry(item_code="_Test Item Home Desktop 100", # Stock RM
target="Work In Progress - _TC",
qty=4, basic_rate=100
)
se = frappe.get_doc(make_se_from_wo(wo.name, "Manufacture", 2))
se.submit() # Finish WO
mr.reload()
wo.reload()
so.reload()
self.assertEqual(so.items[0].work_order_qty, wo.produced_qty)
self.assertEqual(mr.status, "Manufactured")
def automatically_fetch_payment_terms(enable=1):
accounts_settings = frappe.get_doc("Accounts Settings")
accounts_settings.automatically_fetch_payment_terms = enable

View File

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

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"],
as_dict=1)
item_stock_qty = get_stock_availability(item_code, warehouse)
item_stock_qty, is_stock_item = get_stock_availability(item_code, warehouse)
price_list_rate, currency = frappe.db.get_value('Item Price', {
'price_list': price_list,
'item_code': item_code
@@ -99,7 +99,6 @@ def get_items(start, page_length, price_list, item_group, pos_profile, search_te
), {'warehouse': warehouse}, as_dict=1)
if items_data:
items_data = filter_service_items(items_data)
items = [d.item_code for d in items_data]
item_prices_data = frappe.get_all("Item Price",
fields = ["item_code", "price_list_rate", "currency"],
@@ -112,7 +111,7 @@ def get_items(start, page_length, price_list, item_group, pos_profile, search_te
for item in items_data:
item_code = item.item_code
item_price = item_prices.get(item_code) or {}
item_stock_qty = get_stock_availability(item_code, warehouse)
item_stock_qty, is_stock_item = get_stock_availability(item_code, warehouse)
row = {}
row.update(item)
@@ -144,14 +143,6 @@ def search_for_serial_or_batch_or_barcode_number(search_value):
return {}
def filter_service_items(items):
for item in items:
if not item['is_stock_item']:
if not frappe.db.exists('Product Bundle', item['item_code']):
items.remove(item)
return items
def get_conditions(search_term):
condition = "("
condition += """item.name like {search_term}

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -31,23 +31,9 @@ class Bin(Document):
def update_reserved_qty_for_production(self):
'''Update qty reserved for production from Production Item tables
in open work orders'''
self.reserved_qty_for_production = frappe.db.sql('''
SELECT
SUM(CASE WHEN ifnull(skip_transfer, 0) = 0 THEN
item.required_qty - item.transferred_qty
ELSE
item.required_qty - item.consumed_qty END)
END
FROM `tabWork Order` pro, `tabWork Order Item` item
WHERE
item.item_code = %s
and item.parent = pro.name
and pro.docstatus = 1
and item.source_warehouse = %s
and pro.status not in ("Stopped", "Completed")
and (item.required_qty > item.transferred_qty or item.required_qty > item.consumed_qty)
''', (self.item_code, self.warehouse))[0][0]
from erpnext.manufacturing.doctype.work_order.work_order import get_reserved_qty_for_production
self.reserved_qty_for_production = get_reserved_qty_for_production(self.item_code, self.warehouse)
self.set_projected_qty()
self.db_set('reserved_qty_for_production', flt(self.reserved_qty_for_production))

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"))
def update_billed_amount_based_on_so(so_detail, update_modified=True):
from frappe.query_builder.functions import Sum
# Billed against Sales Order directly
billed_against_so = frappe.db.sql("""select sum(amount) from `tabSales Invoice Item`
where so_detail=%s and (dn_detail is null or dn_detail = '') and docstatus=1""", so_detail)
si_item = frappe.qb.DocType("Sales Invoice Item").as_("si_item")
sum_amount = Sum(si_item.amount).as_("amount")
billed_against_so = frappe.qb.from_(si_item).select(sum_amount).where(
(si_item.so_detail == so_detail) &
((si_item.dn_detail.isnull()) | (si_item.dn_detail == '')) &
(si_item.docstatus == 1)
).run()
billed_against_so = billed_against_so and billed_against_so[0][0] or 0
# Get all Delivery Note Item rows against the Sales Order Item row
dn_details = frappe.db.sql("""select dn_item.name, dn_item.amount, dn_item.si_detail, dn_item.parent
from `tabDelivery Note Item` dn_item, `tabDelivery Note` dn
where dn.name=dn_item.parent and dn_item.so_detail=%s
and dn.docstatus=1 and dn.is_return = 0
order by dn.posting_date asc, dn.posting_time asc, dn.name asc""", so_detail, as_dict=1)
dn = frappe.qb.DocType("Delivery Note").as_("dn")
dn_item = frappe.qb.DocType("Delivery Note Item").as_("dn_item")
dn_details = frappe.qb.from_(dn).from_(dn_item).select(dn_item.name, dn_item.amount, dn_item.si_detail, dn_item.parent).where(
(dn.name == dn_item.parent) &
(dn_item.so_detail == so_detail) &
(dn.docstatus == 1) &
(dn.is_return == 0)
).orderby(
dn.posting_date, dn.posting_time, dn.name
).run(as_dict=True)
updated_dn = []
for dnd in dn_details:

View File

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

View File

@@ -4,10 +4,11 @@
import frappe
from frappe.utils import flt
from frappe.utils import add_to_date, flt, now
from erpnext.accounts.doctype.account.test_account import create_account, get_inventory_account
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
from erpnext.accounts.utils import update_gl_entries_after
from erpnext.assets.doctype.asset.test_asset import create_asset_category, create_fixed_asset_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import (
get_gl_entries,
@@ -28,7 +29,8 @@ class TestLandedCostVoucher(ERPNextTestCase):
"voucher_type": pr.doctype,
"voucher_no": pr.name,
"item_code": "_Test Item",
"warehouse": "Stores - TCP1"
"warehouse": "Stores - TCP1",
"is_cancelled": 0,
},
fieldname=["qty_after_transaction", "stock_value"], as_dict=1)
@@ -41,14 +43,39 @@ class TestLandedCostVoucher(ERPNextTestCase):
"voucher_type": pr.doctype,
"voucher_no": pr.name,
"item_code": "_Test Item",
"warehouse": "Stores - TCP1"
"warehouse": "Stores - TCP1",
"is_cancelled": 0,
},
fieldname=["qty_after_transaction", "stock_value"], as_dict=1)
self.assertEqual(last_sle.qty_after_transaction, last_sle_after_landed_cost.qty_after_transaction)
self.assertEqual(last_sle_after_landed_cost.stock_value - last_sle.stock_value, 25.0)
# assert after submit
self.assertPurchaseReceiptLCVGLEntries(pr)
# Mess up cancelled SLE modified timestamp to check
# if they aren't effective in any business logic.
frappe.db.set_value("Stock Ledger Entry",
{
"is_cancelled": 1,
"voucher_type": pr.doctype,
"voucher_no": pr.name
},
"is_cancelled", 1,
modified=add_to_date(now(), hours=1, as_datetime=True, as_string=True)
)
items, warehouses = pr.get_items_and_warehouses()
update_gl_entries_after(pr.posting_date, pr.posting_time,
warehouses, items, company=pr.company)
# reassert after reposting
self.assertPurchaseReceiptLCVGLEntries(pr)
def assertPurchaseReceiptLCVGLEntries(self, pr):
gl_entries = get_gl_entries("Purchase Receipt", pr.name)
self.assertTrue(gl_entries)
@@ -74,8 +101,8 @@ class TestLandedCostVoucher(ERPNextTestCase):
for gle in gl_entries:
if not gle.get('is_cancelled'):
self.assertEqual(expected_values[gle.account][0], gle.debit)
self.assertEqual(expected_values[gle.account][1], gle.credit)
self.assertEqual(expected_values[gle.account][0], gle.debit, msg=f"incorrect debit for {gle.account}")
self.assertEqual(expected_values[gle.account][1], gle.credit, msg=f"incorrect credit for {gle.account}")
def test_landed_cost_voucher_against_purchase_invoice(self):

View File

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

View File

@@ -1,10 +1,14 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
from frappe.utils import add_to_date, nowdate
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import get_gl_entries
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.tests.utils import ERPNextTestCase, change_settings
@@ -12,31 +16,30 @@ class TestPackedItem(ERPNextTestCase):
"Test impact on Packed Items table in various scenarios."
@classmethod
def setUpClass(cls) -> None:
make_item("_Test Product Bundle X", {"is_stock_item": 0})
make_item("_Test Bundle Item 1", {"is_stock_item": 1})
make_item("_Test Bundle Item 2", {"is_stock_item": 1})
super().setUpClass()
cls.bundle = "_Test Product Bundle X"
cls.bundle_items = ["_Test Bundle Item 1", "_Test Bundle Item 2"]
make_item(cls.bundle, {"is_stock_item": 0})
for item in cls.bundle_items:
make_item(item, {"is_stock_item": 1})
make_item("_Test Normal Stock Item", {"is_stock_item": 1})
make_product_bundle(
"_Test Product Bundle X",
["_Test Bundle Item 1", "_Test Bundle Item 2"],
qty=2
)
make_product_bundle(cls.bundle, cls.bundle_items, qty=2)
def test_adding_bundle_item(self):
"Test impact on packed items if bundle item row is added."
so = make_sales_order(item_code = "_Test Product Bundle X", qty=1,
so = make_sales_order(item_code = self.bundle, qty=1,
do_not_submit=True)
self.assertEqual(so.items[0].qty, 1)
self.assertEqual(len(so.packed_items), 2)
self.assertEqual(so.packed_items[0].item_code, "_Test Bundle Item 1")
self.assertEqual(so.packed_items[0].item_code, self.bundle_items[0])
self.assertEqual(so.packed_items[0].qty, 2)
def test_updating_bundle_item(self):
"Test impact on packed items if bundle item row is updated."
so = make_sales_order(item_code = "_Test Product Bundle X", qty=1,
do_not_submit=True)
so = make_sales_order(item_code=self.bundle, qty=1, do_not_submit=True)
so.items[0].qty = 2 # change qty
so.save()
@@ -55,7 +58,7 @@ class TestPackedItem(ERPNextTestCase):
so_items = []
for qty in [2, 4, 6, 8]:
so_items.append({
"item_code": "_Test Product Bundle X",
"item_code": self.bundle,
"qty": qty,
"rate": 400,
"warehouse": "_Test Warehouse - _TC"
@@ -66,7 +69,7 @@ class TestPackedItem(ERPNextTestCase):
# check alternate rows for qty
self.assertEqual(len(so.packed_items), 8)
self.assertEqual(so.packed_items[1].item_code, "_Test Bundle Item 2")
self.assertEqual(so.packed_items[1].item_code, self.bundle_items[1])
self.assertEqual(so.packed_items[1].qty, 4)
self.assertEqual(so.packed_items[3].qty, 8)
self.assertEqual(so.packed_items[5].qty, 12)
@@ -94,8 +97,7 @@ class TestPackedItem(ERPNextTestCase):
@change_settings("Selling Settings", {"editable_bundle_item_rates": 1})
def test_bundle_item_cumulative_price(self):
"Test if Bundle Item rate is cumulative from packed items."
so = make_sales_order(item_code = "_Test Product Bundle X", qty=2,
do_not_submit=True)
so = make_sales_order(item_code=self.bundle, qty=2, do_not_submit=True)
so.packed_items[0].rate = 150
so.packed_items[1].rate = 200
@@ -109,7 +111,7 @@ class TestPackedItem(ERPNextTestCase):
so_items = []
for qty in [2, 4]:
so_items.append({
"item_code": "_Test Product Bundle X",
"item_code": self.bundle,
"qty": qty,
"rate": 400,
"warehouse": "_Test Warehouse - _TC"
@@ -124,4 +126,33 @@ class TestPackedItem(ERPNextTestCase):
self.assertEqual(len(dn.packed_items), 4)
self.assertEqual(dn.packed_items[2].qty, 6)
self.assertEqual(dn.packed_items[3].qty, 6)
self.assertEqual(dn.packed_items[3].qty, 6)
def test_reposting_packed_items(self):
warehouse = "Stores - TCP1"
company = "_Test Company with perpetual inventory"
today = nowdate()
yesterday = add_to_date(today, days=-1, as_string=True)
for item in self.bundle_items:
make_stock_entry(item_code=item, to_warehouse=warehouse, qty=10, rate=100, posting_date=today)
so = make_sales_order(item_code = self.bundle, qty=1, company=company, warehouse=warehouse)
dn = make_delivery_note(so.name)
dn.save()
dn.submit()
gles = get_gl_entries(dn.doctype, dn.name)
credit_before_repost = sum(gle.credit for gle in gles)
# backdated stock entry
for item in self.bundle_items:
make_stock_entry(item_code=item, to_warehouse=warehouse, qty=10, rate=200, posting_date=yesterday)
# assert correct reposting
gles = get_gl_entries(dn.doctype, dn.name)
credit_after_reposting = sum(gle.credit for gle in gles)
self.assertNotEqual(credit_before_repost, credit_after_reposting)
self.assertAlmostEqual(credit_after_reposting, 2 * credit_before_repost)

View File

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

View File

@@ -4,6 +4,7 @@
import json
import unittest
from collections import defaultdict
import frappe
from frappe.utils import add_days, cint, cstr, flt, today
@@ -17,7 +18,7 @@ from erpnext.stock.doctype.purchase_receipt.purchase_receipt import make_purchas
from erpnext.stock.doctype.serial_no.serial_no import SerialNoDuplicateError, get_serial_nos
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
from erpnext.stock.stock_ledger import SerialNoExistsInFutureTransaction
from erpnext.tests.utils import ERPNextTestCase
from erpnext.tests.utils import ERPNextTestCase, change_settings
class TestPurchaseReceipt(ERPNextTestCase):
@@ -1367,6 +1368,36 @@ class TestPurchaseReceipt(ERPNextTestCase):
automatically_fetch_payment_terms(enable=0)
@change_settings("Stock Settings", {"allow_negative_stock": 1})
def test_neg_to_positive(self):
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
item_code = "_TestNegToPosItem"
warehouse = "Stores - TCP1"
company = "_Test Company with perpetual inventory"
account = "Stock Received But Not Billed - TCP1"
make_item(item_code)
se = make_stock_entry(item_code=item_code, from_warehouse=warehouse, qty=50, do_not_save=True, rate=0)
se.items[0].allow_zero_valuation_rate = 1
se.save()
se.submit()
pr = make_purchase_receipt(
qty=50,
rate=1,
item_code=item_code,
warehouse=warehouse,
get_taxes_and_charges=True,
company=company,
)
gles = get_gl_entries(pr.doctype, pr.name)
for gle in gles:
if gle.account == account:
self.assertEqual(gle.credit, 50)
def get_sl_entries(voucher_type, voucher_no):
return frappe.db.sql(""" select actual_qty, warehouse, stock_value_difference
from `tabStock Ledger Entry` where voucher_type=%s and voucher_no=%s

View File

@@ -13,7 +13,7 @@ from erpnext.accounts.utils import (
check_if_stock_and_account_balance_synced,
update_gl_entries_after,
)
from erpnext.stock.stock_ledger import repost_future_sle
from erpnext.stock.stock_ledger import get_items_to_be_repost, repost_future_sle
class RepostItemValuation(Document):
@@ -138,13 +138,20 @@ def repost_gl_entries(doc):
if doc.based_on == 'Transaction':
ref_doc = frappe.get_doc(doc.voucher_type, doc.voucher_no)
items, warehouses = ref_doc.get_items_and_warehouses()
doc_items, doc_warehouses = ref_doc.get_items_and_warehouses()
sles = get_items_to_be_repost(doc.voucher_type, doc.voucher_no)
sle_items = [sle.item_code for sle in sles]
sle_warehouse = [sle.warehouse for sle in sles]
items = list(set(doc_items).union(set(sle_items)))
warehouses = list(set(doc_warehouses).union(set(sle_warehouse)))
else:
items = [doc.item_code]
warehouses = [doc.warehouse]
update_gl_entries_after(doc.posting_date, doc.posting_time,
warehouses, items, company=doc.company)
for_warehouses=warehouses, for_items=items, company=doc.company)
def notify_error_to_stock_managers(doc, traceback):
recipients = get_users_with_role("Stock Manager")

View File

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

View File

@@ -77,7 +77,6 @@ class StockEntry(StockController):
self.validate_posting_time()
self.validate_purpose()
self.set_title()
self.validate_item()
self.validate_customer_provided_item()
self.validate_qty()
@@ -1117,7 +1116,7 @@ class StockEntry(StockController):
self.set_actual_qty()
self.update_items_for_process_loss()
self.validate_customer_provided_item()
self.calculate_rate_and_amount()
self.calculate_rate_and_amount(raise_error_if_no_rate=False)
def set_scrap_items(self):
if self.purpose != "Send to Subcontractor" and self.purpose in ["Manufacture", "Repack"]:
@@ -1837,14 +1836,6 @@ class StockEntry(StockController):
return sorted(list(set(get_serial_nos(self.pro_doc.serial_no)) - set(used_serial_nos)))
def set_title(self):
if frappe.flags.in_import and self.title:
# Allow updating title during data import/update
return
self.title = self.purpose
@frappe.whitelist()
def move_sample_to_retention_warehouse(company, items):
if isinstance(items, string_types):

View File

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

View File

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

View File

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

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
Filters = frappe._dict
precision = cint(frappe.db.get_single_value("System Settings", "float_precision"))
def execute(filters: Filters = None) -> Tuple:
to_date = filters["to_date"]
@@ -48,10 +49,13 @@ def format_report_data(filters: Filters, item_details: Dict, to_date: str) -> Li
if filters.get("show_warehouse_wise_stock"):
row.append(details.warehouse)
row.extend([item_dict.get("total_qty"), average_age,
row.extend([
flt(item_dict.get("total_qty"), precision),
average_age,
range1, range2, range3, above_range3,
earliest_age, latest_age,
details.stock_uom])
details.stock_uom
])
data.append(row)
@@ -79,13 +83,13 @@ def get_range_age(filters: Filters, fifo_queue: List, to_date: str, item_dict: D
qty = flt(item[0]) if not item_dict["has_serial_no"] else 1.0
if age <= filters.range1:
range1 += qty
range1 = flt(range1 + qty, precision)
elif age <= filters.range2:
range2 += qty
range2 = flt(range2 + qty, precision)
elif age <= filters.range3:
range3 += qty
range3 = flt(range3 + qty, precision)
else:
above_range3 += qty
above_range3 = flt(above_range3 + qty, precision)
return range1, range2, range3, above_range3
@@ -252,6 +256,7 @@ class FIFOSlots:
key, fifo_queue, transferred_item_key = self.__init_key_stores(d)
if d.voucher_type == "Stock Reconciliation":
# get difference in qty shift as actual qty
prev_balance_qty = self.item_details[key].get("qty_after_transaction", 0)
d.actual_qty = flt(d.qty_after_transaction) - flt(prev_balance_qty)
@@ -264,12 +269,16 @@ class FIFOSlots:
self.__update_balances(d, key)
if not self.filters.get("show_warehouse_wise_stock"):
# (Item 1, WH 1), (Item 1, WH 2) => (Item 1)
self.item_details = self.__aggregate_details_by_item(self.item_details)
return self.item_details
def __init_key_stores(self, row: Dict) -> Tuple:
"Initialise keys and FIFO Queue."
key = (row.name, row.warehouse) if self.filters.get('show_warehouse_wise_stock') else row.name
key = (row.name, row.warehouse)
self.item_details.setdefault(key, {"details": row, "fifo_queue": []})
fifo_queue = self.item_details[key]["fifo_queue"]
@@ -281,14 +290,16 @@ class FIFOSlots:
def __compute_incoming_stock(self, row: Dict, fifo_queue: List, transfer_key: Tuple, serial_nos: List):
"Update FIFO Queue on inward stock."
if self.transferred_item_details.get(transfer_key):
transfer_data = self.transferred_item_details.get(transfer_key)
if transfer_data:
# inward/outward from same voucher, item & warehouse
slot = self.transferred_item_details[transfer_key].pop(0)
fifo_queue.append(slot)
# eg: Repack with same item, Stock reco for batch item
# consume transfer data and add stock to fifo queue
self.__adjust_incoming_transfer_qty(transfer_data, fifo_queue, row)
else:
if not serial_nos:
if fifo_queue and flt(fifo_queue[0][0]) < 0:
# neutralize negative stock by adding positive stock
if fifo_queue and flt(fifo_queue[0][0]) <= 0:
# neutralize 0/negative stock by adding positive stock
fifo_queue[0][0] += flt(row.actual_qty)
fifo_queue[0][1] = row.posting_date
else:
@@ -319,7 +330,7 @@ class FIFOSlots:
elif not fifo_queue:
# negative stock, no balance but qty yet to consume
fifo_queue.append([-(qty_to_pop), row.posting_date])
self.transferred_item_details[transfer_key].append([row.actual_qty, row.posting_date])
self.transferred_item_details[transfer_key].append([qty_to_pop, row.posting_date])
qty_to_pop = 0
else:
# qty to pop < slot qty, ample balance
@@ -328,6 +339,33 @@ class FIFOSlots:
self.transferred_item_details[transfer_key].append([qty_to_pop, slot[1]])
qty_to_pop = 0
def __adjust_incoming_transfer_qty(self, transfer_data: Dict, fifo_queue: List, row: Dict):
"Add previously removed stock back to FIFO Queue."
transfer_qty_to_pop = flt(row.actual_qty)
def add_to_fifo_queue(slot):
if fifo_queue and flt(fifo_queue[0][0]) <= 0:
# neutralize 0/negative stock by adding positive stock
fifo_queue[0][0] += flt(slot[0])
fifo_queue[0][1] = slot[1]
else:
fifo_queue.append(slot)
while transfer_qty_to_pop:
if transfer_data and 0 < transfer_data[0][0] <= transfer_qty_to_pop:
# bucket qty is not enough, consume whole
transfer_qty_to_pop -= transfer_data[0][0]
add_to_fifo_queue(transfer_data.pop(0))
elif not transfer_data:
# transfer bucket is empty, extra incoming qty
add_to_fifo_queue([transfer_qty_to_pop, row.posting_date])
transfer_qty_to_pop = 0
else:
# ample bucket qty to consume
transfer_data[0][0] -= transfer_qty_to_pop
add_to_fifo_queue([transfer_qty_to_pop, transfer_data[0][1]])
transfer_qty_to_pop = 0
def __update_balances(self, row: Dict, key: Union[Tuple, str]):
self.item_details[key]["qty_after_transaction"] = row.qty_after_transaction
@@ -338,6 +376,27 @@ class FIFOSlots:
self.item_details[key]["has_serial_no"] = row.has_serial_no
def __aggregate_details_by_item(self, wh_wise_data: Dict) -> Dict:
"Aggregate Item-Wh wise data into single Item entry."
item_aggregated_data = {}
for key,row in wh_wise_data.items():
item = key[0]
if not item_aggregated_data.get(item):
item_aggregated_data.setdefault(item, {
"details": frappe._dict(),
"fifo_queue": [],
"qty_after_transaction": 0.0,
"total_qty": 0.0
})
item_row = item_aggregated_data.get(item)
item_row["details"].update(row["details"])
item_row["fifo_queue"].extend(row["fifo_queue"])
item_row["qty_after_transaction"] += flt(row["qty_after_transaction"])
item_row["total_qty"] += flt(row["total_qty"])
item_row["has_serial_no"] = row["has_serial_no"]
return item_aggregated_data
def __get_stock_ledger_entries(self) -> List[Dict]:
sle = frappe.qb.DocType("Stock Ledger Entry")
item = self.__get_item_query() # used as derived table in sle query

View File

@@ -15,6 +15,7 @@ Here, the balance qty is 70.
50 qty is (today-the 1st) days old
20 qty is (today-the 2nd) days old
> Note: We generate FIFO slots warehouse wise as stock reconciliations from different warehouses can cause incorrect values.
### Calculation of FIFO Slots
#### Case 1: Outward from sufficient balance qty
@@ -70,4 +71,39 @@ Date | Qty | Queue
2nd | -60 | [[-10, 1-12-2021]]
3rd | +5 | [[-5, 3-12-2021]]
4th | +10 | [[5, 4-12-2021]]
4th | +20 | [[5, 4-12-2021], [20, 4-12-2021]]
4th | +20 | [[5, 4-12-2021], [20, 4-12-2021]]
### Concept of Transfer Qty Bucket
In the case of **Repack**, Quantity that comes in, isn't really incoming. It is just new stock repurposed from old stock, due to incoming-outgoing of the same warehouse.
Here, stock is consumed from the FIFO Queue. It is then re-added back to the queue.
While adding stock back to the queue we need to know how much to add.
For this we need to keep track of how much was previously consumed.
Hence we use **Transfer Qty Bucket**.
While re-adding stock, we try to add buckets that were consumed earlier (date intact), to maintain correctness.
#### Case 1: Same Item-Warehouse in Repack
Eg:
-------------------------------------------------------------------------------------
Date | Qty | Voucher | FIFO Queue | Transfer Qty Buckets
-------------------------------------------------------------------------------------
1st | +500 | PR | [[500, 1-12-2021]] |
2nd | -50 | Repack | [[450, 1-12-2021]] | [[50, 1-12-2021]]
2nd | +50 | Repack | [[450, 1-12-2021], [50, 1-12-2021]] | []
- The balance at the end is restored back to 500
- However, the initial 500 qty bucket is now split into 450 and 50, with the same date
- The net effect is the same as that before the Repack
#### Case 2: Same Item-Warehouse in Repack with Split Consumption rows
Eg:
-------------------------------------------------------------------------------------
Date | Qty | Voucher | FIFO Queue | Transfer Qty Buckets
-------------------------------------------------------------------------------------
1st | +500 | PR | [[500, 1-12-2021]] |
2nd | -50 | Repack | [[450, 1-12-2021]] | [[50, 1-12-2021]]
2nd | -50 | Repack | [[400, 1-12-2021]] | [[50, 1-12-2021],
- | | | |[50, 1-12-2021]]
2nd | +100 | Repack | [[400, 1-12-2021], [50, 1-12-2021], | []
- | | | [50, 1-12-2021]] |

View File

@@ -3,7 +3,7 @@
import frappe
from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots
from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots, format_report_data
from erpnext.tests.utils import ERPNextTestCase
@@ -11,15 +11,17 @@ class TestStockAgeing(ERPNextTestCase):
def setUp(self) -> None:
self.filters = frappe._dict(
company="_Test Company",
to_date="2021-12-10"
to_date="2021-12-10",
range1=30, range2=60, range3=90
)
def test_normal_inward_outward_queue(self):
"Reference: Case 1 in stock_ageing_fifo_logic.md"
"Reference: Case 1 in stock_ageing_fifo_logic.md (same wh)"
sle = [
frappe._dict(
name="Flask Item",
actual_qty=30, qty_after_transaction=30,
warehouse="WH 1",
posting_date="2021-12-01", voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False, serial_no=None
@@ -27,6 +29,7 @@ class TestStockAgeing(ERPNextTestCase):
frappe._dict(
name="Flask Item",
actual_qty=20, qty_after_transaction=50,
warehouse="WH 1",
posting_date="2021-12-02", voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False, serial_no=None
@@ -34,6 +37,7 @@ class TestStockAgeing(ERPNextTestCase):
frappe._dict(
name="Flask Item",
actual_qty=(-10), qty_after_transaction=40,
warehouse="WH 1",
posting_date="2021-12-03", voucher_type="Stock Entry",
voucher_no="003",
has_serial_no=False, serial_no=None
@@ -50,11 +54,12 @@ class TestStockAgeing(ERPNextTestCase):
self.assertEqual(queue[0][0], 20.0)
def test_insufficient_balance(self):
"Reference: Case 3 in stock_ageing_fifo_logic.md"
"Reference: Case 3 in stock_ageing_fifo_logic.md (same wh)"
sle = [
frappe._dict(
name="Flask Item",
actual_qty=(-30), qty_after_transaction=(-30),
warehouse="WH 1",
posting_date="2021-12-01", voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False, serial_no=None
@@ -62,6 +67,7 @@ class TestStockAgeing(ERPNextTestCase):
frappe._dict(
name="Flask Item",
actual_qty=20, qty_after_transaction=(-10),
warehouse="WH 1",
posting_date="2021-12-02", voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False, serial_no=None
@@ -69,6 +75,7 @@ class TestStockAgeing(ERPNextTestCase):
frappe._dict(
name="Flask Item",
actual_qty=20, qty_after_transaction=10,
warehouse="WH 1",
posting_date="2021-12-03", voucher_type="Stock Entry",
voucher_no="003",
has_serial_no=False, serial_no=None
@@ -76,6 +83,7 @@ class TestStockAgeing(ERPNextTestCase):
frappe._dict(
name="Flask Item",
actual_qty=10, qty_after_transaction=20,
warehouse="WH 1",
posting_date="2021-12-03", voucher_type="Stock Entry",
voucher_no="004",
has_serial_no=False, serial_no=None
@@ -91,11 +99,16 @@ class TestStockAgeing(ERPNextTestCase):
self.assertEqual(queue[0][0], 10.0)
self.assertEqual(queue[1][0], 10.0)
def test_stock_reconciliation(self):
def test_basic_stock_reconciliation(self):
"""
Ledger (same wh): [+30, reco reset >> 50, -10]
Bal: 40
"""
sle = [
frappe._dict(
name="Flask Item",
actual_qty=30, qty_after_transaction=30,
warehouse="WH 1",
posting_date="2021-12-01", voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False, serial_no=None
@@ -103,6 +116,7 @@ class TestStockAgeing(ERPNextTestCase):
frappe._dict(
name="Flask Item",
actual_qty=0, qty_after_transaction=50,
warehouse="WH 1",
posting_date="2021-12-02", voucher_type="Stock Reconciliation",
voucher_no="002",
has_serial_no=False, serial_no=None
@@ -110,6 +124,7 @@ class TestStockAgeing(ERPNextTestCase):
frappe._dict(
name="Flask Item",
actual_qty=(-10), qty_after_transaction=40,
warehouse="WH 1",
posting_date="2021-12-03", voucher_type="Stock Entry",
voucher_no="003",
has_serial_no=False, serial_no=None
@@ -122,5 +137,477 @@ class TestStockAgeing(ERPNextTestCase):
queue = result["fifo_queue"]
self.assertEqual(result["qty_after_transaction"], result["total_qty"])
self.assertEqual(result["total_qty"], 40.0)
self.assertEqual(queue[0][0], 20.0)
self.assertEqual(queue[1][0], 20.0)
def test_sequential_stock_reco_same_warehouse(self):
"""
Test back to back stock recos (same warehouse).
Ledger: [reco opening >> +1000, reco reset >> 400, -10]
Bal: 390
"""
sle = [
frappe._dict(
name="Flask Item",
actual_qty=0, qty_after_transaction=1000,
warehouse="WH 1",
posting_date="2021-12-01", voucher_type="Stock Reconciliation",
voucher_no="002",
has_serial_no=False, serial_no=None
),
frappe._dict(
name="Flask Item",
actual_qty=0, qty_after_transaction=400,
warehouse="WH 1",
posting_date="2021-12-02", voucher_type="Stock Reconciliation",
voucher_no="003",
has_serial_no=False, serial_no=None
),
frappe._dict(
name="Flask Item",
actual_qty=(-10), qty_after_transaction=390,
warehouse="WH 1",
posting_date="2021-12-03", voucher_type="Stock Entry",
voucher_no="003",
has_serial_no=False, serial_no=None
)
]
slots = FIFOSlots(self.filters, sle).generate()
result = slots["Flask Item"]
queue = result["fifo_queue"]
self.assertEqual(result["qty_after_transaction"], result["total_qty"])
self.assertEqual(result["total_qty"], 390.0)
self.assertEqual(queue[0][0], 390.0)
def test_sequential_stock_reco_different_warehouse(self):
"""
Ledger:
WH | Voucher | Qty
-------------------
WH1 | Reco | 1000
WH2 | Reco | 400
WH1 | SE | -10
Bal: WH1 bal + WH2 bal = 990 + 400 = 1390
"""
sle = [
frappe._dict(
name="Flask Item",
actual_qty=0, qty_after_transaction=1000,
warehouse="WH 1",
posting_date="2021-12-01", voucher_type="Stock Reconciliation",
voucher_no="002",
has_serial_no=False, serial_no=None
),
frappe._dict(
name="Flask Item",
actual_qty=0, qty_after_transaction=400,
warehouse="WH 2",
posting_date="2021-12-02", voucher_type="Stock Reconciliation",
voucher_no="003",
has_serial_no=False, serial_no=None
),
frappe._dict(
name="Flask Item",
actual_qty=(-10), qty_after_transaction=990,
warehouse="WH 1",
posting_date="2021-12-03", voucher_type="Stock Entry",
voucher_no="004",
has_serial_no=False, serial_no=None
)
]
item_wise_slots, item_wh_wise_slots = generate_item_and_item_wh_wise_slots(
filters=self.filters,sle=sle
)
# test without 'show_warehouse_wise_stock'
item_result = item_wise_slots["Flask Item"]
queue = item_result["fifo_queue"]
self.assertEqual(item_result["qty_after_transaction"], item_result["total_qty"])
self.assertEqual(item_result["total_qty"], 1390.0)
self.assertEqual(queue[0][0], 990.0)
self.assertEqual(queue[1][0], 400.0)
# test with 'show_warehouse_wise_stock' checked
item_wh_balances = [item_wh_wise_slots.get(i).get("qty_after_transaction") for i in item_wh_wise_slots]
self.assertEqual(sum(item_wh_balances), item_result["qty_after_transaction"])
def test_repack_entry_same_item_split_rows(self):
"""
Split consumption rows and have single repacked item row (same warehouse).
Ledger:
Item | Qty | Voucher
------------------------
Item 1 | 500 | 001
Item 1 | -50 | 002 (repack)
Item 1 | -50 | 002 (repack)
Item 1 | 100 | 002 (repack)
Case most likely for batch items. Test time bucket computation.
"""
sle = [
frappe._dict( # stock up item
name="Flask Item",
actual_qty=500, qty_after_transaction=500,
warehouse="WH 1",
posting_date="2021-12-03", voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False, serial_no=None
),
frappe._dict(
name="Flask Item",
actual_qty=(-50), qty_after_transaction=450,
warehouse="WH 1",
posting_date="2021-12-04", voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False, serial_no=None
),
frappe._dict(
name="Flask Item",
actual_qty=(-50), qty_after_transaction=400,
warehouse="WH 1",
posting_date="2021-12-04", voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False, serial_no=None
),
frappe._dict(
name="Flask Item",
actual_qty=100, qty_after_transaction=500,
warehouse="WH 1",
posting_date="2021-12-04", voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False, serial_no=None
),
]
slots = FIFOSlots(self.filters, sle).generate()
item_result = slots["Flask Item"]
queue = item_result["fifo_queue"]
self.assertEqual(item_result["total_qty"], 500.0)
self.assertEqual(queue[0][0], 400.0)
self.assertEqual(queue[1][0], 50.0)
self.assertEqual(queue[2][0], 50.0)
# check if time buckets add up to balance qty
self.assertEqual(sum([i[0] for i in queue]), 500.0)
def test_repack_entry_same_item_overconsume(self):
"""
Over consume item and have less repacked item qty (same warehouse).
Ledger:
Item | Qty | Voucher
------------------------
Item 1 | 500 | 001
Item 1 | -100 | 002 (repack)
Item 1 | 50 | 002 (repack)
Case most likely for batch items. Test time bucket computation.
"""
sle = [
frappe._dict( # stock up item
name="Flask Item",
actual_qty=500, qty_after_transaction=500,
warehouse="WH 1",
posting_date="2021-12-03", voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False, serial_no=None
),
frappe._dict(
name="Flask Item",
actual_qty=(-100), qty_after_transaction=400,
warehouse="WH 1",
posting_date="2021-12-04", voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False, serial_no=None
),
frappe._dict(
name="Flask Item",
actual_qty=50, qty_after_transaction=450,
warehouse="WH 1",
posting_date="2021-12-04", voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False, serial_no=None
),
]
slots = FIFOSlots(self.filters, sle).generate()
item_result = slots["Flask Item"]
queue = item_result["fifo_queue"]
self.assertEqual(item_result["total_qty"], 450.0)
self.assertEqual(queue[0][0], 400.0)
self.assertEqual(queue[1][0], 50.0)
# check if time buckets add up to balance qty
self.assertEqual(sum([i[0] for i in queue]), 450.0)
def test_repack_entry_same_item_overconsume_with_split_rows(self):
"""
Over consume item and have less repacked item qty (same warehouse).
Ledger:
Item | Qty | Voucher
------------------------
Item 1 | 20 | 001
Item 1 | -50 | 002 (repack)
Item 1 | -50 | 002 (repack)
Item 1 | 50 | 002 (repack)
"""
sle = [
frappe._dict( # stock up item
name="Flask Item",
actual_qty=20, qty_after_transaction=20,
warehouse="WH 1",
posting_date="2021-12-03", voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False, serial_no=None
),
frappe._dict(
name="Flask Item",
actual_qty=(-50), qty_after_transaction=(-30),
warehouse="WH 1",
posting_date="2021-12-04", voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False, serial_no=None
),
frappe._dict(
name="Flask Item",
actual_qty=(-50), qty_after_transaction=(-80),
warehouse="WH 1",
posting_date="2021-12-04", voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False, serial_no=None
),
frappe._dict(
name="Flask Item",
actual_qty=50, qty_after_transaction=(-30),
warehouse="WH 1",
posting_date="2021-12-04", voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False, serial_no=None
),
]
fifo_slots = FIFOSlots(self.filters, sle)
slots = fifo_slots.generate()
item_result = slots["Flask Item"]
queue = item_result["fifo_queue"]
self.assertEqual(item_result["total_qty"], -30.0)
self.assertEqual(queue[0][0], -30.0)
# check transfer bucket
transfer_bucket = fifo_slots.transferred_item_details[('002', 'Flask Item', 'WH 1')]
self.assertEqual(transfer_bucket[0][0], 50)
def test_repack_entry_same_item_overproduce(self):
"""
Under consume item and have more repacked item qty (same warehouse).
Ledger:
Item | Qty | Voucher
------------------------
Item 1 | 500 | 001
Item 1 | -50 | 002 (repack)
Item 1 | 100 | 002 (repack)
Case most likely for batch items. Test time bucket computation.
"""
sle = [
frappe._dict( # stock up item
name="Flask Item",
actual_qty=500, qty_after_transaction=500,
warehouse="WH 1",
posting_date="2021-12-03", voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False, serial_no=None
),
frappe._dict(
name="Flask Item",
actual_qty=(-50), qty_after_transaction=450,
warehouse="WH 1",
posting_date="2021-12-04", voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False, serial_no=None
),
frappe._dict(
name="Flask Item",
actual_qty=100, qty_after_transaction=550,
warehouse="WH 1",
posting_date="2021-12-04", voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False, serial_no=None
),
]
slots = FIFOSlots(self.filters, sle).generate()
item_result = slots["Flask Item"]
queue = item_result["fifo_queue"]
self.assertEqual(item_result["total_qty"], 550.0)
self.assertEqual(queue[0][0], 450.0)
self.assertEqual(queue[1][0], 50.0)
self.assertEqual(queue[2][0], 50.0)
# check if time buckets add up to balance qty
self.assertEqual(sum([i[0] for i in queue]), 550.0)
def test_repack_entry_same_item_overproduce_with_split_rows(self):
"""
Over consume item and have less repacked item qty (same warehouse).
Ledger:
Item | Qty | Voucher
------------------------
Item 1 | 20 | 001
Item 1 | -50 | 002 (repack)
Item 1 | 50 | 002 (repack)
Item 1 | 50 | 002 (repack)
"""
sle = [
frappe._dict( # stock up item
name="Flask Item",
actual_qty=20, qty_after_transaction=20,
warehouse="WH 1",
posting_date="2021-12-03", voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False, serial_no=None
),
frappe._dict(
name="Flask Item",
actual_qty=(-50), qty_after_transaction=(-30),
warehouse="WH 1",
posting_date="2021-12-04", voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False, serial_no=None
),
frappe._dict(
name="Flask Item",
actual_qty=50, qty_after_transaction=20,
warehouse="WH 1",
posting_date="2021-12-04", voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False, serial_no=None
),
frappe._dict(
name="Flask Item",
actual_qty=50, qty_after_transaction=70,
warehouse="WH 1",
posting_date="2021-12-04", voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False, serial_no=None
),
]
fifo_slots = FIFOSlots(self.filters, sle)
slots = fifo_slots.generate()
item_result = slots["Flask Item"]
queue = item_result["fifo_queue"]
self.assertEqual(item_result["total_qty"], 70.0)
self.assertEqual(queue[0][0], 20.0)
self.assertEqual(queue[1][0], 50.0)
# check transfer bucket
transfer_bucket = fifo_slots.transferred_item_details[('002', 'Flask Item', 'WH 1')]
self.assertFalse(transfer_bucket)
def test_negative_stock_same_voucher(self):
"""
Test negative stock scenario in transfer bucket via repack entry (same wh).
Ledger:
Item | Qty | Voucher
------------------------
Item 1 | -50 | 001
Item 1 | -50 | 001
Item 1 | 30 | 001
Item 1 | 80 | 001
"""
sle = [
frappe._dict( # stock up item
name="Flask Item",
actual_qty=(-50), qty_after_transaction=(-50),
warehouse="WH 1",
posting_date="2021-12-01", voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False, serial_no=None
),
frappe._dict( # stock up item
name="Flask Item",
actual_qty=(-50), qty_after_transaction=(-100),
warehouse="WH 1",
posting_date="2021-12-01", voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False, serial_no=None
),
frappe._dict( # stock up item
name="Flask Item",
actual_qty=30, qty_after_transaction=(-70),
warehouse="WH 1",
posting_date="2021-12-01", voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False, serial_no=None
),
]
fifo_slots = FIFOSlots(self.filters, sle)
slots = fifo_slots.generate()
item_result = slots["Flask Item"]
# check transfer bucket
transfer_bucket = fifo_slots.transferred_item_details[('001', 'Flask Item', 'WH 1')]
self.assertEqual(transfer_bucket[0][0], 20)
self.assertEqual(transfer_bucket[1][0], 50)
self.assertEqual(item_result["fifo_queue"][0][0], -70.0)
sle.append(frappe._dict(
name="Flask Item",
actual_qty=80, qty_after_transaction=10,
warehouse="WH 1",
posting_date="2021-12-01", voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False, serial_no=None
))
fifo_slots = FIFOSlots(self.filters, sle)
slots = fifo_slots.generate()
item_result = slots["Flask Item"]
transfer_bucket = fifo_slots.transferred_item_details[('001', 'Flask Item', 'WH 1')]
self.assertFalse(transfer_bucket)
self.assertEqual(item_result["fifo_queue"][0][0], 10.0)
def test_precision(self):
"Test if final balance qty is rounded off correctly."
sle = [
frappe._dict( # stock up item
name="Flask Item",
actual_qty=0.3, qty_after_transaction=0.3,
warehouse="WH 1",
posting_date="2021-12-01", voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False, serial_no=None
),
frappe._dict( # stock up item
name="Flask Item",
actual_qty=0.6, qty_after_transaction=0.9,
warehouse="WH 1",
posting_date="2021-12-01", voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False, serial_no=None
),
]
slots = FIFOSlots(self.filters, sle).generate()
report_data = format_report_data(self.filters, slots, self.filters["to_date"])
row = report_data[0] # first row in report
bal_qty = row[5]
range_qty_sum = sum([i for i in row[7:11]]) # get sum of range balance
# check if value of Available Qty column matches with range bucket post format
self.assertEqual(bal_qty, 0.9)
self.assertEqual(bal_qty, range_qty_sum)
def generate_item_and_item_wh_wise_slots(filters, sle):
"Return results with and without 'show_warehouse_wise_stock'"
item_wise_slots = FIFOSlots(filters, sle).generate()
filters.show_warehouse_wise_stock = True
item_wh_wise_slots = FIFOSlots(filters, sle).generate()
filters.show_warehouse_wise_stock = False
return item_wise_slots, item_wh_wise_slots

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": _("Valuation Rate"), "fieldname": "valuation_rate", "fieldtype": "Currency", "width": 110, "options": "Company:company:default_currency", "convertible": "rate"},
{"label": _("Balance Value"), "fieldname": "stock_value", "fieldtype": "Currency", "width": 110, "options": "Company:company:default_currency"},
{"label": _("Value Change"), "fieldname": "stock_value_difference", "fieldtype": "Currency", "width": 110, "options": "Company:company:default_currency"},
{"label": _("Voucher Type"), "fieldname": "voucher_type", "width": 110},
{"label": _("Voucher #"), "fieldname": "voucher_no", "fieldtype": "Dynamic Link", "options": "voucher_type", "width": 100},
{"label": _("Batch"), "fieldname": "batch_no", "fieldtype": "Link", "options": "Batch", "width": 100},

View File

@@ -3,7 +3,7 @@
import frappe
from frappe.utils import cstr, flt, nowdate, nowtime
from frappe.utils import cstr, flt, now, nowdate, nowtime
from erpnext.controllers.stock_controller import create_repost_item_valuation_entry
from erpnext.stock.utils import update_bin
@@ -175,6 +175,7 @@ def update_bin_qty(item_code, warehouse, qty_dict=None):
bin.set(field, flt(value))
mismatch = True
bin.modified = now()
if mismatch:
bin.set_projected_qty()
bin.db_update()

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) {
frm.disable_save();
frm.get_field("file_to_rename").df.options = {
restrictions: {
allowed_file_types: [".csv"],
},
};
if (!frm.doc.file_to_rename) {
frm.get_field("rename_log").$wrapper.html("");
}