mirror of
https://github.com/frappe/erpnext.git
synced 2026-04-18 14:25:10 +00:00
Merge branch 'version-13-hotfix' into fetch-valuation-rate
This commit is contained in:
2
.github/workflows/patch.yml
vendored
2
.github/workflows/patch.yml
vendored
@@ -41,7 +41,7 @@ jobs:
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: 3.6
|
||||
python-version: 3.7
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v2
|
||||
|
||||
@@ -14,6 +14,10 @@ frappe.ui.form.on("Bank Reconciliation Tool", {
|
||||
});
|
||||
},
|
||||
|
||||
onload: function (frm) {
|
||||
frm.trigger('bank_account');
|
||||
},
|
||||
|
||||
refresh: function (frm) {
|
||||
frappe.require("assets/js/bank-reconciliation-tool.min.js", () =>
|
||||
frm.trigger("make_reconciliation_tool")
|
||||
@@ -51,7 +55,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", {
|
||||
bank_account: function (frm) {
|
||||
frappe.db.get_value(
|
||||
"Bank Account",
|
||||
frm.bank_account,
|
||||
frm.doc.bank_account,
|
||||
"account",
|
||||
(r) => {
|
||||
frappe.db.get_value(
|
||||
@@ -60,6 +64,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", {
|
||||
"account_currency",
|
||||
(r) => {
|
||||
frm.currency = r.account_currency;
|
||||
frm.trigger("render_chart");
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -124,7 +129,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", {
|
||||
}
|
||||
},
|
||||
|
||||
render_chart(frm) {
|
||||
render_chart: frappe.utils.debounce((frm) => {
|
||||
frm.cards_manager = new erpnext.accounts.bank_reconciliation.NumberCardManager(
|
||||
{
|
||||
$reconciliation_tool_cards: frm.get_field(
|
||||
@@ -136,7 +141,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", {
|
||||
currency: frm.currency,
|
||||
}
|
||||
);
|
||||
},
|
||||
}, 500),
|
||||
|
||||
render(frm) {
|
||||
if (frm.doc.bank_account) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"actions": [],
|
||||
"allow_import": 1,
|
||||
"allow_rename": 1,
|
||||
"creation": "2018-11-22 22:45:00.370913",
|
||||
"creation": "2022-01-19 01:09:13.297137",
|
||||
"doctype": "DocType",
|
||||
"document_type": "Setup",
|
||||
"editable_grid": 1,
|
||||
@@ -10,6 +10,9 @@
|
||||
"field_order": [
|
||||
"title",
|
||||
"company",
|
||||
"column_break_3",
|
||||
"disabled",
|
||||
"section_break_5",
|
||||
"taxes"
|
||||
],
|
||||
"fields": [
|
||||
@@ -36,10 +39,24 @@
|
||||
"label": "Company",
|
||||
"options": "Company",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_3",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "disabled",
|
||||
"fieldtype": "Check",
|
||||
"label": "Disabled"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_5",
|
||||
"fieldtype": "Section Break"
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"modified": "2021-03-08 19:50:21.416513",
|
||||
"modified": "2022-01-18 21:11:23.105589",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Item Tax Template",
|
||||
@@ -82,6 +99,7 @@
|
||||
"show_name_in_global_search": 1,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"title_field": "title",
|
||||
"track_changes": 1
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -586,23 +586,29 @@ class TestPOSInvoice(unittest.TestCase):
|
||||
item_price.insert()
|
||||
pr = make_pricing_rule(selling=1, priority=5, discount_percentage=10)
|
||||
pr.save()
|
||||
pos_inv = create_pos_invoice(qty=1, do_not_submit=1)
|
||||
pos_inv.items[0].rate = 300
|
||||
pos_inv.save()
|
||||
self.assertEquals(pos_inv.items[0].discount_percentage, 10)
|
||||
# rate shouldn't change
|
||||
self.assertEquals(pos_inv.items[0].rate, 405)
|
||||
|
||||
pos_inv.ignore_pricing_rule = 1
|
||||
pos_inv.items[0].rate = 300
|
||||
pos_inv.save()
|
||||
self.assertEquals(pos_inv.ignore_pricing_rule, 1)
|
||||
# rate should change since pricing rules are ignored
|
||||
self.assertEquals(pos_inv.items[0].rate, 300)
|
||||
try:
|
||||
pos_inv = create_pos_invoice(qty=1, do_not_submit=1)
|
||||
pos_inv.items[0].rate = 300
|
||||
pos_inv.save()
|
||||
self.assertEquals(pos_inv.items[0].discount_percentage, 10)
|
||||
# rate shouldn't change
|
||||
self.assertEquals(pos_inv.items[0].rate, 405)
|
||||
|
||||
item_price.delete()
|
||||
pos_inv.delete()
|
||||
pr.delete()
|
||||
pos_inv.ignore_pricing_rule = 1
|
||||
pos_inv.save()
|
||||
self.assertEquals(pos_inv.ignore_pricing_rule, 1)
|
||||
# rate should reset since pricing rules are ignored
|
||||
self.assertEquals(pos_inv.items[0].rate, 450)
|
||||
|
||||
pos_inv.items[0].rate = 300
|
||||
pos_inv.save()
|
||||
self.assertEquals(pos_inv.items[0].rate, 300)
|
||||
|
||||
finally:
|
||||
item_price.delete()
|
||||
pos_inv.delete()
|
||||
pr.delete()
|
||||
|
||||
|
||||
def create_pos_invoice(**args):
|
||||
|
||||
@@ -85,12 +85,20 @@ class POSInvoiceMergeLog(Document):
|
||||
sales_invoice.set_posting_time = 1
|
||||
sales_invoice.posting_date = getdate(self.posting_date)
|
||||
sales_invoice.save()
|
||||
self.write_off_fractional_amount(sales_invoice, data)
|
||||
sales_invoice.submit()
|
||||
|
||||
self.consolidated_invoice = sales_invoice.name
|
||||
|
||||
return sales_invoice.name
|
||||
|
||||
def write_off_fractional_amount(self, invoice, data):
|
||||
pos_invoice_grand_total = sum(d.grand_total for d in data)
|
||||
|
||||
if abs(pos_invoice_grand_total - invoice.grand_total) < 1:
|
||||
invoice.write_off_amount += -1 * (pos_invoice_grand_total - invoice.grand_total)
|
||||
invoice.save()
|
||||
|
||||
def process_merging_into_credit_note(self, data):
|
||||
credit_note = self.get_new_sales_invoice()
|
||||
credit_note.is_return = 1
|
||||
@@ -103,6 +111,7 @@ class POSInvoiceMergeLog(Document):
|
||||
# TODO: return could be against multiple sales invoice which could also have been consolidated?
|
||||
# credit_note.return_against = self.consolidated_invoice
|
||||
credit_note.save()
|
||||
self.write_off_fractional_amount(credit_note, data)
|
||||
credit_note.submit()
|
||||
|
||||
self.consolidated_credit_note = credit_note.name
|
||||
@@ -136,9 +145,15 @@ class POSInvoiceMergeLog(Document):
|
||||
i.uom == item.uom and i.net_rate == item.net_rate and i.warehouse == item.warehouse):
|
||||
found = True
|
||||
i.qty = i.qty + item.qty
|
||||
i.amount = i.amount + item.net_amount
|
||||
i.net_amount = i.amount
|
||||
i.base_amount = i.base_amount + item.base_net_amount
|
||||
i.base_net_amount = i.base_amount
|
||||
|
||||
if not found:
|
||||
item.rate = item.net_rate
|
||||
item.amount = item.net_amount
|
||||
item.base_amount = item.base_net_amount
|
||||
item.price_list_rate = 0
|
||||
si_item = map_child_doc(item, invoice, {"doctype": "Sales Invoice Item"})
|
||||
items.append(si_item)
|
||||
@@ -170,6 +185,7 @@ class POSInvoiceMergeLog(Document):
|
||||
found = True
|
||||
if not found:
|
||||
payments.append(payment)
|
||||
|
||||
rounding_adjustment += doc.rounding_adjustment
|
||||
rounded_total += doc.rounded_total
|
||||
base_rounding_adjustment += doc.base_rounding_adjustment
|
||||
|
||||
@@ -12,6 +12,7 @@ from erpnext.accounts.doctype.pos_invoice.test_pos_invoice import create_pos_inv
|
||||
from erpnext.accounts.doctype.pos_invoice_merge_log.pos_invoice_merge_log import (
|
||||
consolidate_pos_invoices,
|
||||
)
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
|
||||
class TestPOSInvoiceMergeLog(unittest.TestCase):
|
||||
@@ -150,3 +151,132 @@ class TestPOSInvoiceMergeLog(unittest.TestCase):
|
||||
frappe.set_user("Administrator")
|
||||
frappe.db.sql("delete from `tabPOS Profile`")
|
||||
frappe.db.sql("delete from `tabPOS Invoice`")
|
||||
|
||||
|
||||
def test_consolidation_round_off_error_1(self):
|
||||
'''
|
||||
Test round off error in consolidated invoice creation if POS Invoice has inclusive tax
|
||||
'''
|
||||
|
||||
frappe.db.sql("delete from `tabPOS Invoice`")
|
||||
|
||||
try:
|
||||
make_stock_entry(
|
||||
to_warehouse="_Test Warehouse - _TC",
|
||||
item_code="_Test Item",
|
||||
rate=8000,
|
||||
qty=10,
|
||||
)
|
||||
|
||||
init_user_and_profile()
|
||||
|
||||
inv = create_pos_invoice(qty=3, rate=10000, do_not_save=True)
|
||||
inv.append("taxes", {
|
||||
"account_head": "_Test Account VAT - _TC",
|
||||
"charge_type": "On Net Total",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"description": "VAT",
|
||||
"doctype": "Sales Taxes and Charges",
|
||||
"rate": 7.5,
|
||||
"included_in_print_rate": 1
|
||||
})
|
||||
inv.append('payments', {
|
||||
'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 30000
|
||||
})
|
||||
inv.insert()
|
||||
inv.submit()
|
||||
|
||||
inv2 = create_pos_invoice(qty=3, rate=10000, do_not_save=True)
|
||||
inv2.append("taxes", {
|
||||
"account_head": "_Test Account VAT - _TC",
|
||||
"charge_type": "On Net Total",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"description": "VAT",
|
||||
"doctype": "Sales Taxes and Charges",
|
||||
"rate": 7.5,
|
||||
"included_in_print_rate": 1
|
||||
})
|
||||
inv2.append('payments', {
|
||||
'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 30000
|
||||
})
|
||||
inv2.insert()
|
||||
inv2.submit()
|
||||
|
||||
consolidate_pos_invoices()
|
||||
|
||||
inv.load_from_db()
|
||||
consolidated_invoice = frappe.get_doc('Sales Invoice', inv.consolidated_invoice)
|
||||
self.assertEqual(consolidated_invoice.outstanding_amount, 0)
|
||||
self.assertEqual(consolidated_invoice.status, 'Paid')
|
||||
|
||||
finally:
|
||||
frappe.set_user("Administrator")
|
||||
frappe.db.sql("delete from `tabPOS Profile`")
|
||||
frappe.db.sql("delete from `tabPOS Invoice`")
|
||||
|
||||
def test_consolidation_round_off_error_2(self):
|
||||
'''
|
||||
Test the same case as above but with an Unpaid POS Invoice
|
||||
'''
|
||||
frappe.db.sql("delete from `tabPOS Invoice`")
|
||||
|
||||
try:
|
||||
make_stock_entry(
|
||||
to_warehouse="_Test Warehouse - _TC",
|
||||
item_code="_Test Item",
|
||||
rate=8000,
|
||||
qty=10,
|
||||
)
|
||||
|
||||
init_user_and_profile()
|
||||
|
||||
inv = create_pos_invoice(qty=6, rate=10000, do_not_save=True)
|
||||
inv.append("taxes", {
|
||||
"account_head": "_Test Account VAT - _TC",
|
||||
"charge_type": "On Net Total",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"description": "VAT",
|
||||
"doctype": "Sales Taxes and Charges",
|
||||
"rate": 7.5,
|
||||
"included_in_print_rate": 1
|
||||
})
|
||||
inv.append('payments', {
|
||||
'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 60000
|
||||
})
|
||||
inv.insert()
|
||||
inv.submit()
|
||||
|
||||
inv2 = create_pos_invoice(qty=6, rate=10000, do_not_save=True)
|
||||
inv2.append("taxes", {
|
||||
"account_head": "_Test Account VAT - _TC",
|
||||
"charge_type": "On Net Total",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"description": "VAT",
|
||||
"doctype": "Sales Taxes and Charges",
|
||||
"rate": 7.5,
|
||||
"included_in_print_rate": 1
|
||||
})
|
||||
inv2.append('payments', {
|
||||
'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 60000
|
||||
})
|
||||
inv2.insert()
|
||||
inv2.submit()
|
||||
|
||||
inv3 = create_pos_invoice(qty=3, rate=600, do_not_save=True)
|
||||
inv3.append('payments', {
|
||||
'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 1000
|
||||
})
|
||||
inv3.insert()
|
||||
inv3.submit()
|
||||
|
||||
consolidate_pos_invoices()
|
||||
|
||||
inv.load_from_db()
|
||||
consolidated_invoice = frappe.get_doc('Sales Invoice', inv.consolidated_invoice)
|
||||
self.assertEqual(consolidated_invoice.outstanding_amount, 800)
|
||||
self.assertNotEqual(consolidated_invoice.status, 'Paid')
|
||||
|
||||
finally:
|
||||
frappe.set_user("Administrator")
|
||||
frappe.db.sql("delete from `tabPOS Profile`")
|
||||
frappe.db.sql("delete from `tabPOS Invoice`")
|
||||
|
||||
@@ -250,13 +250,17 @@ def get_pricing_rule_for_item(args, price_list_rate=0, doc=None, for_validate=Fa
|
||||
"free_item_data": [],
|
||||
"parent": args.parent,
|
||||
"parenttype": args.parenttype,
|
||||
"child_docname": args.get('child_docname')
|
||||
"child_docname": args.get('child_docname'),
|
||||
})
|
||||
|
||||
if args.ignore_pricing_rule or not args.item_code:
|
||||
if frappe.db.exists(args.doctype, args.name) and args.get("pricing_rules"):
|
||||
item_details = remove_pricing_rule_for_item(args.get("pricing_rules"),
|
||||
item_details, args.get('item_code'))
|
||||
item_details = remove_pricing_rule_for_item(
|
||||
args.get("pricing_rules"),
|
||||
item_details,
|
||||
item_code=args.get("item_code"),
|
||||
rate=args.get("price_list_rate"),
|
||||
)
|
||||
return item_details
|
||||
|
||||
update_args_for_pricing_rule(args)
|
||||
@@ -309,8 +313,12 @@ def get_pricing_rule_for_item(args, price_list_rate=0, doc=None, for_validate=Fa
|
||||
if not doc: return item_details
|
||||
|
||||
elif args.get("pricing_rules"):
|
||||
item_details = remove_pricing_rule_for_item(args.get("pricing_rules"),
|
||||
item_details, args.get('item_code'))
|
||||
item_details = remove_pricing_rule_for_item(
|
||||
args.get("pricing_rules"),
|
||||
item_details,
|
||||
item_code=args.get("item_code"),
|
||||
rate=args.get("price_list_rate"),
|
||||
)
|
||||
|
||||
return item_details
|
||||
|
||||
@@ -391,7 +399,7 @@ def apply_price_discount_rule(pricing_rule, item_details, args):
|
||||
item_details[field] += (pricing_rule.get(field, 0)
|
||||
if pricing_rule else args.get(field, 0))
|
||||
|
||||
def remove_pricing_rule_for_item(pricing_rules, item_details, item_code=None):
|
||||
def remove_pricing_rule_for_item(pricing_rules, item_details, item_code=None, rate=None):
|
||||
from erpnext.accounts.doctype.pricing_rule.utils import (
|
||||
get_applied_pricing_rules,
|
||||
get_pricing_rule_items,
|
||||
@@ -404,6 +412,7 @@ def remove_pricing_rule_for_item(pricing_rules, item_details, item_code=None):
|
||||
if pricing_rule.rate_or_discount == 'Discount Percentage':
|
||||
item_details.discount_percentage = 0.0
|
||||
item_details.discount_amount = 0.0
|
||||
item_details.rate = rate or 0.0
|
||||
|
||||
if pricing_rule.rate_or_discount == 'Discount Amount':
|
||||
item_details.discount_amount = 0.0
|
||||
@@ -422,6 +431,7 @@ def remove_pricing_rule_for_item(pricing_rules, item_details, item_code=None):
|
||||
item_details.applied_on_items = ','.join(items)
|
||||
|
||||
item_details.pricing_rules = ''
|
||||
item_details.pricing_rule_removed = True
|
||||
|
||||
return item_details
|
||||
|
||||
@@ -433,9 +443,12 @@ def remove_pricing_rules(item_list):
|
||||
out = []
|
||||
for item in item_list:
|
||||
item = frappe._dict(item)
|
||||
if item.get('pricing_rules'):
|
||||
out.append(remove_pricing_rule_for_item(item.get("pricing_rules"),
|
||||
item, item.item_code))
|
||||
if item.get("pricing_rules"):
|
||||
out.append(
|
||||
remove_pricing_rule_for_item(
|
||||
item.get("pricing_rules"), item, item.item_code, item.get("price_list_rate")
|
||||
)
|
||||
)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
@@ -650,6 +650,47 @@ class TestPricingRule(unittest.TestCase):
|
||||
frappe.delete_doc_if_exists("Pricing Rule", "_Test Pricing Rule with Min Qty - 1")
|
||||
frappe.delete_doc_if_exists("Pricing Rule", "_Test Pricing Rule with Min Qty - 2")
|
||||
|
||||
def test_remove_pricing_rule(self):
|
||||
item = make_item("Water Flask")
|
||||
make_item_price("Water Flask", "_Test Price List", 100)
|
||||
|
||||
pricing_rule_record = {
|
||||
"doctype": "Pricing Rule",
|
||||
"title": "_Test Water Flask Rule",
|
||||
"apply_on": "Item Code",
|
||||
"price_or_product_discount": "Price",
|
||||
"items": [{
|
||||
"item_code": "Water Flask",
|
||||
}],
|
||||
"selling": 1,
|
||||
"currency": "INR",
|
||||
"rate_or_discount": "Discount Percentage",
|
||||
"discount_percentage": 20,
|
||||
"company": "_Test Company"
|
||||
}
|
||||
rule = frappe.get_doc(pricing_rule_record)
|
||||
rule.insert()
|
||||
|
||||
si = create_sales_invoice(do_not_save=True, item_code="Water Flask")
|
||||
si.selling_price_list = "_Test Price List"
|
||||
si.save()
|
||||
|
||||
self.assertEqual(si.items[0].price_list_rate, 100)
|
||||
self.assertEqual(si.items[0].discount_percentage, 20)
|
||||
self.assertEqual(si.items[0].rate, 80)
|
||||
|
||||
si.ignore_pricing_rule = 1
|
||||
si.save()
|
||||
|
||||
self.assertEqual(si.items[0].discount_percentage, 0)
|
||||
self.assertEqual(si.items[0].rate, 100)
|
||||
|
||||
si.delete()
|
||||
rule.delete()
|
||||
frappe.get_doc("Item Price", {"item_code": "Water Flask"}).delete()
|
||||
item.delete()
|
||||
|
||||
|
||||
test_dependencies = ["Campaign"]
|
||||
|
||||
def make_pricing_rule(**args):
|
||||
|
||||
@@ -176,8 +176,8 @@ class PurchaseInvoice(BuyingController):
|
||||
|
||||
if self.supplier and account.account_type != "Payable":
|
||||
frappe.throw(
|
||||
_("Please ensure {} account is a Payable account. Change the account type to Payable or select a different account.")
|
||||
.format(frappe.bold("Credit To")), title=_("Invalid Account")
|
||||
_("Please ensure {} account {} is a Payable account. Change the account type to Payable or select a different account.")
|
||||
.format(frappe.bold("Credit To"), frappe.bold(self.credit_to)), title=_("Invalid Account")
|
||||
)
|
||||
|
||||
self.party_account_currency = account.account_currency
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -1260,14 +1263,14 @@ class SalesInvoice(SellingController):
|
||||
def update_billing_status_in_dn(self, update_modified=True):
|
||||
updated_delivery_notes = []
|
||||
for d in self.get("items"):
|
||||
if d.so_detail:
|
||||
updated_delivery_notes += update_billed_amount_based_on_so(d.so_detail, update_modified)
|
||||
elif d.dn_detail:
|
||||
if d.dn_detail:
|
||||
billed_amt = frappe.db.sql("""select sum(amount) from `tabSales Invoice Item`
|
||||
where dn_detail=%s and docstatus=1""", d.dn_detail)
|
||||
billed_amt = billed_amt and billed_amt[0][0] or 0
|
||||
frappe.db.set_value("Delivery Note Item", d.dn_detail, "billed_amt", billed_amt, update_modified=update_modified)
|
||||
updated_delivery_notes.append(d.delivery_note)
|
||||
elif d.so_detail:
|
||||
updated_delivery_notes += update_billed_amount_based_on_so(d.so_detail, update_modified)
|
||||
|
||||
for dn in set(updated_delivery_notes):
|
||||
frappe.get_doc("Delivery Note", dn).update_billing_percentage(update_modified=update_modified)
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"autoname": "field:title",
|
||||
"creation": "2018-11-22 23:38:39.668804",
|
||||
"creation": "2022-01-19 01:09:28.920486",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"title"
|
||||
"title",
|
||||
"disabled"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -18,14 +19,21 @@
|
||||
"label": "Title",
|
||||
"reqd": 1,
|
||||
"unique": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "disabled",
|
||||
"fieldtype": "Check",
|
||||
"label": "Disabled"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2021-03-03 11:50:38.748872",
|
||||
"modified": "2022-01-18 21:13:41.161017",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Tax Category",
|
||||
"naming_rule": "By fieldname",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
@@ -65,5 +73,6 @@
|
||||
"quick_entry": 1,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"align_labels_right": 0,
|
||||
"creation": "2017-08-08 12:33:04.773099",
|
||||
"custom_format": 1,
|
||||
"disabled": 0,
|
||||
"doc_type": "Sales Invoice",
|
||||
"docstatus": 0,
|
||||
"doctype": "Print Format",
|
||||
"font": "Default",
|
||||
"html": "<style>\n\t.print-format table, .print-format tr, \n\t.print-format td, .print-format div, .print-format p {\n\t\tfont-family: Tahoma, sans-serif;\n\t\tline-height: 150%;\n\t\tvertical-align: middle;\n\t}\n\t@media screen {\n\t\t.print-format {\n\t\t\twidth: 4in;\n\t\t\tpadding: 0.25in;\n\t\t\tmin-height: 8in;\n\t\t}\n\t}\n</style>\n\n{% if letter_head %}\n {{ letter_head }}\n{% endif %}\n<p class=\"text-center\">\n\t{{ doc.company }}<br>\n\t{% if doc.company_address_display %}\n\t\t{% set company_address = doc.company_address_display.replace(\"\\n\", \" \").replace(\"<br>\", \" \") %}\n\t\t{% if \"GSTIN\" not in company_address %}\n\t\t\t{{ company_address }}\n\t\t\t<b>{{ _(\"GSTIN\") }}:</b>{{ doc.company_gstin }}\n\t\t{% else %}\n\t\t\t{{ company_address.replace(\"GSTIN\", \"<br>GSTIN\") }}\n\t\t{% endif %}\n\t{% endif %}\n\t<br>\n\t{% if doc.docstatus == 0 %}\n\t\t<b>{{ doc.status + \" \"+ (doc.select_print_heading or _(\"Invoice\")) }}</b><br>\n\t{% else %}\n\t\t<b>{{ doc.select_print_heading or _(\"Invoice\") }}</b><br>\n\t{% endif %}\n</p>\n<p>\n\t<b>{{ _(\"Receipt No\") }}:</b> {{ doc.name }}<br>\n\t<b>{{ _(\"Date\") }}:</b> {{ doc.get_formatted(\"posting_date\") }}<br>\n\t{% if doc.grand_total > 50000 %}\n\t\t{% set customer_address = doc.address_display.replace(\"\\n\", \" \").replace(\"<br>\", \" \") %}\n\t\t<b>{{ _(\"Customer\") }}:</b><br>\n\t\t{{ doc.customer_name }}<br>\n\t\t{{ customer_address }}\n\t{% endif %}\n</p>\n\n<hr>\n<table class=\"table table-condensed cart no-border\">\n\t<thead>\n\t\t<tr>\n\t\t\t<th width=\"50%\">{{ _(\"Item\") }}</b></th>\n\t\t\t<th width=\"25%\" class=\"text-right\">{{ _(\"Qty\") }}</th>\n\t\t\t<th width=\"25%\" class=\"text-right\">{{ _(\"Amount\") }}</th>\n\t\t</tr>\n\t</thead>\n\t<tbody>\n\t\t{%- for item in doc.items -%}\n\t\t<tr>\n\t\t\t<td>\n\t\t\t\t{{ item.item_code }}\n\t\t\t\t{%- if item.item_name != item.item_code -%}\n\t\t\t\t\t<br>{{ item.item_name }}\n\t\t\t\t{%- endif -%}\n\t\t\t\t{%- if item.gst_hsn_code -%}\n\t\t\t\t\t<br><b>{{ _(\"HSN/SAC\") }}:</b> {{ item.gst_hsn_code }}\n\t\t\t\t{%- endif -%}\n\t\t\t\t{%- if item.serial_no -%}\n\t\t\t\t\t<br><b>{{ _(\"Serial No\") }}:</b> {{ item.serial_no }}\n\t\t\t\t{%- endif -%}\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">{{ item.qty }}<br>@ {{ item.rate }}</td>\n\t\t\t<td class=\"text-right\">{{ item.get_formatted(\"amount\") }}</td>\n\t\t</tr>\n\t\t{%- endfor -%}\n\t</tbody>\n</table>\n<table class=\"table table-condensed no-border\">\n\t<tbody>\n\t\t<tr>\n\t\t\t{% if doc.flags.show_inclusive_tax_in_print %}\n\t\t\t\t<td class=\"text-right\" style=\"width: 70%\">\n\t\t\t\t\t{{ _(\"Total Excl. Tax\") }}\n\t\t\t\t</td>\n\t\t\t\t<td class=\"text-right\">\n\t\t\t\t\t{{ doc.get_formatted(\"net_total\", doc) }}\n\t\t\t\t</td>\n\t\t\t{% else %}\n\t\t\t\t<td class=\"text-right\" style=\"width: 70%\">\n\t\t\t\t\t{{ _(\"Total\") }}\n\t\t\t\t</td>\n\t\t\t\t<td class=\"text-right\">\n\t\t\t\t\t{{ doc.get_formatted(\"total\", doc) }}\n\t\t\t\t</td>\n\t\t\t{% endif %}\n\t\t</tr>\n\t\t{%- for row in doc.taxes -%}\n\t\t {%- if (not row.included_in_print_rate or doc.flags.show_inclusive_tax_in_print) and row.tax_amount != 0 -%}\n\t\t\t<tr>\n\t\t\t\t<td class=\"text-right\" style=\"width: 70%\">\n\t\t\t\t\t{{ row.description }}\n\t\t\t\t</td>\n\t\t\t\t<td class=\"text-right\">\n\t\t\t\t\t{{ row.get_formatted(\"tax_amount\", doc) }}\n\t\t\t\t</td>\n\t\t\t<tr>\n\t\t {%- endif -%}\n\t\t{%- endfor -%}\n\t\t{%- if doc.discount_amount -%}\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 75%\">\n\t\t\t\t{{ _(\"Discount\") }}\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ doc.get_formatted(\"discount_amount\") }}\n\t\t\t</td>\n\t\t</tr>\n\t\t{%- endif -%}\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 75%\">\n\t\t\t\t<b>{{ _(\"Grand Total\") }}</b>\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ doc.get_formatted(\"grand_total\") }}\n\t\t\t</td>\n\t\t</tr>\n\t\t{%- if doc.rounded_total -%}\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 75%\">\n\t\t\t\t<b>{{ _(\"Rounded Total\") }}</b>\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ doc.get_formatted(\"rounded_total\") }}\n\t\t\t</td>\n\t\t</tr>\n\t\t{%- endif -%}\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 75%\">\n\t\t\t\t<b>{{ _(\"Paid Amount\") }}</b>\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ doc.get_formatted(\"paid_amount\") }}\n\t\t\t</td>\n\t\t</tr>\n\t{%- if doc.change_amount -%}\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 75%\">\n\t\t\t\t<b>{{ _(\"Change Amount\") }}</b>\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ doc.get_formatted(\"change_amount\") }}\n\t\t\t</td>\n\t\t</tr>\n\t{%- endif -%}\n\t</tbody>\n</table>\n<p>{{ doc.terms or \"\" }}</p>\n<p class=\"text-center\">{{ _(\"Thank you, please visit again.\") }}</p>",
|
||||
"idx": 0,
|
||||
"line_breaks": 0,
|
||||
"modified": "2020-04-29 16:39:12.936215",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "GST POS Invoice",
|
||||
"owner": "Administrator",
|
||||
"print_format_builder": 0,
|
||||
"print_format_type": "Jinja",
|
||||
"raw_printing": 0,
|
||||
"show_section_headings": 0,
|
||||
"standard": "Yes"
|
||||
}
|
||||
@@ -354,9 +354,6 @@ def accumulate_values_into_parents(accounts, accounts_by_name, companies):
|
||||
if d.parent_account:
|
||||
account = d.parent_account_name
|
||||
|
||||
# if not accounts_by_name.get(account):
|
||||
# continue
|
||||
|
||||
for company in companies:
|
||||
accounts_by_name[account][company] = \
|
||||
accounts_by_name[account].get(company, 0.0) + d.get(company, 0.0)
|
||||
@@ -367,7 +364,7 @@ def accumulate_values_into_parents(accounts, accounts_by_name, companies):
|
||||
accounts_by_name[account].get("opening_balance", 0.0) + d.get("opening_balance", 0.0)
|
||||
|
||||
def get_account_heads(root_type, companies, filters):
|
||||
accounts = get_accounts(root_type, filters)
|
||||
accounts = get_accounts(root_type, companies)
|
||||
|
||||
if not accounts:
|
||||
return None, None, None
|
||||
@@ -396,7 +393,7 @@ def update_parent_account_names(accounts):
|
||||
|
||||
for account in accounts:
|
||||
if account.parent_account:
|
||||
account["parent_account_name"] = name_to_account_map[account.parent_account]
|
||||
account["parent_account_name"] = name_to_account_map.get(account.parent_account)
|
||||
|
||||
return accounts
|
||||
|
||||
@@ -419,12 +416,19 @@ def get_subsidiary_companies(company):
|
||||
return frappe.db.sql_list("""select name from `tabCompany`
|
||||
where lft >= {0} and rgt <= {1} order by lft, rgt""".format(lft, rgt))
|
||||
|
||||
def get_accounts(root_type, filters):
|
||||
return frappe.db.sql(""" select name, is_group, company,
|
||||
parent_account, lft, rgt, root_type, report_type, account_name, account_number
|
||||
from
|
||||
`tabAccount` where company = %s and root_type = %s
|
||||
""" , (filters.get('company'), root_type), as_dict=1)
|
||||
def get_accounts(root_type, companies):
|
||||
accounts = []
|
||||
added_accounts = []
|
||||
|
||||
for company in companies:
|
||||
for account in frappe.get_all("Account", fields=["name", "is_group", "company",
|
||||
"parent_account", "lft", "rgt", "root_type", "report_type", "account_name", "account_number"],
|
||||
filters={"company": company, "root_type": root_type}):
|
||||
if account.account_name not in added_accounts:
|
||||
accounts.append(account)
|
||||
added_accounts.append(account.account_name)
|
||||
|
||||
return accounts
|
||||
|
||||
def prepare_data(accounts, start_date, end_date, balance_must_be, companies, company_currency, filters):
|
||||
data = []
|
||||
|
||||
@@ -42,6 +42,11 @@ frappe.query_reports["Gross Profit"] = {
|
||||
"parent_field": "parent_invoice",
|
||||
"initial_depth": 3,
|
||||
"formatter": function(value, row, column, data, default_formatter) {
|
||||
if (column.fieldname == "sales_invoice" && column.options == "Item" && data.indent == 0) {
|
||||
column._options = "Sales Invoice";
|
||||
} else {
|
||||
column._options = "Item";
|
||||
}
|
||||
value = default_formatter(value, row, column, data);
|
||||
|
||||
if (data && (data.indent == 0.0 || row[1].content == "Total")) {
|
||||
|
||||
@@ -23,7 +23,7 @@ def validate_filters(filters):
|
||||
def get_result(filters, tds_docs, tds_accounts, tax_category_map):
|
||||
supplier_map = get_supplier_pan_map()
|
||||
tax_rate_map = get_tax_rate_map(filters)
|
||||
gle_map = get_gle_map(filters, tds_docs)
|
||||
gle_map = get_gle_map(tds_docs)
|
||||
|
||||
out = []
|
||||
for name, details in gle_map.items():
|
||||
@@ -78,7 +78,7 @@ def get_supplier_pan_map():
|
||||
|
||||
return supplier_map
|
||||
|
||||
def get_gle_map(filters, documents):
|
||||
def get_gle_map(documents):
|
||||
# create gle_map of the form
|
||||
# {"purchase_invoice": list of dict of all gle created for this invoice}
|
||||
gle_map = {}
|
||||
@@ -86,7 +86,7 @@ def get_gle_map(filters, documents):
|
||||
gle = frappe.db.get_all('GL Entry',
|
||||
{
|
||||
"voucher_no": ["in", documents],
|
||||
"credit": (">", 0)
|
||||
"is_cancelled": 0
|
||||
},
|
||||
["credit", "debit", "account", "voucher_no", "posting_date", "voucher_type", "against", "party"],
|
||||
)
|
||||
@@ -184,21 +184,28 @@ def get_tds_docs(filters):
|
||||
payment_entries = []
|
||||
journal_entries = []
|
||||
tax_category_map = {}
|
||||
or_filters = {}
|
||||
bank_accounts = frappe.get_all('Account', {'is_group': 0, 'account_type': 'Bank'}, pluck="name")
|
||||
|
||||
tds_accounts = frappe.get_all("Tax Withholding Account", {'company': filters.get('company')},
|
||||
pluck="account")
|
||||
|
||||
query_filters = {
|
||||
"credit": ('>', 0),
|
||||
"account": ("in", tds_accounts),
|
||||
"posting_date": ("between", [filters.get("from_date"), filters.get("to_date")]),
|
||||
"is_cancelled": 0
|
||||
"is_cancelled": 0,
|
||||
"against": ("not in", bank_accounts)
|
||||
}
|
||||
|
||||
if filters.get('supplier'):
|
||||
query_filters.update({'against': filters.get('supplier')})
|
||||
if filters.get("supplier"):
|
||||
del query_filters["account"]
|
||||
del query_filters["against"]
|
||||
or_filters = {
|
||||
"against": filters.get('supplier'),
|
||||
"party": filters.get('supplier')
|
||||
}
|
||||
|
||||
tds_docs = frappe.get_all("GL Entry", query_filters, ["voucher_no", "voucher_type", "against", "party"])
|
||||
tds_docs = frappe.get_all("GL Entry", filters=query_filters, or_filters=or_filters, fields=["voucher_no", "voucher_type", "against", "party"])
|
||||
|
||||
for d in tds_docs:
|
||||
if d.voucher_type == "Purchase Invoice":
|
||||
|
||||
@@ -141,6 +141,7 @@
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"fetch_from": "item_code.image",
|
||||
"fieldname": "image",
|
||||
"fieldtype": "Attach Image",
|
||||
"hidden": 1,
|
||||
@@ -502,7 +503,7 @@
|
||||
"link_fieldname": "asset"
|
||||
}
|
||||
],
|
||||
"modified": "2021-06-24 14:58:51.097908",
|
||||
"modified": "2022-01-30 20:19:24.680027",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Assets",
|
||||
"name": "Asset",
|
||||
@@ -544,4 +545,4 @@
|
||||
"sort_order": "DESC",
|
||||
"title_field": "asset_name",
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -682,17 +682,18 @@ class TestPurchaseOrder(unittest.TestCase):
|
||||
|
||||
bin1 = frappe.db.get_value("Bin",
|
||||
filters={"warehouse": "_Test Warehouse - _TC", "item_code": "_Test Item"},
|
||||
fieldname=["reserved_qty_for_sub_contract", "projected_qty"], as_dict=1)
|
||||
fieldname=["reserved_qty_for_sub_contract", "projected_qty", "modified"], as_dict=1)
|
||||
|
||||
# Submit PO
|
||||
po = create_purchase_order(item_code="_Test FG Item", is_subcontracted="Yes")
|
||||
|
||||
bin2 = frappe.db.get_value("Bin",
|
||||
filters={"warehouse": "_Test Warehouse - _TC", "item_code": "_Test Item"},
|
||||
fieldname=["reserved_qty_for_sub_contract", "projected_qty"], as_dict=1)
|
||||
fieldname=["reserved_qty_for_sub_contract", "projected_qty", "modified"], as_dict=1)
|
||||
|
||||
self.assertEqual(bin2.reserved_qty_for_sub_contract, bin1.reserved_qty_for_sub_contract + 10)
|
||||
self.assertEqual(bin2.projected_qty, bin1.projected_qty - 10)
|
||||
self.assertNotEqual(bin1.modified, bin2.modified)
|
||||
|
||||
# Create stock transfer
|
||||
rm_item = [{"item_code":"_Test FG Item","rm_item_code":"_Test Item","item_name":"_Test Item",
|
||||
|
||||
@@ -49,7 +49,7 @@ valid_scorecard = [
|
||||
"min_grade":0.0,"name":"Very Poor",
|
||||
"prevent_rfqs":1,
|
||||
"notify_supplier":0,
|
||||
"doctype":"Supplier Scorecard Standing",
|
||||
"doctype":"Supplier Scorecard Scoring Standing",
|
||||
"max_grade":30.0,
|
||||
"prevent_pos":1,
|
||||
"warn_pos":0,
|
||||
@@ -65,7 +65,7 @@ valid_scorecard = [
|
||||
"name":"Poor",
|
||||
"prevent_rfqs":1,
|
||||
"notify_supplier":0,
|
||||
"doctype":"Supplier Scorecard Standing",
|
||||
"doctype":"Supplier Scorecard Scoring Standing",
|
||||
"max_grade":50.0,
|
||||
"prevent_pos":0,
|
||||
"warn_pos":0,
|
||||
@@ -81,7 +81,7 @@ valid_scorecard = [
|
||||
"name":"Average",
|
||||
"prevent_rfqs":0,
|
||||
"notify_supplier":0,
|
||||
"doctype":"Supplier Scorecard Standing",
|
||||
"doctype":"Supplier Scorecard Scoring Standing",
|
||||
"max_grade":80.0,
|
||||
"prevent_pos":0,
|
||||
"warn_pos":0,
|
||||
@@ -97,7 +97,7 @@ valid_scorecard = [
|
||||
"name":"Excellent",
|
||||
"prevent_rfqs":0,
|
||||
"notify_supplier":0,
|
||||
"doctype":"Supplier Scorecard Standing",
|
||||
"doctype":"Supplier Scorecard Scoring Standing",
|
||||
"max_grade":100.0,
|
||||
"prevent_pos":0,
|
||||
"warn_pos":0,
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -205,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(
|
||||
@@ -252,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(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from typing import List, Tuple
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
@@ -181,33 +182,28 @@ class StockController(AccountsController):
|
||||
|
||||
return details
|
||||
|
||||
def get_items_and_warehouses(self):
|
||||
items, warehouses = [], []
|
||||
def get_items_and_warehouses(self) -> Tuple[List[str], List[str]]:
|
||||
"""Get list of items and warehouses affected by a transaction"""
|
||||
|
||||
if hasattr(self, "items"):
|
||||
item_doclist = self.get("items")
|
||||
elif self.doctype == "Stock Reconciliation":
|
||||
item_doclist = []
|
||||
data = json.loads(self.reconciliation_json)
|
||||
for row in data[data.index(self.head_row)+1:]:
|
||||
d = frappe._dict(zip(["item_code", "warehouse", "qty", "valuation_rate"], row))
|
||||
item_doclist.append(d)
|
||||
if not (hasattr(self, "items") or hasattr(self, "packed_items")):
|
||||
return [], []
|
||||
|
||||
if item_doclist:
|
||||
for d in item_doclist:
|
||||
if d.item_code and d.item_code not in items:
|
||||
items.append(d.item_code)
|
||||
item_rows = (self.get("items") or []) + (self.get("packed_items") or [])
|
||||
|
||||
if d.get("warehouse") and d.warehouse not in warehouses:
|
||||
warehouses.append(d.warehouse)
|
||||
items = {d.item_code for d in item_rows if d.item_code}
|
||||
|
||||
if self.doctype == "Stock Entry":
|
||||
if d.get("s_warehouse") and d.s_warehouse not in warehouses:
|
||||
warehouses.append(d.s_warehouse)
|
||||
if d.get("t_warehouse") and d.t_warehouse not in warehouses:
|
||||
warehouses.append(d.t_warehouse)
|
||||
warehouses = set()
|
||||
for d in item_rows:
|
||||
if d.get("warehouse"):
|
||||
warehouses.add(d.warehouse)
|
||||
|
||||
return items, warehouses
|
||||
if self.doctype == "Stock Entry":
|
||||
if d.get("s_warehouse"):
|
||||
warehouses.add(d.s_warehouse)
|
||||
if d.get("t_warehouse"):
|
||||
warehouses.add(d.t_warehouse)
|
||||
|
||||
return list(items), list(warehouses)
|
||||
|
||||
def get_stock_ledger_details(self):
|
||||
stock_ledger = {}
|
||||
@@ -219,7 +215,7 @@ class StockController(AccountsController):
|
||||
from
|
||||
`tabStock Ledger Entry`
|
||||
where
|
||||
voucher_type=%s and voucher_no=%s
|
||||
voucher_type=%s and voucher_no=%s and is_cancelled = 0
|
||||
""", (self.doctype, self.name), as_dict=True)
|
||||
|
||||
for sle in stock_ledger_entries:
|
||||
|
||||
@@ -106,6 +106,9 @@ class calculate_taxes_and_totals(object):
|
||||
self.doc.conversion_rate = flt(self.doc.conversion_rate)
|
||||
|
||||
def calculate_item_values(self):
|
||||
if self.doc.get('is_consolidated'):
|
||||
return
|
||||
|
||||
if not self.discount_amount_applied:
|
||||
for item in self.doc.get("items"):
|
||||
self.doc.round_floats_in(item)
|
||||
@@ -646,12 +649,12 @@ class calculate_taxes_and_totals(object):
|
||||
def calculate_change_amount(self):
|
||||
self.doc.change_amount = 0.0
|
||||
self.doc.base_change_amount = 0.0
|
||||
grand_total = self.doc.rounded_total or self.doc.grand_total
|
||||
base_grand_total = self.doc.base_rounded_total or self.doc.base_grand_total
|
||||
|
||||
if self.doc.doctype == "Sales Invoice" \
|
||||
and self.doc.paid_amount > self.doc.grand_total and not self.doc.is_return \
|
||||
and self.doc.paid_amount > grand_total and not self.doc.is_return \
|
||||
and any(d.type == "Cash" for d in self.doc.payments):
|
||||
grand_total = self.doc.rounded_total or self.doc.grand_total
|
||||
base_grand_total = self.doc.base_rounded_total or self.doc.base_grand_total
|
||||
|
||||
self.doc.change_amount = flt(self.doc.paid_amount - grand_total +
|
||||
self.doc.write_off_amount, self.doc.precision("change_amount"))
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"allow_events_in_timeline": 0,
|
||||
"allow_guest_to_view": 0,
|
||||
"allow_import": 0,
|
||||
"allow_rename": 0,
|
||||
"allow_rename": 1,
|
||||
"autoname": "field:lost_reason",
|
||||
"beta": 0,
|
||||
"creation": "2018-12-28 14:48:51.044975",
|
||||
@@ -57,7 +57,7 @@
|
||||
"issingle": 0,
|
||||
"istable": 0,
|
||||
"max_attachments": 0,
|
||||
"modified": "2018-12-28 14:49:43.336437",
|
||||
"modified": "2022-02-16 10:49:43.336437",
|
||||
"modified_by": "Administrator",
|
||||
"module": "CRM",
|
||||
"name": "Opportunity Lost Reason",
|
||||
@@ -150,4 +150,4 @@
|
||||
"track_changes": 0,
|
||||
"track_seen": 0,
|
||||
"track_views": 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,26 +66,24 @@ class ItemVariantsCacheManager:
|
||||
)
|
||||
]
|
||||
|
||||
# join with Website Item
|
||||
item_variants_data = frappe.get_all(
|
||||
'Item Variant Attribute',
|
||||
{'variant_of': parent_item_code},
|
||||
['parent', 'attribute', 'attribute_value'],
|
||||
order_by='name',
|
||||
as_list=1
|
||||
)
|
||||
|
||||
disabled_items = set(
|
||||
[i.name for i in frappe.db.get_all('Item', {'disabled': 1})]
|
||||
# Get Variants and tehir Attributes that are not disabled
|
||||
iva = frappe.qb.DocType("Item Variant Attribute")
|
||||
item = frappe.qb.DocType("Item")
|
||||
query = (
|
||||
frappe.qb.from_(iva)
|
||||
.join(item).on(item.name == iva.parent)
|
||||
.select(
|
||||
iva.parent, iva.attribute, iva.attribute_value
|
||||
).where(
|
||||
(iva.variant_of == parent_item_code)
|
||||
& (item.disabled == 0)
|
||||
).orderby(iva.name)
|
||||
)
|
||||
item_variants_data = query.run()
|
||||
|
||||
attribute_value_item_map = frappe._dict()
|
||||
item_attribute_value_map = frappe._dict()
|
||||
|
||||
# dont consider variants that are disabled
|
||||
# pull all other variants
|
||||
item_variants_data = [r for r in item_variants_data if r[0] not in disabled_items]
|
||||
|
||||
for row in item_variants_data:
|
||||
item_code, attribute, attribute_value = row
|
||||
# (attr, value) => [item1, item2]
|
||||
@@ -124,4 +122,7 @@ def build_cache(item_code):
|
||||
def enqueue_build_cache(item_code):
|
||||
if frappe.cache().hget('item_cache_build_in_progress', item_code):
|
||||
return
|
||||
frappe.enqueue(build_cache, item_code=item_code, queue='long')
|
||||
frappe.enqueue(
|
||||
"erpnext.e_commerce.variant_selector.item_variants_cache.build_cache",
|
||||
item_code=item_code, queue='long'
|
||||
)
|
||||
|
||||
@@ -106,6 +106,8 @@ class TestVariantSelector(ERPNextTestCase):
|
||||
})
|
||||
|
||||
make_web_item_price(item_code="Test-Tshirt-Temp-S-R", price_list_rate=100)
|
||||
|
||||
frappe.local.shopping_cart_settings = None # clear cached settings values
|
||||
next_values = get_next_attribute_and_values(
|
||||
"Test-Tshirt-Temp",
|
||||
selected_attributes={"Test Size": "Small", "Test Colour": "Red"}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ from six.moves.urllib.parse import urlencode
|
||||
|
||||
|
||||
class GoCardlessSettings(Document):
|
||||
supported_currencies = ["EUR", "DKK", "GBP", "SEK"]
|
||||
supported_currencies = ["EUR", "DKK", "GBP", "SEK", "AUD", "NZD", "CAD", "USD"]
|
||||
|
||||
def validate(self):
|
||||
self.initialize_client()
|
||||
@@ -79,7 +79,7 @@ class GoCardlessSettings(Document):
|
||||
|
||||
def validate_transaction_currency(self, currency):
|
||||
if currency not in self.supported_currencies:
|
||||
frappe.throw(_("Please select another payment method. Stripe does not support transactions in currency '{0}'").format(currency))
|
||||
frappe.throw(_("Please select another payment method. Go Cardless does not support transactions in currency '{0}'").format(currency))
|
||||
|
||||
def get_payment_url(self, **kwargs):
|
||||
return get_url("./integrations/gocardless_checkout?{0}".format(urlencode(kwargs)))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -545,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)
|
||||
|
||||
@@ -553,7 +553,7 @@ class TestLeaveApplication(unittest.TestCase):
|
||||
frappe.db.set_value('Leave Type', leave_type, 'max_leaves_allowed', 0)
|
||||
i = 0
|
||||
while(i<6):
|
||||
allocate_earned_leaves()
|
||||
allocate_earned_leaves(ignore_duplicates=True)
|
||||
i += 1
|
||||
self.assertEqual(get_leave_balance_on(employee.name, leave_type, nowdate()), 9)
|
||||
|
||||
|
||||
@@ -8,12 +8,11 @@ from math import ceil
|
||||
import frappe
|
||||
from frappe import _, bold
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import date_diff, flt, formatdate, get_datetime, getdate
|
||||
from frappe.utils import date_diff, flt, formatdate, get_last_day, getdate
|
||||
from six import string_types
|
||||
|
||||
|
||||
class LeavePolicyAssignment(Document):
|
||||
|
||||
def validate(self):
|
||||
self.validate_policy_assignment_overlap()
|
||||
self.set_dates()
|
||||
@@ -95,10 +94,12 @@ class LeavePolicyAssignment(Document):
|
||||
new_leaves_allocated = 0
|
||||
|
||||
elif leave_type_details.get(leave_type).is_earned_leave == 1:
|
||||
if self.assignment_based_on == "Leave Period":
|
||||
new_leaves_allocated = self.get_leaves_for_passed_months(leave_type, new_leaves_allocated, leave_type_details, date_of_joining)
|
||||
else:
|
||||
if not self.assignment_based_on:
|
||||
new_leaves_allocated = 0
|
||||
else:
|
||||
# get leaves for past months if assignment is based on Leave Period / Joining Date
|
||||
new_leaves_allocated = self.get_leaves_for_passed_months(leave_type, new_leaves_allocated, leave_type_details, date_of_joining)
|
||||
|
||||
# Calculate leaves at pro-rata basis for employees joining after the beginning of the given leave period
|
||||
elif getdate(date_of_joining) > getdate(self.effective_from):
|
||||
remaining_period = ((date_diff(self.effective_to, date_of_joining) + 1) / (date_diff(self.effective_to, self.effective_from) + 1))
|
||||
@@ -109,21 +110,24 @@ class LeavePolicyAssignment(Document):
|
||||
def get_leaves_for_passed_months(self, leave_type, new_leaves_allocated, leave_type_details, date_of_joining):
|
||||
from erpnext.hr.utils import get_monthly_earned_leave
|
||||
|
||||
current_month = get_datetime().month
|
||||
current_year = get_datetime().year
|
||||
current_date = frappe.flags.current_date or getdate()
|
||||
if current_date > getdate(self.effective_to):
|
||||
current_date = getdate(self.effective_to)
|
||||
|
||||
from_date = frappe.db.get_value("Leave Period", self.leave_period, "from_date")
|
||||
if getdate(date_of_joining) > getdate(from_date):
|
||||
from_date = date_of_joining
|
||||
|
||||
from_date_month = get_datetime(from_date).month
|
||||
from_date_year = get_datetime(from_date).year
|
||||
from_date = getdate(self.effective_from)
|
||||
if getdate(date_of_joining) > from_date:
|
||||
from_date = getdate(date_of_joining)
|
||||
|
||||
months_passed = 0
|
||||
if current_year == from_date_year and current_month > from_date_month:
|
||||
months_passed = current_month - from_date_month
|
||||
elif current_year > from_date_year:
|
||||
months_passed = (12 - from_date_month) + current_month
|
||||
based_on_doj = leave_type_details.get(leave_type).based_on_date_of_joining
|
||||
|
||||
if current_date.year == from_date.year and current_date.month >= from_date.month:
|
||||
months_passed = current_date.month - from_date.month
|
||||
months_passed = add_current_month_if_applicable(months_passed, date_of_joining, based_on_doj)
|
||||
|
||||
elif current_date.year > from_date.year:
|
||||
months_passed = (12 - from_date.month) + current_date.month
|
||||
months_passed = add_current_month_if_applicable(months_passed, date_of_joining, based_on_doj)
|
||||
|
||||
if months_passed > 0:
|
||||
monthly_earned_leave = get_monthly_earned_leave(new_leaves_allocated,
|
||||
@@ -135,6 +139,23 @@ class LeavePolicyAssignment(Document):
|
||||
return new_leaves_allocated
|
||||
|
||||
|
||||
def add_current_month_if_applicable(months_passed, date_of_joining, based_on_doj):
|
||||
date = getdate(frappe.flags.current_date) or getdate()
|
||||
|
||||
if based_on_doj:
|
||||
# if leave type allocation is based on DOJ, and the date of assignment creation is same as DOJ,
|
||||
# then the month should be considered
|
||||
if date.day == date_of_joining.day:
|
||||
months_passed += 1
|
||||
else:
|
||||
last_day_of_month = get_last_day(date)
|
||||
# if its the last day of the month, then that month should be considered
|
||||
if last_day_of_month == date:
|
||||
months_passed += 1
|
||||
|
||||
return months_passed
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_assignment_for_multiple_employees(employees, data):
|
||||
|
||||
@@ -169,7 +190,7 @@ def create_assignment_for_multiple_employees(employees, data):
|
||||
def get_leave_type_details():
|
||||
leave_type_details = frappe._dict()
|
||||
leave_types = frappe.get_all("Leave Type",
|
||||
fields=["name", "is_lwp", "is_earned_leave", "is_compensatory",
|
||||
fields=["name", "is_lwp", "is_earned_leave", "is_compensatory", "based_on_date_of_joining",
|
||||
"is_carry_forward", "expire_carry_forwarded_leaves_after_days", "earned_leave_frequency", "rounding"])
|
||||
for d in leave_types:
|
||||
leave_type_details.setdefault(d.name, d)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import unittest
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_months, get_first_day, getdate
|
||||
from frappe.utils import add_months, get_first_day, get_last_day, getdate
|
||||
|
||||
from erpnext.hr.doctype.leave_application.test_leave_application import (
|
||||
get_employee,
|
||||
@@ -20,36 +20,31 @@ test_dependencies = ["Employee"]
|
||||
class TestLeavePolicyAssignment(unittest.TestCase):
|
||||
def setUp(self):
|
||||
for doctype in ["Leave Period", "Leave Application", "Leave Allocation", "Leave Policy Assignment", "Leave Ledger Entry"]:
|
||||
frappe.db.sql("delete from `tab{0}`".format(doctype)) #nosec
|
||||
frappe.db.delete(doctype)
|
||||
|
||||
employee = get_employee()
|
||||
self.original_doj = employee.date_of_joining
|
||||
self.employee = employee
|
||||
|
||||
def test_grant_leaves(self):
|
||||
leave_period = get_leave_period()
|
||||
employee = get_employee()
|
||||
|
||||
# create the leave policy with leave type "_Test Leave Type", allocation = 10
|
||||
# allocation = 10
|
||||
leave_policy = create_leave_policy()
|
||||
leave_policy.submit()
|
||||
|
||||
|
||||
data = {
|
||||
"assignment_based_on": "Leave Period",
|
||||
"leave_policy": leave_policy.name,
|
||||
"leave_period": leave_period.name
|
||||
}
|
||||
|
||||
leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data))
|
||||
|
||||
leave_policy_assignment_doc = frappe.get_doc("Leave Policy Assignment", leave_policy_assignments[0])
|
||||
leave_policy_assignment_doc.reload()
|
||||
|
||||
self.assertEqual(leave_policy_assignment_doc.leaves_allocated, 1)
|
||||
leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
|
||||
self.assertEqual(frappe.db.get_value("Leave Policy Assignment", leave_policy_assignments[0], "leaves_allocated"), 1)
|
||||
|
||||
leave_allocation = frappe.get_list("Leave Allocation", filters={
|
||||
"employee": employee.name,
|
||||
"employee": self.employee.name,
|
||||
"leave_policy":leave_policy.name,
|
||||
"leave_policy_assignment": leave_policy_assignments[0],
|
||||
"docstatus": 1})[0]
|
||||
|
||||
leave_alloc_doc = frappe.get_doc("Leave Allocation", leave_allocation)
|
||||
|
||||
self.assertEqual(leave_alloc_doc.new_leaves_allocated, 10)
|
||||
@@ -61,62 +56,45 @@ class TestLeavePolicyAssignment(unittest.TestCase):
|
||||
|
||||
def test_allow_to_grant_all_leave_after_cancellation_of_every_leave_allocation(self):
|
||||
leave_period = get_leave_period()
|
||||
employee = get_employee()
|
||||
|
||||
# create the leave policy with leave type "_Test Leave Type", allocation = 10
|
||||
leave_policy = create_leave_policy()
|
||||
leave_policy.submit()
|
||||
|
||||
|
||||
data = {
|
||||
"assignment_based_on": "Leave Period",
|
||||
"leave_policy": leave_policy.name,
|
||||
"leave_period": leave_period.name
|
||||
}
|
||||
|
||||
leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data))
|
||||
|
||||
leave_policy_assignment_doc = frappe.get_doc("Leave Policy Assignment", leave_policy_assignments[0])
|
||||
leave_policy_assignment_doc.reload()
|
||||
|
||||
leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
|
||||
|
||||
# every leave is allocated no more leave can be granted now
|
||||
self.assertEqual(leave_policy_assignment_doc.leaves_allocated, 1)
|
||||
|
||||
self.assertEqual(frappe.db.get_value("Leave Policy Assignment", leave_policy_assignments[0], "leaves_allocated"), 1)
|
||||
leave_allocation = frappe.get_list("Leave Allocation", filters={
|
||||
"employee": employee.name,
|
||||
"employee": self.employee.name,
|
||||
"leave_policy":leave_policy.name,
|
||||
"leave_policy_assignment": leave_policy_assignments[0],
|
||||
"docstatus": 1})[0]
|
||||
|
||||
leave_alloc_doc = frappe.get_doc("Leave Allocation", leave_allocation)
|
||||
|
||||
# User all allowed to grant leave when there is no allocation against assignment
|
||||
leave_alloc_doc.cancel()
|
||||
leave_alloc_doc.delete()
|
||||
|
||||
leave_policy_assignment_doc.reload()
|
||||
|
||||
|
||||
# User are now allowed to grant leave
|
||||
self.assertEqual(leave_policy_assignment_doc.leaves_allocated, 0)
|
||||
self.assertEqual(frappe.db.get_value("Leave Policy Assignment", leave_policy_assignments[0], "leaves_allocated"), 0)
|
||||
|
||||
def test_earned_leave_allocation(self):
|
||||
leave_period = create_leave_period("Test Earned Leave Period")
|
||||
employee = get_employee()
|
||||
leave_type = create_earned_leave_type("Test Earned Leave")
|
||||
|
||||
leave_policy = frappe.get_doc({
|
||||
"doctype": "Leave Policy",
|
||||
"leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 6}]
|
||||
}).insert()
|
||||
}).submit()
|
||||
|
||||
data = {
|
||||
"assignment_based_on": "Leave Period",
|
||||
"leave_policy": leave_policy.name,
|
||||
"leave_period": leave_period.name
|
||||
}
|
||||
leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data))
|
||||
leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
|
||||
|
||||
# leaves allocated should be 0 since it is an earned leave and allocation happens via scheduler based on set frequency
|
||||
leaves_allocated = frappe.db.get_value("Leave Allocation", {
|
||||
@@ -124,11 +102,200 @@ class TestLeavePolicyAssignment(unittest.TestCase):
|
||||
}, "total_leaves_allocated")
|
||||
self.assertEqual(leaves_allocated, 0)
|
||||
|
||||
def test_earned_leave_alloc_for_passed_months_based_on_leave_period(self):
|
||||
leave_period, leave_policy = setup_leave_period_and_policy(get_first_day(add_months(getdate(), -1)))
|
||||
|
||||
# Case 1: assignment created one month after the leave period, should allocate 1 leave
|
||||
frappe.flags.current_date = get_first_day(getdate())
|
||||
data = {
|
||||
"assignment_based_on": "Leave Period",
|
||||
"leave_policy": leave_policy.name,
|
||||
"leave_period": leave_period.name
|
||||
}
|
||||
leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
|
||||
|
||||
leaves_allocated = frappe.db.get_value("Leave Allocation", {
|
||||
"leave_policy_assignment": leave_policy_assignments[0]
|
||||
}, "total_leaves_allocated")
|
||||
self.assertEqual(leaves_allocated, 1)
|
||||
|
||||
def test_earned_leave_alloc_for_passed_months_on_month_end_based_on_leave_period(self):
|
||||
leave_period, leave_policy = setup_leave_period_and_policy(get_first_day(add_months(getdate(), -2)))
|
||||
# Case 2: assignment created on the last day of the leave period's latter month
|
||||
# should allocate 1 leave for current month even though the month has not ended
|
||||
# since the daily job might have already executed
|
||||
frappe.flags.current_date = get_last_day(getdate())
|
||||
|
||||
data = {
|
||||
"assignment_based_on": "Leave Period",
|
||||
"leave_policy": leave_policy.name,
|
||||
"leave_period": leave_period.name
|
||||
}
|
||||
leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
|
||||
|
||||
leaves_allocated = frappe.db.get_value("Leave Allocation", {
|
||||
"leave_policy_assignment": leave_policy_assignments[0]
|
||||
}, "total_leaves_allocated")
|
||||
self.assertEqual(leaves_allocated, 3)
|
||||
|
||||
# if the daily job is not completed yet, there is another check present
|
||||
# to ensure leave is not already allocated to avoid duplication
|
||||
from erpnext.hr.utils import allocate_earned_leaves
|
||||
allocate_earned_leaves()
|
||||
|
||||
leaves_allocated = frappe.db.get_value("Leave Allocation", {
|
||||
"leave_policy_assignment": leave_policy_assignments[0]
|
||||
}, "total_leaves_allocated")
|
||||
self.assertEqual(leaves_allocated, 3)
|
||||
|
||||
def test_earned_leave_alloc_for_passed_months_with_cf_leaves_based_on_leave_period(self):
|
||||
from erpnext.hr.doctype.leave_allocation.test_leave_allocation import create_leave_allocation
|
||||
|
||||
leave_period, leave_policy = setup_leave_period_and_policy(get_first_day(add_months(getdate(), -2)))
|
||||
# initial leave allocation = 5
|
||||
leave_allocation = create_leave_allocation(employee=self.employee.name, employee_name=self.employee.employee_name, leave_type="Test Earned Leave",
|
||||
from_date=add_months(getdate(), -12), to_date=add_months(getdate(), -3), new_leaves_allocated=5, carry_forward=0)
|
||||
leave_allocation.submit()
|
||||
|
||||
# Case 3: assignment created on the last day of the leave period's latter month with carry forwarding
|
||||
frappe.flags.current_date = get_last_day(add_months(getdate(), -1))
|
||||
data = {
|
||||
"assignment_based_on": "Leave Period",
|
||||
"leave_policy": leave_policy.name,
|
||||
"leave_period": leave_period.name,
|
||||
"carry_forward": 1
|
||||
}
|
||||
# carry forwarded leaves = 5, 3 leaves allocated for passed months
|
||||
leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
|
||||
|
||||
details = frappe.db.get_value("Leave Allocation", {
|
||||
"leave_policy_assignment": leave_policy_assignments[0]
|
||||
}, ["total_leaves_allocated", "new_leaves_allocated", "unused_leaves", "name"], as_dict=True)
|
||||
self.assertEqual(details.new_leaves_allocated, 2)
|
||||
self.assertEqual(details.unused_leaves, 5)
|
||||
self.assertEqual(details.total_leaves_allocated, 7)
|
||||
|
||||
# if the daily job is not completed yet, there is another check present
|
||||
# to ensure leave is not already allocated to avoid duplication
|
||||
from erpnext.hr.utils import is_earned_leave_already_allocated
|
||||
frappe.flags.current_date = get_last_day(getdate())
|
||||
|
||||
allocation = frappe.get_doc("Leave Allocation", details.name)
|
||||
# 1 leave is still pending to be allocated, irrespective of carry forwarded leaves
|
||||
self.assertFalse(is_earned_leave_already_allocated(allocation, leave_policy.leave_policy_details[0].annual_allocation))
|
||||
|
||||
def test_earned_leave_alloc_for_passed_months_based_on_joining_date(self):
|
||||
# tests leave alloc for earned leaves for assignment based on joining date in policy assignment
|
||||
leave_type = create_earned_leave_type("Test Earned Leave")
|
||||
leave_policy = frappe.get_doc({
|
||||
"doctype": "Leave Policy",
|
||||
"title": "Test Leave Policy",
|
||||
"leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 12}]
|
||||
}).submit()
|
||||
|
||||
# joining date set to 2 months back
|
||||
self.employee.date_of_joining = get_first_day(add_months(getdate(), -2))
|
||||
self.employee.save()
|
||||
|
||||
# assignment created on the last day of the current month
|
||||
frappe.flags.current_date = get_last_day(getdate())
|
||||
data = {
|
||||
"assignment_based_on": "Joining Date",
|
||||
"leave_policy": leave_policy.name
|
||||
}
|
||||
leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
|
||||
leaves_allocated = frappe.db.get_value("Leave Allocation", {"leave_policy_assignment": leave_policy_assignments[0]},
|
||||
"total_leaves_allocated")
|
||||
effective_from = frappe.db.get_value("Leave Policy Assignment", leave_policy_assignments[0], "effective_from")
|
||||
self.assertEqual(effective_from, self.employee.date_of_joining)
|
||||
self.assertEqual(leaves_allocated, 3)
|
||||
|
||||
# to ensure leave is not already allocated to avoid duplication
|
||||
from erpnext.hr.utils import allocate_earned_leaves
|
||||
frappe.flags.current_date = get_last_day(getdate())
|
||||
allocate_earned_leaves()
|
||||
|
||||
leaves_allocated = frappe.db.get_value("Leave Allocation", {"leave_policy_assignment": leave_policy_assignments[0]},
|
||||
"total_leaves_allocated")
|
||||
self.assertEqual(leaves_allocated, 3)
|
||||
|
||||
def test_grant_leaves_on_doj_for_earned_leaves_based_on_leave_period(self):
|
||||
# tests leave alloc based on leave period for earned leaves with "based on doj" configuration in leave type
|
||||
leave_period, leave_policy = setup_leave_period_and_policy(get_first_day(add_months(getdate(), -2)), based_on_doj=True)
|
||||
|
||||
# joining date set to 2 months back
|
||||
self.employee.date_of_joining = get_first_day(add_months(getdate(), -2))
|
||||
self.employee.save()
|
||||
|
||||
# assignment created on the same day of the current month, should allocate leaves including the current month
|
||||
frappe.flags.current_date = get_first_day(getdate())
|
||||
|
||||
data = {
|
||||
"assignment_based_on": "Leave Period",
|
||||
"leave_policy": leave_policy.name,
|
||||
"leave_period": leave_period.name
|
||||
}
|
||||
leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
|
||||
|
||||
leaves_allocated = frappe.db.get_value("Leave Allocation", {
|
||||
"leave_policy_assignment": leave_policy_assignments[0]
|
||||
}, "total_leaves_allocated")
|
||||
self.assertEqual(leaves_allocated, 3)
|
||||
|
||||
# if the daily job is not completed yet, there is another check present
|
||||
# to ensure leave is not already allocated to avoid duplication
|
||||
from erpnext.hr.utils import allocate_earned_leaves
|
||||
frappe.flags.current_date = get_first_day(getdate())
|
||||
allocate_earned_leaves()
|
||||
|
||||
leaves_allocated = frappe.db.get_value("Leave Allocation", {
|
||||
"leave_policy_assignment": leave_policy_assignments[0]
|
||||
}, "total_leaves_allocated")
|
||||
self.assertEqual(leaves_allocated, 3)
|
||||
|
||||
def test_grant_leaves_on_doj_for_earned_leaves_based_on_joining_date(self):
|
||||
# tests leave alloc based on joining date for earned leaves with "based on doj" configuration in leave type
|
||||
leave_type = create_earned_leave_type("Test Earned Leave", based_on_doj=True)
|
||||
leave_policy = frappe.get_doc({
|
||||
"doctype": "Leave Policy",
|
||||
"title": "Test Leave Policy",
|
||||
"leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 12}]
|
||||
}).submit()
|
||||
|
||||
# joining date set to 2 months back
|
||||
# leave should be allocated for current month too since this day is same as the joining day
|
||||
self.employee.date_of_joining = get_first_day(add_months(getdate(), -2))
|
||||
self.employee.save()
|
||||
|
||||
# assignment created on the first day of the current month
|
||||
frappe.flags.current_date = get_first_day(getdate())
|
||||
data = {
|
||||
"assignment_based_on": "Joining Date",
|
||||
"leave_policy": leave_policy.name
|
||||
}
|
||||
leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
|
||||
leaves_allocated = frappe.db.get_value("Leave Allocation", {"leave_policy_assignment": leave_policy_assignments[0]},
|
||||
"total_leaves_allocated")
|
||||
effective_from = frappe.db.get_value("Leave Policy Assignment", leave_policy_assignments[0], "effective_from")
|
||||
self.assertEqual(effective_from, self.employee.date_of_joining)
|
||||
self.assertEqual(leaves_allocated, 3)
|
||||
|
||||
# to ensure leave is not already allocated to avoid duplication
|
||||
from erpnext.hr.utils import allocate_earned_leaves
|
||||
frappe.flags.current_date = get_first_day(getdate())
|
||||
allocate_earned_leaves()
|
||||
|
||||
leaves_allocated = frappe.db.get_value("Leave Allocation", {"leave_policy_assignment": leave_policy_assignments[0]},
|
||||
"total_leaves_allocated")
|
||||
self.assertEqual(leaves_allocated, 3)
|
||||
|
||||
def tearDown(self):
|
||||
frappe.db.rollback()
|
||||
frappe.db.set_value("Employee", self.employee.name, "date_of_joining", self.original_doj)
|
||||
frappe.flags.current_date = None
|
||||
|
||||
|
||||
def create_earned_leave_type(leave_type):
|
||||
def create_earned_leave_type(leave_type, based_on_doj=False):
|
||||
frappe.delete_doc_if_exists("Leave Type", leave_type, force=1)
|
||||
|
||||
return frappe.get_doc(dict(
|
||||
@@ -137,13 +304,15 @@ def create_earned_leave_type(leave_type):
|
||||
is_earned_leave=1,
|
||||
earned_leave_frequency="Monthly",
|
||||
rounding=0.5,
|
||||
max_leaves_allowed=6
|
||||
is_carry_forward=1,
|
||||
based_on_date_of_joining=based_on_doj
|
||||
)).insert()
|
||||
|
||||
|
||||
def create_leave_period(name):
|
||||
def create_leave_period(name, start_date=None):
|
||||
frappe.delete_doc_if_exists("Leave Period", name, force=1)
|
||||
start_date = get_first_day(getdate())
|
||||
if not start_date:
|
||||
start_date = get_first_day(getdate())
|
||||
|
||||
return frappe.get_doc(dict(
|
||||
name=name,
|
||||
@@ -152,4 +321,17 @@ def create_leave_period(name):
|
||||
to_date=add_months(start_date, 12),
|
||||
company="_Test Company",
|
||||
is_active=1
|
||||
)).insert()
|
||||
)).insert()
|
||||
|
||||
|
||||
def setup_leave_period_and_policy(start_date, based_on_doj=False):
|
||||
leave_type = create_earned_leave_type("Test Earned Leave", based_on_doj)
|
||||
leave_period = create_leave_period("Test Earned Leave Period",
|
||||
start_date=start_date)
|
||||
leave_policy = frappe.get_doc({
|
||||
"doctype": "Leave Policy",
|
||||
"title": "Test Leave Policy",
|
||||
"leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 12}]
|
||||
}).insert()
|
||||
|
||||
return leave_period, leave_policy
|
||||
@@ -353,7 +353,7 @@ def generate_leave_encashment():
|
||||
|
||||
create_leave_encashment(leave_allocation=leave_allocation)
|
||||
|
||||
def allocate_earned_leaves():
|
||||
def allocate_earned_leaves(ignore_duplicates=False):
|
||||
'''Allocate earned leaves to Employees'''
|
||||
e_leave_types = get_earned_leaves()
|
||||
today = getdate()
|
||||
@@ -377,13 +377,13 @@ def allocate_earned_leaves():
|
||||
|
||||
from_date=allocation.from_date
|
||||
|
||||
if e_leave_type.based_on_date_of_joining_date:
|
||||
if e_leave_type.based_on_date_of_joining:
|
||||
from_date = frappe.db.get_value("Employee", allocation.employee, "date_of_joining")
|
||||
|
||||
if check_effective_date(from_date, today, e_leave_type.earned_leave_frequency, e_leave_type.based_on_date_of_joining_date):
|
||||
update_previous_leave_allocation(allocation, annual_allocation, e_leave_type)
|
||||
if check_effective_date(from_date, today, e_leave_type.earned_leave_frequency, e_leave_type.based_on_date_of_joining):
|
||||
update_previous_leave_allocation(allocation, annual_allocation, e_leave_type, ignore_duplicates)
|
||||
|
||||
def update_previous_leave_allocation(allocation, annual_allocation, e_leave_type):
|
||||
def update_previous_leave_allocation(allocation, annual_allocation, e_leave_type, ignore_duplicates=False):
|
||||
earned_leaves = get_monthly_earned_leave(annual_allocation, e_leave_type.earned_leave_frequency, e_leave_type.rounding)
|
||||
|
||||
allocation = frappe.get_doc('Leave Allocation', allocation.name)
|
||||
@@ -393,9 +393,12 @@ def update_previous_leave_allocation(allocation, annual_allocation, e_leave_type
|
||||
new_allocation = e_leave_type.max_leaves_allowed
|
||||
|
||||
if new_allocation != allocation.total_leaves_allocated:
|
||||
allocation.db_set("total_leaves_allocated", new_allocation, update_modified=False)
|
||||
today_date = today()
|
||||
create_additional_leave_ledger_entry(allocation, earned_leaves, today_date)
|
||||
|
||||
if ignore_duplicates or not is_earned_leave_already_allocated(allocation, annual_allocation):
|
||||
allocation.db_set("total_leaves_allocated", new_allocation, update_modified=False)
|
||||
create_additional_leave_ledger_entry(allocation, earned_leaves, today_date)
|
||||
|
||||
|
||||
def get_monthly_earned_leave(annual_leaves, frequency, rounding):
|
||||
earned_leaves = 0.0
|
||||
@@ -413,6 +416,28 @@ def get_monthly_earned_leave(annual_leaves, frequency, rounding):
|
||||
return earned_leaves
|
||||
|
||||
|
||||
def is_earned_leave_already_allocated(allocation, annual_allocation):
|
||||
from erpnext.hr.doctype.leave_policy_assignment.leave_policy_assignment import (
|
||||
get_leave_type_details,
|
||||
)
|
||||
|
||||
leave_type_details = get_leave_type_details()
|
||||
date_of_joining = frappe.db.get_value("Employee", allocation.employee, "date_of_joining")
|
||||
|
||||
assignment = frappe.get_doc("Leave Policy Assignment", allocation.leave_policy_assignment)
|
||||
leaves_for_passed_months = assignment.get_leaves_for_passed_months(allocation.leave_type,
|
||||
annual_allocation, leave_type_details, date_of_joining)
|
||||
|
||||
# exclude carry-forwarded leaves while checking for leave allocation for passed months
|
||||
num_allocations = allocation.total_leaves_allocated
|
||||
if allocation.unused_leaves:
|
||||
num_allocations -= allocation.unused_leaves
|
||||
|
||||
if num_allocations >= leaves_for_passed_months:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_leave_allocations(date, leave_type):
|
||||
return frappe.db.sql("""select name, employee, from_date, to_date, leave_policy_assignment, leave_policy
|
||||
from `tabLeave Allocation`
|
||||
@@ -434,7 +459,7 @@ def create_additional_leave_ledger_entry(allocation, leaves, date):
|
||||
allocation.unused_leaves = 0
|
||||
allocation.create_leave_ledger_entry()
|
||||
|
||||
def check_effective_date(from_date, to_date, frequency, based_on_date_of_joining_date):
|
||||
def check_effective_date(from_date, to_date, frequency, based_on_date_of_joining):
|
||||
import calendar
|
||||
|
||||
from dateutil import relativedelta
|
||||
@@ -445,7 +470,7 @@ def check_effective_date(from_date, to_date, frequency, based_on_date_of_joining
|
||||
#last day of month
|
||||
last_day = calendar.monthrange(to_date.year, to_date.month)[1]
|
||||
|
||||
if (from_date.day == to_date.day and based_on_date_of_joining_date) or (not based_on_date_of_joining_date and to_date.day == last_day):
|
||||
if (from_date.day == to_date.day and based_on_date_of_joining) or (not based_on_date_of_joining and to_date.day == last_day):
|
||||
if frequency == "Monthly":
|
||||
return True
|
||||
elif frequency == "Quarterly" and rd.months % 3:
|
||||
|
||||
@@ -46,7 +46,7 @@ frappe.ui.form.on('Loan', {
|
||||
});
|
||||
});
|
||||
|
||||
$.each(["payment_account", "loan_account"], function (i, field) {
|
||||
$.each(["payment_account", "loan_account", "disbursement_account"], function (i, field) {
|
||||
frm.set_query(field, function () {
|
||||
return {
|
||||
"filters": {
|
||||
@@ -88,6 +88,10 @@ frappe.ui.form.on('Loan', {
|
||||
frm.add_custom_button(__('Loan Write Off'), function() {
|
||||
frm.trigger("make_loan_write_off_entry");
|
||||
},__('Create'));
|
||||
|
||||
frm.add_custom_button(__('Loan Refund'), function() {
|
||||
frm.trigger("make_loan_refund");
|
||||
},__('Create'));
|
||||
}
|
||||
}
|
||||
frm.trigger("toggle_fields");
|
||||
@@ -155,6 +159,21 @@ frappe.ui.form.on('Loan', {
|
||||
})
|
||||
},
|
||||
|
||||
make_loan_refund: function(frm) {
|
||||
frappe.call({
|
||||
args: {
|
||||
"loan": frm.doc.name
|
||||
},
|
||||
method: "erpnext.loan_management.doctype.loan.loan.make_refund_jv",
|
||||
callback: function (r) {
|
||||
if (r.message) {
|
||||
let doc = frappe.model.sync(r.message)[0];
|
||||
frappe.set_route("Form", doc.doctype, doc.name);
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
request_loan_closure: function(frm) {
|
||||
frappe.confirm(__("Do you really want to close this loan"),
|
||||
function() {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"actions": [],
|
||||
"allow_import": 1,
|
||||
"autoname": "ACC-LOAN-.YYYY.-.#####",
|
||||
"creation": "2019-08-29 17:29:18.176786",
|
||||
"creation": "2022-01-25 10:30:02.294967",
|
||||
"doctype": "DocType",
|
||||
"document_type": "Document",
|
||||
"editable_grid": 1,
|
||||
@@ -34,6 +34,7 @@
|
||||
"is_term_loan",
|
||||
"account_info",
|
||||
"mode_of_payment",
|
||||
"disbursement_account",
|
||||
"payment_account",
|
||||
"column_break_9",
|
||||
"loan_account",
|
||||
@@ -356,12 +357,21 @@
|
||||
"fieldtype": "Date",
|
||||
"label": "Closure Date",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fetch_from": "loan_type.disbursement_account",
|
||||
"fieldname": "disbursement_account",
|
||||
"fieldtype": "Link",
|
||||
"label": "Disbursement Account",
|
||||
"options": "Account",
|
||||
"read_only": 1,
|
||||
"reqd": 1
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2021-10-20 08:28:16.796105",
|
||||
"modified": "2022-01-25 16:29:16.325501",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Loan Management",
|
||||
"name": "Loan",
|
||||
@@ -391,5 +401,6 @@
|
||||
"search_fields": "posting_date",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ from frappe.utils import add_months, flt, get_last_day, getdate, now_datetime, n
|
||||
from six import string_types
|
||||
|
||||
import erpnext
|
||||
from erpnext.accounts.doctype.journal_entry.journal_entry import get_payment_entry
|
||||
from erpnext.controllers.accounts_controller import AccountsController
|
||||
from erpnext.loan_management.doctype.loan_repayment.loan_repayment import calculate_amounts
|
||||
from erpnext.loan_management.doctype.loan_security_unpledge.loan_security_unpledge import (
|
||||
@@ -234,17 +235,15 @@ def request_loan_closure(loan, posting_date=None):
|
||||
loan_type = frappe.get_value('Loan', loan, 'loan_type')
|
||||
write_off_limit = frappe.get_value('Loan Type', loan_type, 'write_off_amount')
|
||||
|
||||
# checking greater than 0 as there may be some minor precision error
|
||||
if not pending_amount:
|
||||
frappe.db.set_value('Loan', loan, 'status', 'Loan Closure Requested')
|
||||
elif pending_amount < write_off_limit:
|
||||
if pending_amount and abs(pending_amount) < write_off_limit:
|
||||
# Auto create loan write off and update status as loan closure requested
|
||||
write_off = make_loan_write_off(loan)
|
||||
write_off.submit()
|
||||
frappe.db.set_value('Loan', loan, 'status', 'Loan Closure Requested')
|
||||
else:
|
||||
elif pending_amount > 0:
|
||||
frappe.throw(_("Cannot close loan as there is an outstanding of {0}").format(pending_amount))
|
||||
|
||||
frappe.db.set_value('Loan', loan, 'status', 'Loan Closure Requested')
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_loan_application(loan_application):
|
||||
loan = frappe.get_doc("Loan Application", loan_application)
|
||||
@@ -401,4 +400,39 @@ def add_single_month(date):
|
||||
if getdate(date) == get_last_day(date):
|
||||
return get_last_day(add_months(date, 1))
|
||||
else:
|
||||
return add_months(date, 1)
|
||||
return add_months(date, 1)
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_refund_jv(loan, amount=0, reference_number=None, reference_date=None, submit=0):
|
||||
loan_details = frappe.db.get_value('Loan', loan, ['applicant_type', 'applicant',
|
||||
'loan_account', 'payment_account', 'posting_date', 'company', 'name',
|
||||
'total_payment', 'total_principal_paid'], as_dict=1)
|
||||
|
||||
loan_details.doctype = 'Loan'
|
||||
loan_details[loan_details.applicant_type.lower()] = loan_details.applicant
|
||||
|
||||
if not amount:
|
||||
amount = flt(loan_details.total_principal_paid - loan_details.total_payment)
|
||||
|
||||
if amount < 0:
|
||||
frappe.throw(_('No excess amount pending for refund'))
|
||||
|
||||
refund_jv = get_payment_entry(loan_details, {
|
||||
"party_type": loan_details.applicant_type,
|
||||
"party_account": loan_details.loan_account,
|
||||
"amount_field_party": 'debit_in_account_currency',
|
||||
"amount_field_bank": 'credit_in_account_currency',
|
||||
"amount": amount,
|
||||
"bank_account": loan_details.payment_account
|
||||
})
|
||||
|
||||
if reference_number:
|
||||
refund_jv.cheque_no = reference_number
|
||||
|
||||
if reference_date:
|
||||
refund_jv.cheque_date = reference_date
|
||||
|
||||
if submit:
|
||||
refund_jv.submit()
|
||||
|
||||
return refund_jv
|
||||
@@ -42,16 +42,17 @@ class TestLoan(unittest.TestCase):
|
||||
create_loan_type("Personal Loan", 500000, 8.4,
|
||||
is_term_loan=1,
|
||||
mode_of_payment='Cash',
|
||||
disbursement_account='Disbursement Account - _TC',
|
||||
payment_account='Payment Account - _TC',
|
||||
loan_account='Loan Account - _TC',
|
||||
interest_income_account='Interest Income Account - _TC',
|
||||
penalty_income_account='Penalty Income Account - _TC')
|
||||
|
||||
create_loan_type("Stock Loan", 2000000, 13.5, 25, 1, 5, 'Cash', 'Payment Account - _TC', 'Loan Account - _TC',
|
||||
'Interest Income Account - _TC', 'Penalty Income Account - _TC')
|
||||
create_loan_type("Stock Loan", 2000000, 13.5, 25, 1, 5, 'Cash', 'Disbursement Account - _TC',
|
||||
'Payment Account - _TC', 'Loan Account - _TC', 'Interest Income Account - _TC', 'Penalty Income Account - _TC')
|
||||
|
||||
create_loan_type("Demand Loan", 2000000, 13.5, 25, 0, 5, 'Cash', 'Payment Account - _TC', 'Loan Account - _TC',
|
||||
'Interest Income Account - _TC', 'Penalty Income Account - _TC')
|
||||
create_loan_type("Demand Loan", 2000000, 13.5, 25, 0, 5, 'Cash', 'Disbursement Account - _TC',
|
||||
'Payment Account - _TC', 'Loan Account - _TC', 'Interest Income Account - _TC', 'Penalty Income Account - _TC')
|
||||
|
||||
create_loan_security_type()
|
||||
create_loan_security()
|
||||
@@ -679,6 +680,29 @@ class TestLoan(unittest.TestCase):
|
||||
loan.load_from_db()
|
||||
self.assertEqual(loan.status, "Loan Closure Requested")
|
||||
|
||||
def test_loan_repayment_against_partially_disbursed_loan(self):
|
||||
pledge = [{
|
||||
"loan_security": "Test Security 1",
|
||||
"qty": 4000.00
|
||||
}]
|
||||
|
||||
loan_application = create_loan_application('_Test Company', self.applicant2, 'Demand Loan', pledge)
|
||||
create_pledge(loan_application)
|
||||
|
||||
loan = create_demand_loan(self.applicant2, "Demand Loan", loan_application, posting_date='2019-10-01')
|
||||
loan.submit()
|
||||
|
||||
first_date = '2019-10-01'
|
||||
last_date = '2019-10-30'
|
||||
|
||||
make_loan_disbursement_entry(loan.name, loan.loan_amount/2, disbursement_date=first_date)
|
||||
|
||||
loan.load_from_db()
|
||||
|
||||
self.assertEqual(loan.status, "Partially Disbursed")
|
||||
create_repayment_entry(loan.name, self.applicant2, add_days(last_date, 5),
|
||||
flt(loan.loan_amount/3))
|
||||
|
||||
def test_loan_amount_write_off(self):
|
||||
pledge = [{
|
||||
"loan_security": "Test Security 1",
|
||||
@@ -790,6 +814,18 @@ def create_loan_accounts():
|
||||
"account_type": "Bank",
|
||||
}).insert(ignore_permissions=True)
|
||||
|
||||
if not frappe.db.exists("Account", "Disbursement Account - _TC"):
|
||||
frappe.get_doc({
|
||||
"doctype": "Account",
|
||||
"company": "_Test Company",
|
||||
"account_name": "Disbursement Account",
|
||||
"root_type": "Asset",
|
||||
"report_type": "Balance Sheet",
|
||||
"currency": "INR",
|
||||
"parent_account": "Bank Accounts - _TC",
|
||||
"account_type": "Bank",
|
||||
}).insert(ignore_permissions=True)
|
||||
|
||||
if not frappe.db.exists("Account", "Interest Income Account - _TC"):
|
||||
frappe.get_doc({
|
||||
"doctype": "Account",
|
||||
@@ -815,7 +851,7 @@ def create_loan_accounts():
|
||||
}).insert(ignore_permissions=True)
|
||||
|
||||
def create_loan_type(loan_name, maximum_loan_amount, rate_of_interest, penalty_interest_rate=None, is_term_loan=None, grace_period_in_days=None,
|
||||
mode_of_payment=None, payment_account=None, loan_account=None, interest_income_account=None, penalty_income_account=None,
|
||||
mode_of_payment=None, disbursement_account=None, payment_account=None, loan_account=None, interest_income_account=None, penalty_income_account=None,
|
||||
repayment_method=None, repayment_periods=None):
|
||||
|
||||
if not frappe.db.exists("Loan Type", loan_name):
|
||||
@@ -829,6 +865,7 @@ def create_loan_type(loan_name, maximum_loan_amount, rate_of_interest, penalty_i
|
||||
"penalty_interest_rate": penalty_interest_rate,
|
||||
"grace_period_in_days": grace_period_in_days,
|
||||
"mode_of_payment": mode_of_payment,
|
||||
"disbursement_account": disbursement_account,
|
||||
"payment_account": payment_account,
|
||||
"loan_account": loan_account,
|
||||
"interest_income_account": interest_income_account,
|
||||
|
||||
@@ -15,7 +15,7 @@ from erpnext.payroll.doctype.salary_structure.test_salary_structure import (
|
||||
class TestLoanApplication(unittest.TestCase):
|
||||
def setUp(self):
|
||||
create_loan_accounts()
|
||||
create_loan_type("Home Loan", 500000, 9.2, 0, 1, 0, 'Cash', 'Payment Account - _TC', 'Loan Account - _TC',
|
||||
create_loan_type("Home Loan", 500000, 9.2, 0, 1, 0, 'Cash', 'Disbursement Account - _TC', 'Payment Account - _TC', 'Loan Account - _TC',
|
||||
'Interest Income Account - _TC', 'Penalty Income Account - _TC', 'Repay Over Number of Periods', 18)
|
||||
self.applicant = make_employee("kate_loan@loan.com", "_Test Company")
|
||||
make_salary_structure("Test Salary Structure Loan", "Monthly", employee=self.applicant, currency='INR')
|
||||
|
||||
@@ -122,7 +122,7 @@ class LoanDisbursement(AccountsController):
|
||||
gle_map.append(
|
||||
self.get_gl_dict({
|
||||
"account": loan_details.loan_account,
|
||||
"against": loan_details.payment_account,
|
||||
"against": loan_details.disbursement_account,
|
||||
"debit": self.disbursed_amount,
|
||||
"debit_in_account_currency": self.disbursed_amount,
|
||||
"against_voucher_type": "Loan",
|
||||
@@ -137,7 +137,7 @@ class LoanDisbursement(AccountsController):
|
||||
|
||||
gle_map.append(
|
||||
self.get_gl_dict({
|
||||
"account": loan_details.payment_account,
|
||||
"account": loan_details.disbursement_account,
|
||||
"against": loan_details.loan_account,
|
||||
"credit": self.disbursed_amount,
|
||||
"credit_in_account_currency": self.disbursed_amount,
|
||||
|
||||
@@ -44,8 +44,8 @@ class TestLoanDisbursement(unittest.TestCase):
|
||||
def setUp(self):
|
||||
create_loan_accounts()
|
||||
|
||||
create_loan_type("Demand Loan", 2000000, 13.5, 25, 0, 5, 'Cash', 'Payment Account - _TC', 'Loan Account - _TC',
|
||||
'Interest Income Account - _TC', 'Penalty Income Account - _TC')
|
||||
create_loan_type("Demand Loan", 2000000, 13.5, 25, 0, 5, 'Cash', 'Disbursement Account - _TC',
|
||||
'Payment Account - _TC', 'Loan Account - _TC', 'Interest Income Account - _TC', 'Penalty Income Account - _TC')
|
||||
|
||||
create_loan_security_type()
|
||||
create_loan_security()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -30,8 +30,8 @@ class TestLoanInterestAccrual(unittest.TestCase):
|
||||
def setUp(self):
|
||||
create_loan_accounts()
|
||||
|
||||
create_loan_type("Demand Loan", 2000000, 13.5, 25, 0, 5, 'Cash', 'Payment Account - _TC', 'Loan Account - _TC',
|
||||
'Interest Income Account - _TC', 'Penalty Income Account - _TC')
|
||||
create_loan_type("Demand Loan", 2000000, 13.5, 25, 0, 5, 'Cash', 'Disbursement Account - _TC',
|
||||
'Payment Account - _TC', 'Loan Account - _TC', 'Interest Income Account - _TC', 'Penalty Income Account - _TC')
|
||||
|
||||
create_loan_security_type()
|
||||
create_loan_security()
|
||||
|
||||
@@ -126,7 +126,7 @@ class LoanRepayment(AccountsController):
|
||||
|
||||
def update_paid_amount(self):
|
||||
loan = frappe.get_value("Loan", self.against_loan, ['total_amount_paid', 'total_principal_paid',
|
||||
'status', 'is_secured_loan', 'total_payment', 'loan_amount', 'total_interest_payable',
|
||||
'status', 'is_secured_loan', 'total_payment', 'loan_amount', 'disbursed_amount', 'total_interest_payable',
|
||||
'written_off_amount'], as_dict=1)
|
||||
|
||||
loan.update({
|
||||
@@ -154,7 +154,7 @@ class LoanRepayment(AccountsController):
|
||||
|
||||
def mark_as_unpaid(self):
|
||||
loan = frappe.get_value("Loan", self.against_loan, ['total_amount_paid', 'total_principal_paid',
|
||||
'status', 'is_secured_loan', 'total_payment', 'loan_amount', 'total_interest_payable',
|
||||
'status', 'is_secured_loan', 'total_payment', 'loan_amount', 'disbursed_amount', 'total_interest_payable',
|
||||
'written_off_amount'], as_dict=1)
|
||||
|
||||
no_of_repayments = len(self.repayment_details)
|
||||
@@ -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 ''
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ frappe.ui.form.on('Loan Type', {
|
||||
});
|
||||
});
|
||||
|
||||
$.each(["payment_account", "loan_account"], function (i, field) {
|
||||
$.each(["payment_account", "loan_account", "disbursement_account"], function (i, field) {
|
||||
frm.set_query(field, function () {
|
||||
return {
|
||||
"filters": {
|
||||
|
||||
@@ -19,9 +19,10 @@
|
||||
"description",
|
||||
"account_details_section",
|
||||
"mode_of_payment",
|
||||
"disbursement_account",
|
||||
"payment_account",
|
||||
"loan_account",
|
||||
"column_break_12",
|
||||
"loan_account",
|
||||
"interest_income_account",
|
||||
"penalty_income_account",
|
||||
"amended_from"
|
||||
@@ -79,7 +80,7 @@
|
||||
{
|
||||
"fieldname": "payment_account",
|
||||
"fieldtype": "Link",
|
||||
"label": "Payment Account",
|
||||
"label": "Repayment Account",
|
||||
"options": "Account",
|
||||
"reqd": 1
|
||||
},
|
||||
@@ -149,15 +150,23 @@
|
||||
"fieldtype": "Currency",
|
||||
"label": "Auto Write Off Amount ",
|
||||
"options": "Company:company:default_currency"
|
||||
},
|
||||
{
|
||||
"fieldname": "disbursement_account",
|
||||
"fieldtype": "Link",
|
||||
"label": "Disbursement Account",
|
||||
"options": "Account",
|
||||
"reqd": 1
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2021-04-19 18:10:57.368490",
|
||||
"modified": "2022-01-25 16:23:57.009349",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Loan Management",
|
||||
"name": "Loan Type",
|
||||
"naming_rule": "By fieldname",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
@@ -181,5 +190,6 @@
|
||||
}
|
||||
],
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC"
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
@@ -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()
|
||||
@@ -309,7 +320,7 @@ class ProductionPlan(Document):
|
||||
|
||||
if self.total_produced_qty > 0:
|
||||
self.status = "In Process"
|
||||
if self.check_have_work_orders_completed():
|
||||
if self.all_items_completed():
|
||||
self.status = "Completed"
|
||||
|
||||
if self.status != 'Completed':
|
||||
@@ -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:
|
||||
@@ -575,21 +592,32 @@ class ProductionPlan(Document):
|
||||
|
||||
self.append("sub_assembly_items", data)
|
||||
|
||||
def check_have_work_orders_completed(self):
|
||||
wo_status = frappe.db.get_list(
|
||||
def all_items_completed(self):
|
||||
all_items_produced = all(flt(d.planned_qty) - flt(d.produced_qty) < 0.000001
|
||||
for d in self.po_items)
|
||||
if not all_items_produced:
|
||||
return False
|
||||
|
||||
wo_status = frappe.get_all(
|
||||
"Work Order",
|
||||
filters={"production_plan": self.name},
|
||||
filters={
|
||||
"production_plan": self.name,
|
||||
"status": ("not in", ["Closed", "Stopped"]),
|
||||
"docstatus": ("<", 2),
|
||||
},
|
||||
fields="status",
|
||||
pluck="status"
|
||||
pluck="status",
|
||||
)
|
||||
return all(s == "Completed" for s in wo_status)
|
||||
all_work_orders_completed = all(s == "Completed" for s in wo_status)
|
||||
return all_work_orders_completed
|
||||
|
||||
@frappe.whitelist()
|
||||
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 +626,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 +1053,8 @@ def get_sub_assembly_items(bom_no, bom_data, to_produce_qty, indent=0):
|
||||
|
||||
if d.value:
|
||||
get_sub_assembly_items(d.value, bom_data, stock_qty, indent=indent+1)
|
||||
|
||||
def set_default_warehouses(row, default_warehouses):
|
||||
for field in ['wip_warehouse', 'fg_warehouse']:
|
||||
if not row.get(field):
|
||||
row[field] = default_warehouses.get(field)
|
||||
|
||||
@@ -11,6 +11,7 @@ from erpnext.manufacturing.doctype.production_plan.production_plan import (
|
||||
)
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
from erpnext.stock.doctype.item.test_item import create_item
|
||||
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
|
||||
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
|
||||
create_stock_reconciliation,
|
||||
)
|
||||
@@ -36,15 +37,21 @@ class TestProductionPlan(ERPNextTestCase):
|
||||
if not frappe.db.get_value('BOM', {'item': item}):
|
||||
make_bom(item = item, raw_materials = raw_materials)
|
||||
|
||||
def test_production_plan(self):
|
||||
def test_production_plan_mr_creation(self):
|
||||
"Test if MRs are created for unavailable raw materials."
|
||||
pln = create_production_plan(item_code='Test Production Item 1')
|
||||
self.assertTrue(len(pln.mr_items), 2)
|
||||
pln.make_material_request()
|
||||
|
||||
pln = frappe.get_doc('Production Plan', pln.name)
|
||||
pln.make_material_request()
|
||||
pln.reload()
|
||||
self.assertTrue(pln.status, 'Material Requested')
|
||||
material_requests = frappe.get_all('Material Request Item', fields = ['distinct parent'],
|
||||
filters = {'production_plan': pln.name}, as_list=1)
|
||||
|
||||
material_requests = frappe.get_all(
|
||||
'Material Request Item',
|
||||
fields = ['distinct parent'],
|
||||
filters = {'production_plan': pln.name},
|
||||
as_list=1
|
||||
)
|
||||
|
||||
self.assertTrue(len(material_requests), 2)
|
||||
|
||||
@@ -66,27 +73,42 @@ class TestProductionPlan(ERPNextTestCase):
|
||||
pln.cancel()
|
||||
|
||||
def test_production_plan_start_date(self):
|
||||
"Test if Work Order has same Planned Start Date as Prod Plan."
|
||||
planned_date = add_to_date(date=None, days=3)
|
||||
plan = create_production_plan(item_code='Test Production Item 1', planned_start_date=planned_date)
|
||||
plan = create_production_plan(
|
||||
item_code='Test Production Item 1',
|
||||
planned_start_date=planned_date
|
||||
)
|
||||
plan.make_work_order()
|
||||
|
||||
work_orders = frappe.get_all('Work Order', fields = ['name', 'planned_start_date'],
|
||||
filters = {'production_plan': plan.name})
|
||||
work_orders = frappe.get_all(
|
||||
'Work Order',
|
||||
fields = ['name', 'planned_start_date'],
|
||||
filters = {'production_plan': plan.name}
|
||||
)
|
||||
|
||||
self.assertEqual(work_orders[0].planned_start_date, planned_date)
|
||||
|
||||
for wo in work_orders:
|
||||
frappe.delete_doc('Work Order', wo.name)
|
||||
|
||||
frappe.get_doc('Production Plan', plan.name).cancel()
|
||||
plan.reload()
|
||||
plan.cancel()
|
||||
|
||||
def test_production_plan_for_existing_ordered_qty(self):
|
||||
"""
|
||||
- Enable 'ignore_existing_ordered_qty'.
|
||||
- Test if MR Planning table pulls Raw Material Qty even if it is in stock.
|
||||
"""
|
||||
sr1 = create_stock_reconciliation(item_code="Raw Material Item 1",
|
||||
target="_Test Warehouse - _TC", qty=1, rate=110)
|
||||
sr2 = create_stock_reconciliation(item_code="Raw Material Item 2",
|
||||
target="_Test Warehouse - _TC", qty=1, rate=120)
|
||||
|
||||
pln = create_production_plan(item_code='Test Production Item 1', ignore_existing_ordered_qty=0)
|
||||
pln = create_production_plan(
|
||||
item_code='Test Production Item 1',
|
||||
ignore_existing_ordered_qty=1
|
||||
)
|
||||
self.assertTrue(len(pln.mr_items), 1)
|
||||
self.assertTrue(flt(pln.mr_items[0].quantity), 1.0)
|
||||
|
||||
@@ -95,23 +117,39 @@ class TestProductionPlan(ERPNextTestCase):
|
||||
pln.cancel()
|
||||
|
||||
def test_production_plan_with_non_stock_item(self):
|
||||
pln = create_production_plan(item_code='Test Production Item 1', include_non_stock_items=0)
|
||||
"Test if MR Planning table includes Non Stock RM."
|
||||
pln = create_production_plan(
|
||||
item_code='Test Production Item 1',
|
||||
include_non_stock_items=1
|
||||
)
|
||||
self.assertTrue(len(pln.mr_items), 3)
|
||||
pln.cancel()
|
||||
|
||||
def test_production_plan_without_multi_level(self):
|
||||
pln = create_production_plan(item_code='Test Production Item 1', use_multi_level_bom=0)
|
||||
"Test MR Planning table for non exploded BOM."
|
||||
pln = create_production_plan(
|
||||
item_code='Test Production Item 1',
|
||||
use_multi_level_bom=0
|
||||
)
|
||||
self.assertTrue(len(pln.mr_items), 2)
|
||||
pln.cancel()
|
||||
|
||||
def test_production_plan_without_multi_level_for_existing_ordered_qty(self):
|
||||
"""
|
||||
- Disable 'ignore_existing_ordered_qty'.
|
||||
- Test if MR Planning table avoids pulling Raw Material Qty as it is in stock for
|
||||
non exploded BOM.
|
||||
"""
|
||||
sr1 = create_stock_reconciliation(item_code="Raw Material Item 1",
|
||||
target="_Test Warehouse - _TC", qty=1, rate=130)
|
||||
sr2 = create_stock_reconciliation(item_code="Subassembly Item 1",
|
||||
target="_Test Warehouse - _TC", qty=1, rate=140)
|
||||
|
||||
pln = create_production_plan(item_code='Test Production Item 1',
|
||||
use_multi_level_bom=0, ignore_existing_ordered_qty=0)
|
||||
pln = create_production_plan(
|
||||
item_code='Test Production Item 1',
|
||||
use_multi_level_bom=0,
|
||||
ignore_existing_ordered_qty=0
|
||||
)
|
||||
self.assertTrue(len(pln.mr_items), 0)
|
||||
|
||||
sr1.cancel()
|
||||
@@ -119,6 +157,7 @@ class TestProductionPlan(ERPNextTestCase):
|
||||
pln.cancel()
|
||||
|
||||
def test_production_plan_sales_orders(self):
|
||||
"Test if previously fulfilled SO (with WO) is pulled into Prod Plan."
|
||||
item = 'Test Production Item 1'
|
||||
so = make_sales_order(item_code=item, qty=1)
|
||||
sales_order = so.name
|
||||
@@ -166,24 +205,25 @@ class TestProductionPlan(ERPNextTestCase):
|
||||
self.assertEqual(sales_orders, [])
|
||||
|
||||
def test_production_plan_combine_items(self):
|
||||
"Test combining FG items in Production Plan."
|
||||
item = 'Test Production Item 1'
|
||||
so = make_sales_order(item_code=item, qty=1)
|
||||
so1 = make_sales_order(item_code=item, qty=1)
|
||||
|
||||
pln = frappe.new_doc('Production Plan')
|
||||
pln.company = so.company
|
||||
pln.company = so1.company
|
||||
pln.get_items_from = 'Sales Order'
|
||||
pln.append('sales_orders', {
|
||||
'sales_order': so.name,
|
||||
'sales_order_date': so.transaction_date,
|
||||
'customer': so.customer,
|
||||
'grand_total': so.grand_total
|
||||
'sales_order': so1.name,
|
||||
'sales_order_date': so1.transaction_date,
|
||||
'customer': so1.customer,
|
||||
'grand_total': so1.grand_total
|
||||
})
|
||||
so = make_sales_order(item_code=item, qty=2)
|
||||
so2 = make_sales_order(item_code=item, qty=2)
|
||||
pln.append('sales_orders', {
|
||||
'sales_order': so.name,
|
||||
'sales_order_date': so.transaction_date,
|
||||
'customer': so.customer,
|
||||
'grand_total': so.grand_total
|
||||
'sales_order': so2.name,
|
||||
'sales_order_date': so2.transaction_date,
|
||||
'customer': so2.customer,
|
||||
'grand_total': so2.grand_total
|
||||
})
|
||||
pln.combine_items = 1
|
||||
pln.get_items()
|
||||
@@ -214,28 +254,37 @@ class TestProductionPlan(ERPNextTestCase):
|
||||
so_wo_qty = frappe.db.get_value('Sales Order Item', so_item, 'work_order_qty')
|
||||
self.assertEqual(so_wo_qty, 0.0)
|
||||
|
||||
latest_plan = frappe.get_doc('Production Plan', pln.name)
|
||||
latest_plan.cancel()
|
||||
pln.reload()
|
||||
pln.cancel()
|
||||
|
||||
def test_pp_to_mr_customer_provided(self):
|
||||
#Material Request from Production Plan for Customer Provided
|
||||
" Test Material Request from Production Plan for Customer Provided Item."
|
||||
create_item('CUST-0987', is_customer_provided_item = 1, customer = '_Test Customer', is_purchase_item = 0)
|
||||
create_item('Production Item CUST')
|
||||
|
||||
for item, raw_materials in {'Production Item CUST': ['Raw Material Item 1', 'CUST-0987']}.items():
|
||||
if not frappe.db.get_value('BOM', {'item': item}):
|
||||
make_bom(item = item, raw_materials = raw_materials)
|
||||
production_plan = create_production_plan(item_code = 'Production Item CUST')
|
||||
production_plan.make_material_request()
|
||||
material_request = frappe.db.get_value('Material Request Item', {'production_plan': production_plan.name, 'item_code': 'CUST-0987'}, 'parent')
|
||||
|
||||
material_request = frappe.db.get_value(
|
||||
'Material Request Item',
|
||||
{'production_plan': production_plan.name, 'item_code': 'CUST-0987'},
|
||||
'parent'
|
||||
)
|
||||
mr = frappe.get_doc('Material Request', material_request)
|
||||
|
||||
self.assertTrue(mr.material_request_type, 'Customer Provided')
|
||||
self.assertTrue(mr.customer, '_Test Customer')
|
||||
|
||||
def test_production_plan_with_multi_level_bom(self):
|
||||
#|Item Code | Qty |
|
||||
#|Test BOM 1 | 1 |
|
||||
#| Test BOM 2 | 2 |
|
||||
#| Test BOM 3 | 3 |
|
||||
"""
|
||||
Item Code | Qty |
|
||||
|Test BOM 1 | 1 |
|
||||
|Test BOM 2 | 2 |
|
||||
|Test BOM 3 | 3 |
|
||||
"""
|
||||
|
||||
for item_code in ["Test BOM 1", "Test BOM 2", "Test BOM 3", "Test RM BOM 1"]:
|
||||
create_item(item_code, is_stock_item=1)
|
||||
@@ -264,15 +313,18 @@ class TestProductionPlan(ERPNextTestCase):
|
||||
pln.make_work_order()
|
||||
|
||||
#last level sub-assembly work order produce qty
|
||||
to_produce_qty = frappe.db.get_value("Work Order",
|
||||
{"production_plan": pln.name, "production_item": "Test BOM 3"}, "qty")
|
||||
to_produce_qty = frappe.db.get_value(
|
||||
"Work Order",
|
||||
{"production_plan": pln.name, "production_item": "Test BOM 3"},
|
||||
"qty"
|
||||
)
|
||||
|
||||
self.assertEqual(to_produce_qty, 18.0)
|
||||
pln.cancel()
|
||||
frappe.delete_doc("Production Plan", pln.name)
|
||||
|
||||
def test_get_warehouse_list_group(self):
|
||||
"""Check if required warehouses are returned"""
|
||||
"Check if required child warehouses are returned."
|
||||
warehouse_json = '[{\"warehouse\":\"_Test Warehouse Group - _TC\"}]'
|
||||
|
||||
warehouses = set(get_warehouse_list(warehouse_json))
|
||||
@@ -284,6 +336,7 @@ class TestProductionPlan(ERPNextTestCase):
|
||||
msg=f"Following warehouses were expected {', '.join(missing_warehouse)}")
|
||||
|
||||
def test_get_warehouse_list_single(self):
|
||||
"Check if same warehouse is returned in absence of child warehouses."
|
||||
warehouse_json = '[{\"warehouse\":\"_Test Scrap Warehouse - _TC\"}]'
|
||||
|
||||
warehouses = set(get_warehouse_list(warehouse_json))
|
||||
@@ -292,6 +345,7 @@ class TestProductionPlan(ERPNextTestCase):
|
||||
self.assertEqual(warehouses, expected_warehouses)
|
||||
|
||||
def test_get_sales_order_with_variant(self):
|
||||
"Check if Template BOM is fetched in absence of Variant BOM."
|
||||
rm_item = create_item('PIV_RM', valuation_rate = 100)
|
||||
if not frappe.db.exists('Item', {"item_code": 'PIV'}):
|
||||
item = create_item('PIV', valuation_rate = 100)
|
||||
@@ -348,16 +402,13 @@ 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_"
|
||||
boms = {
|
||||
"Assembly": {
|
||||
"SubAssembly1": {"ChildPart1": {}, "ChildPart2": {},},
|
||||
"SubAssembly2": {"ChildPart3": {}},
|
||||
"SubAssembly3": {"SubSubAssy1": {"ChildPart4": {}}},
|
||||
"ChildPart5": {},
|
||||
"ChildPart6": {},
|
||||
"SubAssembly4": {"SubSubAssy2": {"ChildPart7": {}}},
|
||||
},
|
||||
@@ -386,6 +437,7 @@ class TestProductionPlan(ERPNextTestCase):
|
||||
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()
|
||||
@@ -441,7 +493,121 @@ class TestProductionPlan(ERPNextTestCase):
|
||||
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 test_qty_based_status(self):
|
||||
pp = frappe.new_doc("Production Plan")
|
||||
pp.po_items = [
|
||||
frappe._dict(planned_qty=5, produce_qty=4)
|
||||
]
|
||||
self.assertFalse(pp.all_items_completed())
|
||||
|
||||
pp.po_items = [
|
||||
frappe._dict(planned_qty=5, produce_qty=10),
|
||||
frappe._dict(planned_qty=5, produce_qty=4)
|
||||
]
|
||||
self.assertFalse(pp.all_items_completed())
|
||||
|
||||
|
||||
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({
|
||||
@@ -449,20 +615,35 @@ def create_production_plan(**args):
|
||||
'company': args.company or '_Test Company',
|
||||
'customer': args.customer or '_Test Customer',
|
||||
'posting_date': nowdate(),
|
||||
'include_non_stock_items': args.include_non_stock_items or 1,
|
||||
'include_subcontracted_items': args.include_subcontracted_items or 1,
|
||||
'ignore_existing_ordered_qty': args.ignore_existing_ordered_qty or 1,
|
||||
'po_items': [{
|
||||
'include_non_stock_items': args.include_non_stock_items or 0,
|
||||
'include_subcontracted_items': args.include_subcontracted_items or 0,
|
||||
'ignore_existing_ordered_qty': args.ignore_existing_ordered_qty or 0,
|
||||
'get_items_from': 'Sales Order'
|
||||
})
|
||||
|
||||
if not args.get("sales_order"):
|
||||
pln.append('po_items', {
|
||||
'use_multi_level_bom': args.use_multi_level_bom or 1,
|
||||
'item_code': args.item_code,
|
||||
'bom_no': frappe.db.get_value('Item', args.item_code, 'default_bom'),
|
||||
'planned_qty': args.planned_qty or 1,
|
||||
'planned_start_date': args.planned_start_date or now_datetime()
|
||||
}]
|
||||
})
|
||||
mr_items = get_items_for_material_requests(pln.as_dict())
|
||||
for d in mr_items:
|
||||
pln.append('mr_items', d)
|
||||
})
|
||||
|
||||
if args.get("get_items_from") == "Sales Order" and args.get("sales_order"):
|
||||
so = args.get("sales_order")
|
||||
pln.append('sales_orders', {
|
||||
'sales_order': so.name,
|
||||
'sales_order_date': so.transaction_date,
|
||||
'customer': so.customer,
|
||||
'grand_total': so.grand_total
|
||||
})
|
||||
pln.get_items()
|
||||
|
||||
if not args.get("skip_getting_mr_items"):
|
||||
mr_items = get_items_for_material_requests(pln.as_dict())
|
||||
for d in mr_items:
|
||||
pln.append('mr_items', d)
|
||||
|
||||
if not args.do_not_save:
|
||||
pln.insert()
|
||||
|
||||
@@ -198,6 +198,21 @@ class TestWorkOrder(ERPNextTestCase):
|
||||
self.assertEqual(cint(bin1_on_end_production.reserved_qty_for_production),
|
||||
cint(bin1_on_start_production.reserved_qty_for_production))
|
||||
|
||||
def test_reserved_qty_for_production_closed(self):
|
||||
|
||||
wo1 = make_wo_order_test_record(item="_Test FG Item", qty=2,
|
||||
source_warehouse=self.warehouse)
|
||||
item = wo1.required_items[0].item_code
|
||||
bin_before = get_bin(item, self.warehouse)
|
||||
bin_before.update_reserved_qty_for_production()
|
||||
|
||||
make_wo_order_test_record(item="_Test FG Item", qty=2,
|
||||
source_warehouse=self.warehouse)
|
||||
close_work_order(wo1.name, "Closed")
|
||||
|
||||
bin_after = get_bin(item, self.warehouse)
|
||||
self.assertEqual(bin_before.reserved_qty_for_production, bin_after.reserved_qty_for_production)
|
||||
|
||||
def test_backflush_qty_for_overpduction_manufacture(self):
|
||||
cancel_stock_entry = []
|
||||
allow_overproduction("overproduction_percentage_for_work_order", 30)
|
||||
@@ -700,7 +715,8 @@ class TestWorkOrder(ERPNextTestCase):
|
||||
wo = make_wo_order_test_record(item=item_name, qty=1, source_warehouse=source_warehouse,
|
||||
company=company)
|
||||
|
||||
self.assertRaises(frappe.ValidationError, make_stock_entry, wo.name, 'Material Transfer for Manufacture')
|
||||
stock_entry = frappe.get_doc(make_stock_entry(wo.name, 'Material Transfer for Manufacture'))
|
||||
self.assertRaises(frappe.ValidationError, stock_entry.save)
|
||||
|
||||
def test_wo_completion_with_pl_bom(self):
|
||||
from erpnext.manufacturing.doctype.bom.test_bom import (
|
||||
|
||||
@@ -8,6 +8,8 @@ from dateutil.relativedelta import relativedelta
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.model.mapper import get_mapped_doc
|
||||
from frappe.query_builder import Case
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import (
|
||||
cint,
|
||||
date_diff,
|
||||
@@ -74,7 +76,6 @@ class WorkOrder(Document):
|
||||
|
||||
self.set_required_items(reset_only_qty = len(self.get("required_items")))
|
||||
|
||||
|
||||
def validate_sales_order(self):
|
||||
if self.sales_order:
|
||||
self.check_sales_order_on_hold_or_close()
|
||||
@@ -271,7 +272,7 @@ class WorkOrder(Document):
|
||||
|
||||
produced_qty = total_qty[0][0] if total_qty else 0
|
||||
|
||||
production_plan.run_method("update_produced_qty", produced_qty, self.production_plan_item)
|
||||
production_plan.run_method("update_produced_pending_qty", produced_qty, self.production_plan_item)
|
||||
|
||||
def before_submit(self):
|
||||
self.create_serial_no_batch_no()
|
||||
@@ -541,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):
|
||||
@@ -621,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:
|
||||
@@ -1171,3 +1172,27 @@ def create_pick_list(source_name, target_doc=None, for_qty=None):
|
||||
doc.set_item_locations()
|
||||
|
||||
return doc
|
||||
|
||||
def get_reserved_qty_for_production(item_code: str, warehouse: str) -> float:
|
||||
"""Get total reserved quantity for any item in specified warehouse"""
|
||||
wo = frappe.qb.DocType("Work Order")
|
||||
wo_item = frappe.qb.DocType("Work Order Item")
|
||||
|
||||
return (
|
||||
frappe.qb
|
||||
.from_(wo)
|
||||
.from_(wo_item)
|
||||
.select(Sum(Case()
|
||||
.when(wo.skip_transfer == 0, wo_item.required_qty - wo_item.transferred_qty)
|
||||
.else_(wo_item.required_qty - wo_item.consumed_qty))
|
||||
)
|
||||
.where(
|
||||
(wo_item.item_code == item_code)
|
||||
& (wo_item.parent == wo.name)
|
||||
& (wo.docstatus == 1)
|
||||
& (wo_item.source_warehouse == warehouse)
|
||||
& (wo.status.notin(["Stopped", "Completed", "Closed"]))
|
||||
& ((wo_item.required_qty > wo_item.transferred_qty)
|
||||
| (wo_item.required_qty > wo_item.consumed_qty))
|
||||
)
|
||||
).run()[0][0] or 0.0
|
||||
|
||||
@@ -89,10 +89,10 @@ def get_bom_stock(filters):
|
||||
GROUP BY bom_item.item_code""".format(qty_field=qty_field, table=table, conditions=conditions, bom=bom), as_dict=1)
|
||||
|
||||
def get_manufacturer_records():
|
||||
details = frappe.get_all('Item Manufacturer', fields = ["manufacturer", "manufacturer_part_no", "parent"])
|
||||
details = frappe.get_all('Item Manufacturer', fields = ["manufacturer", "manufacturer_part_no", "item_code"])
|
||||
manufacture_details = frappe._dict()
|
||||
for detail in details:
|
||||
dic = manufacture_details.setdefault(detail.get('parent'), {})
|
||||
dic = manufacture_details.setdefault(detail.get('item_code'), {})
|
||||
dic.setdefault('manufacturer', []).append(detail.get('manufacturer'))
|
||||
dic.setdefault('manufacturer_part', []).append(detail.get('manufacturer_part_no'))
|
||||
|
||||
|
||||
@@ -346,4 +346,8 @@ erpnext.patches.v13_0.update_maintenance_schedule_field_in_visit
|
||||
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.enable_provisional_accounting
|
||||
erpnext.patches.v13_0.update_disbursement_account
|
||||
erpnext.patches.v13_0.update_reserved_qty_closed_wo
|
||||
erpnext.patches.v13_0.amazon_mws_deprecation_warning
|
||||
erpnext.patches.v13_0.set_work_order_qty_in_so_from_mr
|
||||
|
||||
@@ -10,6 +10,8 @@ def execute():
|
||||
FROM `tabBin`""",as_dict=1)
|
||||
|
||||
for entry in bin_details:
|
||||
if not (entry.item_code and entry.warehouse):
|
||||
continue
|
||||
update_bin_qty(entry.get("item_code"), entry.get("warehouse"), {
|
||||
"indented_qty": get_indented_qty(entry.get("item_code"), entry.get("warehouse"))
|
||||
})
|
||||
|
||||
15
erpnext/patches/v13_0/amazon_mws_deprecation_warning.py
Normal file
15
erpnext/patches/v13_0/amazon_mws_deprecation_warning.py
Normal file
@@ -0,0 +1,15 @@
|
||||
import click
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
|
||||
frappe.reload_doc("erpnext_integrations", "doctype", "amazon_mws_settings")
|
||||
if not frappe.db.get_single_value("Amazon MWS Settings", "enable_amazon"):
|
||||
return
|
||||
|
||||
click.secho(
|
||||
"Amazon MWS Integration is moved to a separate app and will be removed from ERPNext in version-14.\n"
|
||||
"Please install the app to continue using the integration: https://github.com/frappe/ecommerce_integrations",
|
||||
fg="yellow",
|
||||
)
|
||||
36
erpnext/patches/v13_0/set_work_order_qty_in_so_from_mr.py
Normal file
36
erpnext/patches/v13_0/set_work_order_qty_in_so_from_mr.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
"""
|
||||
1. Get submitted Work Orders with MR, MR Item and SO set
|
||||
2. Get SO Item detail from MR Item detail in WO, and set in WO
|
||||
3. Update work_order_qty in SO
|
||||
"""
|
||||
work_order = frappe.qb.DocType("Work Order")
|
||||
query = (
|
||||
frappe.qb.from_(work_order)
|
||||
.select(
|
||||
work_order.name, work_order.produced_qty,
|
||||
work_order.material_request,
|
||||
work_order.material_request_item,
|
||||
work_order.sales_order
|
||||
).where(
|
||||
(work_order.material_request.isnotnull())
|
||||
& (work_order.material_request_item.isnotnull())
|
||||
& (work_order.sales_order.isnotnull())
|
||||
& (work_order.docstatus == 1)
|
||||
& (work_order.produced_qty > 0)
|
||||
)
|
||||
)
|
||||
results = query.run(as_dict=True)
|
||||
|
||||
for row in results:
|
||||
so_item = frappe.get_value(
|
||||
"Material Request Item", row.material_request_item, "sales_order_item"
|
||||
)
|
||||
frappe.db.set_value("Work Order", row.name, "sales_order_item", so_item)
|
||||
|
||||
if so_item:
|
||||
wo = frappe.get_doc("Work Order", row.name)
|
||||
wo.update_work_order_qty_in_so()
|
||||
22
erpnext/patches/v13_0/update_disbursement_account.py
Normal file
22
erpnext/patches/v13_0/update_disbursement_account.py
Normal file
@@ -0,0 +1,22 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
|
||||
frappe.reload_doc("loan_management", "doctype", "loan_type")
|
||||
frappe.reload_doc("loan_management", "doctype", "loan")
|
||||
|
||||
loan_type = frappe.qb.DocType("Loan Type")
|
||||
loan = frappe.qb.DocType("Loan")
|
||||
|
||||
frappe.qb.update(
|
||||
loan_type
|
||||
).set(
|
||||
loan_type.disbursement_account, loan_type.payment_account
|
||||
).run()
|
||||
|
||||
frappe.qb.update(
|
||||
loan
|
||||
).set(
|
||||
loan.disbursement_account, loan.payment_account
|
||||
).run()
|
||||
28
erpnext/patches/v13_0/update_reserved_qty_closed_wo.py
Normal file
28
erpnext/patches/v13_0/update_reserved_qty_closed_wo.py
Normal file
@@ -0,0 +1,28 @@
|
||||
import frappe
|
||||
|
||||
from erpnext.stock.utils import get_bin
|
||||
|
||||
|
||||
def execute():
|
||||
|
||||
wo = frappe.qb.DocType("Work Order")
|
||||
wo_item = frappe.qb.DocType("Work Order Item")
|
||||
|
||||
incorrect_item_wh = (
|
||||
frappe.qb
|
||||
.from_(wo)
|
||||
.join(wo_item).on(wo.name == wo_item.parent)
|
||||
.select(wo_item.item_code, wo.source_warehouse).distinct()
|
||||
.where(
|
||||
(wo.status == "Closed")
|
||||
& (wo.docstatus == 1)
|
||||
& (wo.source_warehouse.notnull())
|
||||
)
|
||||
).run()
|
||||
|
||||
for item_code, warehouse in incorrect_item_wh:
|
||||
if not (item_code and warehouse):
|
||||
continue
|
||||
|
||||
bin = get_bin(item_code, warehouse)
|
||||
bin.update_reserved_qty_for_production()
|
||||
@@ -29,9 +29,11 @@ def execute():
|
||||
""")
|
||||
|
||||
for item_code, warehouse in repost_for:
|
||||
update_bin_qty(item_code, warehouse, {
|
||||
"reserved_qty": get_reserved_qty(item_code, warehouse)
|
||||
})
|
||||
if not (item_code and warehouse):
|
||||
continue
|
||||
update_bin_qty(item_code, warehouse, {
|
||||
"reserved_qty": get_reserved_qty(item_code, warehouse)
|
||||
})
|
||||
|
||||
frappe.db.sql("""delete from tabBin
|
||||
where exists(
|
||||
|
||||
@@ -14,6 +14,8 @@ def execute():
|
||||
union
|
||||
select item_code, warehouse from `tabStock Ledger Entry`) a"""):
|
||||
try:
|
||||
if not (item_code and warehouse):
|
||||
continue
|
||||
count += 1
|
||||
update_bin_qty(item_code, warehouse, {
|
||||
"indented_qty": get_indented_qty(item_code, warehouse),
|
||||
|
||||
@@ -479,11 +479,12 @@ def get_emp_list(sal_struct, cond, end_date, payroll_payable_account):
|
||||
""" % cond, {"sal_struct": tuple(sal_struct), "from_date": end_date, "payroll_payable_account": payroll_payable_account}, as_dict=True)
|
||||
|
||||
def remove_payrolled_employees(emp_list, start_date, end_date):
|
||||
new_emp_list = []
|
||||
for employee_details in emp_list:
|
||||
if frappe.db.exists("Salary Slip", {"employee": employee_details.employee, "start_date": start_date, "end_date": end_date, "docstatus": 1}):
|
||||
emp_list.remove(employee_details)
|
||||
if not frappe.db.exists("Salary Slip", {"employee": employee_details.employee, "start_date": start_date, "end_date": end_date, "docstatus": 1}):
|
||||
new_emp_list.append(employee_details)
|
||||
|
||||
return emp_list
|
||||
return new_emp_list
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_start_end_dates(payroll_frequency, start_date=None, company=None):
|
||||
|
||||
@@ -125,7 +125,7 @@ class TestPayrollEntry(unittest.TestCase):
|
||||
|
||||
if not frappe.db.exists("Account", "_Test Payroll Payable - _TC"):
|
||||
create_account(account_name="_Test Payroll Payable",
|
||||
company="_Test Company", parent_account="Current Liabilities - _TC")
|
||||
company="_Test Company", parent_account="Current Liabilities - _TC", account_type="Payable")
|
||||
|
||||
if not frappe.db.get_value("Company", "_Test Company", "default_payroll_payable_account") or \
|
||||
frappe.db.get_value("Company", "_Test Company", "default_payroll_payable_account") != "_Test Payroll Payable - _TC":
|
||||
@@ -197,6 +197,7 @@ class TestPayrollEntry(unittest.TestCase):
|
||||
create_loan_type("Car Loan", 500000, 8.4,
|
||||
is_term_loan=1,
|
||||
mode_of_payment='Cash',
|
||||
disbursement_account='Disbursement Account - _TC',
|
||||
payment_account='Payment Account - _TC',
|
||||
loan_account='Loan Account - _TC',
|
||||
interest_income_account='Interest Income Account - _TC',
|
||||
|
||||
@@ -6,6 +6,7 @@ import random
|
||||
import unittest
|
||||
|
||||
import frappe
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import (
|
||||
add_days,
|
||||
add_months,
|
||||
@@ -374,6 +375,7 @@ class TestSalarySlip(unittest.TestCase):
|
||||
create_loan_type("Car Loan", 500000, 8.4,
|
||||
is_term_loan=1,
|
||||
mode_of_payment='Cash',
|
||||
disbursement_account='Disbursement Account - _TC',
|
||||
payment_account='Payment Account - _TC',
|
||||
loan_account='Loan Account - _TC',
|
||||
interest_income_account='Interest Income Account - _TC',
|
||||
@@ -691,20 +693,25 @@ def make_employee_salary_slip(user, payroll_frequency, salary_structure=None):
|
||||
|
||||
def make_salary_component(salary_components, test_tax, company_list=None):
|
||||
for salary_component in salary_components:
|
||||
if not frappe.db.exists('Salary Component', salary_component["salary_component"]):
|
||||
if test_tax:
|
||||
if salary_component["type"] == "Earning":
|
||||
salary_component["is_tax_applicable"] = 1
|
||||
elif salary_component["salary_component"] == "TDS":
|
||||
salary_component["variable_based_on_taxable_salary"] = 1
|
||||
salary_component["amount_based_on_formula"] = 0
|
||||
salary_component["amount"] = 0
|
||||
salary_component["formula"] = ""
|
||||
salary_component["condition"] = ""
|
||||
salary_component["doctype"] = "Salary Component"
|
||||
salary_component["salary_component_abbr"] = salary_component["abbr"]
|
||||
frappe.get_doc(salary_component).insert()
|
||||
get_salary_component_account(salary_component["salary_component"], company_list)
|
||||
if frappe.db.exists('Salary Component', salary_component["salary_component"]):
|
||||
continue
|
||||
|
||||
if test_tax:
|
||||
if salary_component["type"] == "Earning":
|
||||
salary_component["is_tax_applicable"] = 1
|
||||
elif salary_component["salary_component"] == "TDS":
|
||||
salary_component["variable_based_on_taxable_salary"] = 1
|
||||
salary_component["amount_based_on_formula"] = 0
|
||||
salary_component["amount"] = 0
|
||||
salary_component["formula"] = ""
|
||||
salary_component["condition"] = ""
|
||||
|
||||
salary_component["salary_component_abbr"] = salary_component["abbr"]
|
||||
doc = frappe.new_doc("Salary Component")
|
||||
doc.update(salary_component)
|
||||
doc.insert()
|
||||
|
||||
get_salary_component_account(doc, company_list)
|
||||
|
||||
def get_salary_component_account(sal_comp, company_list=None):
|
||||
company = erpnext.get_default_company()
|
||||
@@ -712,7 +719,9 @@ def get_salary_component_account(sal_comp, company_list=None):
|
||||
if company_list and company not in company_list:
|
||||
company_list.append(company)
|
||||
|
||||
sal_comp = frappe.get_doc("Salary Component", sal_comp)
|
||||
if not isinstance(sal_comp, Document):
|
||||
sal_comp = frappe.get_doc("Salary Component", sal_comp)
|
||||
|
||||
if not sal_comp.get("accounts"):
|
||||
for d in company_list:
|
||||
company_abbr = frappe.get_cached_value('Company', d, 'abbr')
|
||||
@@ -730,7 +739,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:
|
||||
|
||||
@@ -76,9 +76,6 @@ class Task(NestedSet):
|
||||
if flt(self.progress or 0) > 100:
|
||||
frappe.throw(_("Progress % for a task cannot be more than 100."))
|
||||
|
||||
if flt(self.progress) == 100:
|
||||
self.status = 'Completed'
|
||||
|
||||
if self.status == 'Completed':
|
||||
self.progress = 100
|
||||
|
||||
|
||||
@@ -151,6 +151,35 @@ class TestTimesheet(unittest.TestCase):
|
||||
settings.ignore_employee_time_overlap = initial_setting
|
||||
settings.save()
|
||||
|
||||
def test_timesheet_not_overlapping_with_continuous_timelogs(self):
|
||||
emp = make_employee("test_employee_6@salary.com")
|
||||
|
||||
update_activity_type("_Test Activity Type")
|
||||
timesheet = frappe.new_doc("Timesheet")
|
||||
timesheet.employee = emp
|
||||
timesheet.append(
|
||||
'time_logs',
|
||||
{
|
||||
"billable": 1,
|
||||
"activity_type": "_Test Activity Type",
|
||||
"from_time": now_datetime(),
|
||||
"to_time": now_datetime() + datetime.timedelta(hours=3),
|
||||
"company": "_Test Company"
|
||||
}
|
||||
)
|
||||
timesheet.append(
|
||||
'time_logs',
|
||||
{
|
||||
"billable": 1,
|
||||
"activity_type": "_Test Activity Type",
|
||||
"from_time": now_datetime() + datetime.timedelta(hours=3),
|
||||
"to_time": now_datetime() + datetime.timedelta(hours=4),
|
||||
"company": "_Test Company"
|
||||
}
|
||||
)
|
||||
|
||||
timesheet.save() # should not throw an error
|
||||
|
||||
def test_to_time(self):
|
||||
emp = make_employee("test_employee_6@salary.com")
|
||||
from_time = now_datetime()
|
||||
|
||||
@@ -7,7 +7,7 @@ import json
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import add_to_date, flt, getdate, time_diff_in_hours
|
||||
from frappe.utils import add_to_date, flt, get_datetime, getdate, time_diff_in_hours
|
||||
|
||||
from erpnext.controllers.queries import get_match_cond
|
||||
from erpnext.hr.utils import validate_active_employee
|
||||
@@ -145,7 +145,7 @@ class Timesheet(Document):
|
||||
if not (data.from_time and data.hours):
|
||||
return
|
||||
|
||||
_to_time = add_to_date(data.from_time, hours=data.hours, as_datetime=True)
|
||||
_to_time = get_datetime(add_to_date(data.from_time, hours=data.hours, as_datetime=True))
|
||||
if data.to_time != _to_time:
|
||||
data.to_time = _to_time
|
||||
|
||||
@@ -171,39 +171,54 @@ class Timesheet(Document):
|
||||
.format(args.idx, self.name, existing.name), OverlapError)
|
||||
|
||||
def get_overlap_for(self, fieldname, args, value):
|
||||
cond = "ts.`{0}`".format(fieldname)
|
||||
if fieldname == 'workstation':
|
||||
cond = "tsd.`{0}`".format(fieldname)
|
||||
timesheet = frappe.qb.DocType("Timesheet")
|
||||
timelog = frappe.qb.DocType("Timesheet Detail")
|
||||
|
||||
existing = frappe.db.sql("""select ts.name as name, tsd.from_time as from_time, tsd.to_time as to_time from
|
||||
`tabTimesheet Detail` tsd, `tabTimesheet` ts where {0}=%(val)s and tsd.parent = ts.name and
|
||||
(
|
||||
(%(from_time)s > tsd.from_time and %(from_time)s < tsd.to_time) or
|
||||
(%(to_time)s > tsd.from_time and %(to_time)s < tsd.to_time) or
|
||||
(%(from_time)s <= tsd.from_time and %(to_time)s >= tsd.to_time))
|
||||
and tsd.name!=%(name)s
|
||||
and ts.name!=%(parent)s
|
||||
and ts.docstatus < 2""".format(cond),
|
||||
{
|
||||
"val": value,
|
||||
"from_time": args.from_time,
|
||||
"to_time": args.to_time,
|
||||
"name": args.name or "No Name",
|
||||
"parent": args.parent or "No Name"
|
||||
}, as_dict=True)
|
||||
# check internal overlap
|
||||
for time_log in self.time_logs:
|
||||
if not (time_log.from_time and time_log.to_time
|
||||
and args.from_time and args.to_time): continue
|
||||
from_time = get_datetime(args.from_time)
|
||||
to_time = get_datetime(args.to_time)
|
||||
|
||||
if (fieldname != 'workstation' or args.get(fieldname) == time_log.get(fieldname)) and \
|
||||
args.idx != time_log.idx and ((args.from_time > time_log.from_time and args.from_time < time_log.to_time) or
|
||||
(args.to_time > time_log.from_time and args.to_time < time_log.to_time) or
|
||||
(args.from_time <= time_log.from_time and args.to_time >= time_log.to_time)):
|
||||
return self
|
||||
existing = (
|
||||
frappe.qb.from_(timesheet)
|
||||
.join(timelog)
|
||||
.on(timelog.parent == timesheet.name)
|
||||
.select(timesheet.name.as_('name'), timelog.from_time.as_('from_time'), timelog.to_time.as_('to_time'))
|
||||
.where(
|
||||
(timelog.name != (args.name or "No Name"))
|
||||
& (timesheet.name != (args.parent or "No Name"))
|
||||
& (timesheet.docstatus < 2)
|
||||
& (timesheet[fieldname] == value)
|
||||
& (
|
||||
((from_time > timelog.from_time) & (from_time < timelog.to_time))
|
||||
| ((to_time > timelog.from_time) & (to_time < timelog.to_time))
|
||||
| ((from_time <= timelog.from_time) & (to_time >= timelog.to_time))
|
||||
)
|
||||
)
|
||||
).run(as_dict=True)
|
||||
|
||||
if self.check_internal_overlap(fieldname, args):
|
||||
return self
|
||||
|
||||
return existing[0] if existing else None
|
||||
|
||||
def check_internal_overlap(self, fieldname, args):
|
||||
for time_log in self.time_logs:
|
||||
if not (time_log.from_time and time_log.to_time
|
||||
and args.from_time and args.to_time):
|
||||
continue
|
||||
|
||||
from_time = get_datetime(time_log.from_time)
|
||||
to_time = get_datetime(time_log.to_time)
|
||||
args_from_time = get_datetime(args.from_time)
|
||||
args_to_time = get_datetime(args.to_time)
|
||||
|
||||
if (args.get(fieldname) == time_log.get(fieldname)) and (args.idx != time_log.idx) and (
|
||||
(args_from_time > from_time and args_from_time < to_time)
|
||||
or (args_to_time > from_time and args_to_time < to_time)
|
||||
or (args_from_time <= from_time and args_to_time >= to_time)
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
def update_cost(self):
|
||||
for data in self.time_logs:
|
||||
if data.activity_type or data.is_billable:
|
||||
|
||||
@@ -14,12 +14,6 @@
|
||||
"to_time",
|
||||
"hours",
|
||||
"completed",
|
||||
"section_break_7",
|
||||
"completed_qty",
|
||||
"workstation",
|
||||
"column_break_12",
|
||||
"operation",
|
||||
"operation_id",
|
||||
"project_details",
|
||||
"project",
|
||||
"project_name",
|
||||
@@ -83,43 +77,6 @@
|
||||
"fieldtype": "Check",
|
||||
"label": "Completed"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_7",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:parent.work_order",
|
||||
"fieldname": "completed_qty",
|
||||
"fieldtype": "Float",
|
||||
"label": "Completed Qty"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:parent.work_order",
|
||||
"fieldname": "workstation",
|
||||
"fieldtype": "Link",
|
||||
"label": "Workstation",
|
||||
"options": "Workstation",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_12",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:parent.work_order",
|
||||
"fieldname": "operation",
|
||||
"fieldtype": "Link",
|
||||
"label": "Operation",
|
||||
"options": "Operation",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:parent.work_order",
|
||||
"fieldname": "operation_id",
|
||||
"fieldtype": "Data",
|
||||
"hidden": 1,
|
||||
"label": "Operation Id"
|
||||
},
|
||||
{
|
||||
"fieldname": "project_details",
|
||||
"fieldtype": "Section Break"
|
||||
@@ -267,7 +224,7 @@
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2021-05-18 12:19:33.205940",
|
||||
"modified": "2022-02-17 16:53:34.878798",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Projects",
|
||||
"name": "Timesheet Detail",
|
||||
@@ -275,5 +232,6 @@
|
||||
"permissions": [],
|
||||
"quick_entry": 1,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "ASC"
|
||||
"sort_order": "ASC",
|
||||
"states": []
|
||||
}
|
||||
@@ -1443,7 +1443,8 @@ erpnext.TransactionController = erpnext.taxes_and_totals.extend({
|
||||
"item_code": d.item_code,
|
||||
"pricing_rules": d.pricing_rules,
|
||||
"parenttype": d.parenttype,
|
||||
"parent": d.parent
|
||||
"parent": d.parent,
|
||||
"price_list_rate": d.price_list_rate
|
||||
})
|
||||
}
|
||||
});
|
||||
@@ -2264,19 +2265,12 @@ erpnext.TransactionController = erpnext.taxes_and_totals.extend({
|
||||
|
||||
coupon_code: function() {
|
||||
var me = this;
|
||||
if (this.frm.doc.coupon_code) {
|
||||
frappe.run_serially([
|
||||
() => this.frm.doc.ignore_pricing_rule=1,
|
||||
() => me.ignore_pricing_rule(),
|
||||
() => this.frm.doc.ignore_pricing_rule=0,
|
||||
() => me.apply_pricing_rule()
|
||||
]);
|
||||
} else {
|
||||
frappe.run_serially([
|
||||
() => this.frm.doc.ignore_pricing_rule=1,
|
||||
() => me.ignore_pricing_rule()
|
||||
]);
|
||||
}
|
||||
frappe.run_serially([
|
||||
() => this.frm.doc.ignore_pricing_rule=1,
|
||||
() => me.ignore_pricing_rule(),
|
||||
() => this.frm.doc.ignore_pricing_rule=0,
|
||||
() => me.apply_pricing_rule()
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -221,7 +221,6 @@ def get_regional_address_details(party_details, doctype, company):
|
||||
|
||||
if not party_details.place_of_supply: return party_details
|
||||
if not party_details.company_gstin: return party_details
|
||||
if not party_details.supplier_gstin: return party_details
|
||||
|
||||
if ((doctype in ("Sales Invoice", "Delivery Note", "Sales Order") and party_details.company_gstin
|
||||
and party_details.company_gstin[:2] != party_details.place_of_supply[:2]) or (doctype in ("Purchase Invoice",
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ frappe.query_reports["GSTR-1"] = {
|
||||
"fieldtype": "Link",
|
||||
"options": "Address",
|
||||
"get_query": function () {
|
||||
var company = frappe.query_report.get_filter_value('company');
|
||||
let company = frappe.query_report.get_filter_value('company');
|
||||
if (company) {
|
||||
return {
|
||||
"query": 'frappe.contacts.doctype.address.address.address_query',
|
||||
@@ -26,6 +26,11 @@ frappe.query_reports["GSTR-1"] = {
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"fieldname": "company_gstin",
|
||||
"label": __("Company GSTIN"),
|
||||
"fieldtype": "Select"
|
||||
},
|
||||
{
|
||||
"fieldname": "from_date",
|
||||
"label": __("From Date"),
|
||||
@@ -60,10 +65,21 @@ frappe.query_reports["GSTR-1"] = {
|
||||
}
|
||||
],
|
||||
onload: function (report) {
|
||||
let filters = report.get_values();
|
||||
|
||||
frappe.call({
|
||||
method: 'erpnext.regional.report.gstr_1.gstr_1.get_company_gstins',
|
||||
args: {
|
||||
company: filters.company
|
||||
},
|
||||
callback: function(r) {
|
||||
frappe.query_report.page.fields_dict.company_gstin.df.options = r.message;
|
||||
frappe.query_report.page.fields_dict.company_gstin.refresh();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
report.page.add_inner_button(__("Download as JSON"), function () {
|
||||
var filters = report.get_values();
|
||||
|
||||
frappe.call({
|
||||
method: 'erpnext.regional.report.gstr_1.gstr_1.get_json',
|
||||
args: {
|
||||
|
||||
@@ -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,
|
||||
@@ -254,13 +254,14 @@ class Gstr1Report(object):
|
||||
for opts in (("company", " and company=%(company)s"),
|
||||
("from_date", " and posting_date>=%(from_date)s"),
|
||||
("to_date", " and posting_date<=%(to_date)s"),
|
||||
("company_address", " and company_address=%(company_address)s")):
|
||||
("company_address", " and company_address=%(company_address)s"),
|
||||
("company_gstin", " and company_gstin=%(company_gstin)s")):
|
||||
if self.filters.get(opts[0]):
|
||||
conditions += opts[1]
|
||||
|
||||
|
||||
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 +385,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 +411,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 +518,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 +819,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 +843,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 +877,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 +891,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 +1046,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 +1071,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 +1112,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 +1148,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 +1193,24 @@ 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
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_company_gstins(company):
|
||||
address = frappe.qb.DocType("Address")
|
||||
links = frappe.qb.DocType("Dynamic Link")
|
||||
|
||||
addresses = frappe.qb.from_(address).inner_join(links).on(
|
||||
address.name == links.parent
|
||||
).select(
|
||||
address.gstin
|
||||
).where(
|
||||
links.link_doctype == 'Company'
|
||||
).where(
|
||||
links.link_name == company
|
||||
).run(as_dict=1)
|
||||
|
||||
address_list = [''] + [d.gstin for d in addresses]
|
||||
|
||||
return address_list
|
||||
@@ -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
|
||||
@@ -1295,6 +1295,48 @@ class TestSalesOrder(ERPNextTestCase):
|
||||
so.load_from_db()
|
||||
self.assertEqual(so.billing_status, 'Fully Billed')
|
||||
|
||||
def test_so_back_updated_from_wo_via_mr(self):
|
||||
"SO -> MR (Manufacture) -> WO. Test if WO Qty is updated in SO."
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import (
|
||||
make_stock_entry as make_se_from_wo,
|
||||
)
|
||||
from erpnext.stock.doctype.material_request.material_request import raise_work_orders
|
||||
|
||||
so = make_sales_order(item_list=[{"item_code": "_Test FG Item","qty": 2, "rate":100}])
|
||||
|
||||
mr = make_material_request(so.name)
|
||||
mr.material_request_type = "Manufacture"
|
||||
mr.schedule_date = today()
|
||||
mr.submit()
|
||||
|
||||
# WO from MR
|
||||
wo_name = raise_work_orders(mr.name)[0]
|
||||
wo = frappe.get_doc("Work Order", wo_name)
|
||||
wo.wip_warehouse = "Work In Progress - _TC"
|
||||
wo.skip_transfer = True
|
||||
|
||||
self.assertEqual(wo.sales_order, so.name)
|
||||
self.assertEqual(wo.sales_order_item, so.items[0].name)
|
||||
|
||||
wo.submit()
|
||||
make_stock_entry(item_code="_Test Item", # Stock RM
|
||||
target="Work In Progress - _TC",
|
||||
qty=4, basic_rate=100
|
||||
)
|
||||
make_stock_entry(item_code="_Test Item Home Desktop 100", # Stock RM
|
||||
target="Work In Progress - _TC",
|
||||
qty=4, basic_rate=100
|
||||
)
|
||||
|
||||
se = frappe.get_doc(make_se_from_wo(wo.name, "Manufacture", 2))
|
||||
se.submit() # Finish WO
|
||||
|
||||
mr.reload()
|
||||
wo.reload()
|
||||
so.reload()
|
||||
self.assertEqual(so.items[0].work_order_qty, wo.produced_qty)
|
||||
self.assertEqual(mr.status, "Manufactured")
|
||||
|
||||
def automatically_fetch_payment_terms(enable=1):
|
||||
accounts_settings = frappe.get_doc("Accounts Settings")
|
||||
accounts_settings.automatically_fetch_payment_terms = enable
|
||||
|
||||
@@ -83,8 +83,8 @@
|
||||
"planned_qty",
|
||||
"column_break_69",
|
||||
"work_order_qty",
|
||||
"delivered_qty",
|
||||
"produced_qty",
|
||||
"delivered_qty",
|
||||
"returned_qty",
|
||||
"shopping_cart_section",
|
||||
"additional_notes",
|
||||
@@ -701,10 +701,8 @@
|
||||
"width": "50px"
|
||||
},
|
||||
{
|
||||
"description": "For Production",
|
||||
"fieldname": "produced_qty",
|
||||
"fieldtype": "Float",
|
||||
"hidden": 1,
|
||||
"label": "Produced Quantity",
|
||||
"oldfieldname": "produced_qty",
|
||||
"oldfieldtype": "Currency",
|
||||
@@ -802,7 +800,7 @@
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2021-10-05 12:27:25.014789",
|
||||
"modified": "2022-02-21 13:55:08.883104",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Sales Order Item",
|
||||
@@ -811,5 +809,6 @@
|
||||
"permissions": [],
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
/* eslint-disable */
|
||||
|
||||
function get_filters() {
|
||||
let filters = [
|
||||
{
|
||||
"fieldname":"company",
|
||||
"label": __("Company"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Company",
|
||||
"default": frappe.defaults.get_user_default("Company"),
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname":"period_start_date",
|
||||
"label": __("Start Date"),
|
||||
"fieldtype": "Date",
|
||||
"reqd": 1,
|
||||
"default": frappe.datetime.add_months(frappe.datetime.get_today(), -1)
|
||||
},
|
||||
{
|
||||
"fieldname":"period_end_date",
|
||||
"label": __("End Date"),
|
||||
"fieldtype": "Date",
|
||||
"reqd": 1,
|
||||
"default": frappe.datetime.get_today()
|
||||
},
|
||||
{
|
||||
"fieldname":"sales_order",
|
||||
"label": __("Sales Order"),
|
||||
"fieldtype": "MultiSelectList",
|
||||
"width": 100,
|
||||
"options": "Sales Order",
|
||||
"get_data": function(txt) {
|
||||
return frappe.db.get_link_options("Sales Order", txt, this.filters());
|
||||
},
|
||||
"filters": () => {
|
||||
return {
|
||||
docstatus: 1,
|
||||
payment_terms_template: ['not in', ['']],
|
||||
company: frappe.query_report.get_filter_value("company"),
|
||||
transaction_date: ['between', [frappe.query_report.get_filter_value("period_start_date"), frappe.query_report.get_filter_value("period_end_date")]]
|
||||
}
|
||||
},
|
||||
on_change: function(){
|
||||
frappe.query_report.refresh();
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
return filters;
|
||||
}
|
||||
|
||||
frappe.query_reports["Payment Terms Status for Sales Order"] = {
|
||||
"filters": get_filters(),
|
||||
"formatter": function(value, row, column, data, default_formatter){
|
||||
if(column.fieldname == 'invoices' && value) {
|
||||
invoices = value.split(',');
|
||||
const invoice_formatter = (prev_value, curr_value) => {
|
||||
if(prev_value != "") {
|
||||
return prev_value + ", " + default_formatter(curr_value, row, column, data);
|
||||
}
|
||||
else {
|
||||
return default_formatter(curr_value, row, column, data);
|
||||
}
|
||||
}
|
||||
return invoices.reduce(invoice_formatter, "")
|
||||
}
|
||||
else if (column.fieldname == 'paid_amount' && value){
|
||||
formatted_value = default_formatter(value, row, column, data);
|
||||
if(value > 0) {
|
||||
formatted_value = "<span style='color:green;'>" + formatted_value + "</span>"
|
||||
}
|
||||
return formatted_value;
|
||||
}
|
||||
else if (column.fieldname == 'status' && value == 'Completed'){
|
||||
return "<span style='color:green;'>" + default_formatter(value, row, column, data) + "</span>";
|
||||
}
|
||||
|
||||
return default_formatter(value, row, column, data);
|
||||
},
|
||||
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"add_total_row": 1,
|
||||
"columns": [],
|
||||
"creation": "2021-12-28 10:39:34.533964",
|
||||
"disable_prepared_report": 0,
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"filters": [],
|
||||
"idx": 0,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2021-12-30 10:42:06.058457",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Payment Terms Status for Sales Order",
|
||||
"owner": "Administrator",
|
||||
"prepared_report": 0,
|
||||
"ref_doctype": "Sales Order",
|
||||
"report_name": "Payment Terms Status for Sales Order",
|
||||
"report_type": "Script Report",
|
||||
"roles": [
|
||||
{
|
||||
"role": "Sales User"
|
||||
},
|
||||
{
|
||||
"role": "Sales Manager"
|
||||
},
|
||||
{
|
||||
"role": "Maintenance User"
|
||||
},
|
||||
{
|
||||
"role": "Accounts User"
|
||||
},
|
||||
{
|
||||
"role": "Stock User"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# License: MIT. See LICENSE
|
||||
|
||||
import frappe
|
||||
from frappe import _, qb, query_builder
|
||||
from frappe.query_builder import functions
|
||||
|
||||
|
||||
def get_columns():
|
||||
columns = [
|
||||
{
|
||||
"label": _("Sales Order"),
|
||||
"fieldname": "name",
|
||||
"fieldtype": "Link",
|
||||
"options": "Sales Order",
|
||||
},
|
||||
{
|
||||
"label": _("Posting Date"),
|
||||
"fieldname": "submitted",
|
||||
"fieldtype": "Date",
|
||||
},
|
||||
{
|
||||
"label": _("Payment Term"),
|
||||
"fieldname": "payment_term",
|
||||
"fieldtype": "Data",
|
||||
},
|
||||
{
|
||||
"label": _("Description"),
|
||||
"fieldname": "description",
|
||||
"fieldtype": "Data",
|
||||
},
|
||||
{
|
||||
"label": _("Due Date"),
|
||||
"fieldname": "due_date",
|
||||
"fieldtype": "Date",
|
||||
},
|
||||
{
|
||||
"label": _("Invoice Portion"),
|
||||
"fieldname": "invoice_portion",
|
||||
"fieldtype": "Percent",
|
||||
},
|
||||
{
|
||||
"label": _("Payment Amount"),
|
||||
"fieldname": "base_payment_amount",
|
||||
"fieldtype": "Currency",
|
||||
"options": "currency",
|
||||
},
|
||||
{
|
||||
"label": _("Paid Amount"),
|
||||
"fieldname": "paid_amount",
|
||||
"fieldtype": "Currency",
|
||||
"options": "currency",
|
||||
},
|
||||
{
|
||||
"label": _("Invoices"),
|
||||
"fieldname": "invoices",
|
||||
"fieldtype": "Link",
|
||||
"options": "Sales Invoice",
|
||||
},
|
||||
{
|
||||
"label": _("Status"),
|
||||
"fieldname": "status",
|
||||
"fieldtype": "Data",
|
||||
},
|
||||
{
|
||||
"label": _("Currency"),
|
||||
"fieldname": "currency",
|
||||
"fieldtype": "Currency",
|
||||
"hidden": 1
|
||||
}
|
||||
]
|
||||
return columns
|
||||
|
||||
|
||||
def get_conditions(filters):
|
||||
"""
|
||||
Convert filter options to conditions used in query
|
||||
"""
|
||||
filters = frappe._dict(filters) if filters else frappe._dict({})
|
||||
conditions = frappe._dict({})
|
||||
|
||||
conditions.company = filters.company or frappe.defaults.get_user_default("company")
|
||||
conditions.end_date = filters.period_end_date or frappe.utils.today()
|
||||
conditions.start_date = filters.period_start_date or frappe.utils.add_months(
|
||||
conditions.end_date, -1
|
||||
)
|
||||
conditions.sales_order = filters.sales_order or []
|
||||
|
||||
return conditions
|
||||
|
||||
|
||||
def get_so_with_invoices(filters):
|
||||
"""
|
||||
Get Sales Order with payment terms template with their associated Invoices
|
||||
"""
|
||||
sorders = []
|
||||
|
||||
so = qb.DocType("Sales Order")
|
||||
ps = qb.DocType("Payment Schedule")
|
||||
datediff = query_builder.CustomFunction("DATEDIFF", ["cur_date", "due_date"])
|
||||
ifelse = query_builder.CustomFunction("IF", ["condition", "then", "else"])
|
||||
|
||||
conditions = get_conditions(filters)
|
||||
query_so = (
|
||||
qb.from_(so)
|
||||
.join(ps)
|
||||
.on(ps.parent == so.name)
|
||||
.select(
|
||||
so.name,
|
||||
so.transaction_date.as_("submitted"),
|
||||
ifelse(datediff(ps.due_date, functions.CurDate()) < 0, "Overdue", "Unpaid").as_("status"),
|
||||
ps.payment_term,
|
||||
ps.description,
|
||||
ps.due_date,
|
||||
ps.invoice_portion,
|
||||
ps.base_payment_amount,
|
||||
ps.paid_amount,
|
||||
)
|
||||
.where(
|
||||
(so.docstatus == 1)
|
||||
& (so.payment_terms_template != "NULL")
|
||||
& (so.company == conditions.company)
|
||||
& (so.transaction_date[conditions.start_date : conditions.end_date])
|
||||
)
|
||||
.orderby(so.name, so.transaction_date, ps.due_date)
|
||||
)
|
||||
|
||||
if conditions.sales_order != []:
|
||||
query_so = query_so.where(so.name.isin(conditions.sales_order))
|
||||
|
||||
sorders = query_so.run(as_dict=True)
|
||||
|
||||
invoices = []
|
||||
if sorders != []:
|
||||
soi = qb.DocType("Sales Order Item")
|
||||
si = qb.DocType("Sales Invoice")
|
||||
sii = qb.DocType("Sales Invoice Item")
|
||||
query_inv = (
|
||||
qb.from_(sii)
|
||||
.right_join(si)
|
||||
.on(si.name == sii.parent)
|
||||
.inner_join(soi)
|
||||
.on(soi.name == sii.so_detail)
|
||||
.select(sii.sales_order, sii.parent.as_("invoice"), si.base_grand_total.as_("invoice_amount"))
|
||||
.where((sii.sales_order.isin([x.name for x in sorders])) & (si.docstatus == 1))
|
||||
.groupby(sii.parent)
|
||||
)
|
||||
invoices = query_inv.run(as_dict=True)
|
||||
|
||||
return sorders, invoices
|
||||
|
||||
|
||||
def set_payment_terms_statuses(sales_orders, invoices, filters):
|
||||
"""
|
||||
compute status for payment terms with associated sales invoice using FIFO
|
||||
"""
|
||||
|
||||
for so in sales_orders:
|
||||
so.currency = frappe.get_cached_value('Company', filters.get('company'), 'default_currency')
|
||||
so.invoices = ""
|
||||
for inv in [x for x in invoices if x.sales_order == so.name and x.invoice_amount > 0]:
|
||||
if so.base_payment_amount - so.paid_amount > 0:
|
||||
amount = so.base_payment_amount - so.paid_amount
|
||||
if inv.invoice_amount >= amount:
|
||||
inv.invoice_amount -= amount
|
||||
so.paid_amount += amount
|
||||
so.invoices += "," + inv.invoice
|
||||
so.status = "Completed"
|
||||
break
|
||||
else:
|
||||
so.paid_amount += inv.invoice_amount
|
||||
inv.invoice_amount = 0
|
||||
so.invoices += "," + inv.invoice
|
||||
so.status = "Partly Paid"
|
||||
|
||||
return sales_orders, invoices
|
||||
|
||||
|
||||
def prepare_chart(s_orders):
|
||||
if len(set([x.name for x in s_orders])) == 1:
|
||||
chart = {
|
||||
"data": {
|
||||
"labels": [term.payment_term for term in s_orders],
|
||||
"datasets": [
|
||||
{"name": "Payment Amount", "values": [x.base_payment_amount for x in s_orders],},
|
||||
{"name": "Paid Amount", "values": [x.paid_amount for x in s_orders],},
|
||||
],
|
||||
},
|
||||
"type": "bar",
|
||||
}
|
||||
return chart
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
columns = get_columns()
|
||||
sales_orders, so_invoices = get_so_with_invoices(filters)
|
||||
sales_orders, so_invoices = set_payment_terms_statuses(sales_orders, so_invoices, filters)
|
||||
|
||||
prepare_chart(sales_orders)
|
||||
|
||||
data = sales_orders
|
||||
message = []
|
||||
chart = prepare_chart(sales_orders)
|
||||
|
||||
return columns, data, message, chart
|
||||
@@ -0,0 +1,198 @@
|
||||
import datetime
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_days
|
||||
|
||||
from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
from erpnext.selling.report.payment_terms_status_for_sales_order.payment_terms_status_for_sales_order import (
|
||||
execute,
|
||||
)
|
||||
from erpnext.stock.doctype.item.test_item import create_item
|
||||
from erpnext.tests.utils import ERPNextTestCase
|
||||
|
||||
test_dependencies = ["Sales Order", "Item", "Sales Invoice", "Payment Terms Template"]
|
||||
|
||||
|
||||
class TestPaymentTermsStatusForSalesOrder(ERPNextTestCase):
|
||||
def create_payment_terms_template(self):
|
||||
# create template for 50-50 payments
|
||||
template = None
|
||||
if frappe.db.exists("Payment Terms Template", "_Test 50-50"):
|
||||
template = frappe.get_doc("Payment Terms Template", "_Test 50-50")
|
||||
else:
|
||||
template = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Payment Terms Template",
|
||||
"template_name": "_Test 50-50",
|
||||
"terms": [
|
||||
{
|
||||
"doctype": "Payment Terms Template Detail",
|
||||
"due_date_based_on": "Day(s) after invoice date",
|
||||
"payment_term_name": "_Test 50% on 15 Days",
|
||||
"description": "_Test 50-50",
|
||||
"invoice_portion": 50,
|
||||
"credit_days": 15,
|
||||
},
|
||||
{
|
||||
"doctype": "Payment Terms Template Detail",
|
||||
"due_date_based_on": "Day(s) after invoice date",
|
||||
"payment_term_name": "_Test 50% on 30 Days",
|
||||
"description": "_Test 50-50",
|
||||
"invoice_portion": 50,
|
||||
"credit_days": 30,
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
template.insert()
|
||||
self.template = template
|
||||
|
||||
def test_payment_terms_status(self):
|
||||
self.create_payment_terms_template()
|
||||
item = create_item(item_code="_Test Excavator", is_stock_item=0)
|
||||
so = make_sales_order(
|
||||
transaction_date="2021-06-15",
|
||||
delivery_date=add_days("2021-06-15", -30),
|
||||
item=item.item_code,
|
||||
qty=10,
|
||||
rate=100000,
|
||||
do_not_save=True,
|
||||
)
|
||||
so.po_no = ""
|
||||
so.taxes_and_charges = ""
|
||||
so.taxes = ""
|
||||
so.payment_terms_template = self.template.name
|
||||
so.save()
|
||||
so.submit()
|
||||
|
||||
# make invoice with 60% of the total sales order value
|
||||
sinv = make_sales_invoice(so.name)
|
||||
sinv.taxes_and_charges = ""
|
||||
sinv.taxes = ""
|
||||
sinv.items[0].qty = 6
|
||||
sinv.insert()
|
||||
sinv.submit()
|
||||
columns, data, message, chart = execute(
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"period_start_date": "2021-06-01",
|
||||
"period_end_date": "2021-06-30",
|
||||
"sales_order": [so.name],
|
||||
}
|
||||
)
|
||||
|
||||
expected_value = [
|
||||
{
|
||||
"name": so.name,
|
||||
"submitted": datetime.date(2021, 6, 15),
|
||||
"status": "Completed",
|
||||
"payment_term": None,
|
||||
"description": "_Test 50-50",
|
||||
"due_date": datetime.date(2021, 6, 30),
|
||||
"invoice_portion": 50.0,
|
||||
"currency": "INR",
|
||||
"base_payment_amount": 500000.0,
|
||||
"paid_amount": 500000.0,
|
||||
"invoices": ","+sinv.name,
|
||||
},
|
||||
{
|
||||
"name": so.name,
|
||||
"submitted": datetime.date(2021, 6, 15),
|
||||
"status": "Partly Paid",
|
||||
"payment_term": None,
|
||||
"description": "_Test 50-50",
|
||||
"due_date": datetime.date(2021, 7, 15),
|
||||
"invoice_portion": 50.0,
|
||||
"currency": "INR",
|
||||
"base_payment_amount": 500000.0,
|
||||
"paid_amount": 100000.0,
|
||||
"invoices": ","+sinv.name,
|
||||
},
|
||||
]
|
||||
self.assertEqual(data, expected_value)
|
||||
|
||||
def create_exchange_rate(self, date):
|
||||
# make an entry in Currency Exchange list. serves as a static exchange rate
|
||||
if frappe.db.exists({'doctype': "Currency Exchange",'date': date,'from_currency': 'USD', 'to_currency':'INR'}):
|
||||
return
|
||||
else:
|
||||
doc = frappe.get_doc({
|
||||
'doctype': "Currency Exchange",
|
||||
'date': date,
|
||||
'from_currency': 'USD',
|
||||
'to_currency': frappe.get_cached_value("Company", '_Test Company','default_currency'),
|
||||
'exchange_rate': 70,
|
||||
'for_buying': True,
|
||||
'for_selling': True
|
||||
})
|
||||
doc.insert()
|
||||
|
||||
def test_alternate_currency(self):
|
||||
transaction_date = "2021-06-15"
|
||||
self.create_payment_terms_template()
|
||||
self.create_exchange_rate(transaction_date)
|
||||
item = create_item(item_code="_Test Excavator", is_stock_item=0)
|
||||
so = make_sales_order(
|
||||
transaction_date=transaction_date,
|
||||
currency="USD",
|
||||
delivery_date=add_days(transaction_date, -30),
|
||||
item=item.item_code,
|
||||
qty=10,
|
||||
rate=10000,
|
||||
do_not_save=True,
|
||||
)
|
||||
so.po_no = ""
|
||||
so.taxes_and_charges = ""
|
||||
so.taxes = ""
|
||||
so.payment_terms_template = self.template.name
|
||||
so.save()
|
||||
so.submit()
|
||||
|
||||
# make invoice with 60% of the total sales order value
|
||||
sinv = make_sales_invoice(so.name)
|
||||
sinv.currency = "USD"
|
||||
sinv.taxes_and_charges = ""
|
||||
sinv.taxes = ""
|
||||
sinv.items[0].qty = 6
|
||||
sinv.insert()
|
||||
sinv.submit()
|
||||
columns, data, message, chart = execute(
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"period_start_date": "2021-06-01",
|
||||
"period_end_date": "2021-06-30",
|
||||
"sales_order": [so.name],
|
||||
}
|
||||
)
|
||||
|
||||
# report defaults to company currency.
|
||||
expected_value = [
|
||||
{
|
||||
"name": so.name,
|
||||
"submitted": datetime.date(2021, 6, 15),
|
||||
"status": "Completed",
|
||||
"payment_term": None,
|
||||
"description": "_Test 50-50",
|
||||
"due_date": datetime.date(2021, 6, 30),
|
||||
"invoice_portion": 50.0,
|
||||
"currency": frappe.get_cached_value("Company", '_Test Company','default_currency'),
|
||||
"base_payment_amount": 3500000.0,
|
||||
"paid_amount": 3500000.0,
|
||||
"invoices": ","+sinv.name,
|
||||
},
|
||||
{
|
||||
"name": so.name,
|
||||
"submitted": datetime.date(2021, 6, 15),
|
||||
"status": "Partly Paid",
|
||||
"payment_term": None,
|
||||
"description": "_Test 50-50",
|
||||
"due_date": datetime.date(2021, 7, 15),
|
||||
"invoice_portion": 50.0,
|
||||
"currency": frappe.get_cached_value("Company", '_Test Company','default_currency'),
|
||||
"base_payment_amount": 3500000.0,
|
||||
"paid_amount": 700000.0,
|
||||
"invoices": ","+sinv.name,
|
||||
},
|
||||
]
|
||||
self.assertEqual(data, expected_value)
|
||||
@@ -227,11 +227,11 @@ erpnext.selling.SellingController = erpnext.TransactionController.extend({
|
||||
},
|
||||
callback:function(r){
|
||||
if (in_list(['Delivery Note', 'Sales Invoice'], doc.doctype)) {
|
||||
|
||||
if (doc.doctype === 'Sales Invoice' && (!doc.update_stock)) return;
|
||||
|
||||
me.set_batch_number(cdt, cdn);
|
||||
me.batch_no(doc, cdt, cdn);
|
||||
if (has_batch_no) {
|
||||
me.set_batch_number(cdt, cdn);
|
||||
me.batch_no(doc, cdt, cdn);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -31,23 +31,9 @@ class Bin(Document):
|
||||
def update_reserved_qty_for_production(self):
|
||||
'''Update qty reserved for production from Production Item tables
|
||||
in open work orders'''
|
||||
self.reserved_qty_for_production = frappe.db.sql('''
|
||||
SELECT
|
||||
SUM(CASE WHEN ifnull(skip_transfer, 0) = 0 THEN
|
||||
item.required_qty - item.transferred_qty
|
||||
ELSE
|
||||
item.required_qty - item.consumed_qty END)
|
||||
END
|
||||
FROM `tabWork Order` pro, `tabWork Order Item` item
|
||||
WHERE
|
||||
item.item_code = %s
|
||||
and item.parent = pro.name
|
||||
and pro.docstatus = 1
|
||||
and item.source_warehouse = %s
|
||||
and pro.status not in ("Stopped", "Completed")
|
||||
and (item.required_qty > item.transferred_qty or item.required_qty > item.consumed_qty)
|
||||
''', (self.item_code, self.warehouse))[0][0]
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import get_reserved_qty_for_production
|
||||
|
||||
self.reserved_qty_for_production = get_reserved_qty_for_production(self.item_code, self.warehouse)
|
||||
self.set_projected_qty()
|
||||
|
||||
self.db_set('reserved_qty_for_production', flt(self.reserved_qty_for_production))
|
||||
|
||||
@@ -342,25 +342,21 @@ def update_billed_amount_based_on_so(so_detail, update_modified=True):
|
||||
from frappe.query_builder.functions import Sum
|
||||
|
||||
# Billed against Sales Order directly
|
||||
si = frappe.qb.DocType("Sales Invoice").as_("si")
|
||||
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).from_(si_item).select(sum_amount).where(
|
||||
(si_item.parent == si.name) &
|
||||
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) &
|
||||
(si.update_stock == 0)
|
||||
(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 = 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, dn_item.stock_qty, dn_item.returned_qty).where(
|
||||
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) &
|
||||
@@ -385,11 +381,7 @@ def update_billed_amount_based_on_so(so_detail, update_modified=True):
|
||||
|
||||
# Distribute billed amount directly against SO between DNs based on FIFO
|
||||
if billed_against_so and billed_amt_agianst_dn < dnd.amount:
|
||||
if dnd.returned_qty:
|
||||
pending_to_bill = flt(dnd.amount) * (dnd.stock_qty - dnd.returned_qty) / dnd.stock_qty
|
||||
else:
|
||||
pending_to_bill = flt(dnd.amount)
|
||||
pending_to_bill -= billed_amt_agianst_dn
|
||||
pending_to_bill = flt(dnd.amount) - billed_amt_agianst_dn
|
||||
if pending_to_bill <= billed_against_so:
|
||||
billed_amt_agianst_dn += pending_to_bill
|
||||
billed_against_so -= pending_to_bill
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -534,6 +534,7 @@ def raise_work_orders(material_request):
|
||||
"stock_uom": d.stock_uom,
|
||||
"expected_delivery_date": d.schedule_date,
|
||||
"sales_order": d.sales_order,
|
||||
"sales_order_item": d.get("sales_order_item"),
|
||||
"bom_no": get_item_details(d.item_code).bom_no,
|
||||
"material_request": mr.name,
|
||||
"material_request_item": d.name,
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
from frappe.utils import add_to_date, nowdate
|
||||
|
||||
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
|
||||
from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import get_gl_entries
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
from erpnext.tests.utils import ERPNextTestCase, change_settings
|
||||
|
||||
|
||||
@@ -12,31 +16,30 @@ class TestPackedItem(ERPNextTestCase):
|
||||
"Test impact on Packed Items table in various scenarios."
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
make_item("_Test Product Bundle X", {"is_stock_item": 0})
|
||||
make_item("_Test Bundle Item 1", {"is_stock_item": 1})
|
||||
make_item("_Test Bundle Item 2", {"is_stock_item": 1})
|
||||
super().setUpClass()
|
||||
cls.bundle = "_Test Product Bundle X"
|
||||
cls.bundle_items = ["_Test Bundle Item 1", "_Test Bundle Item 2"]
|
||||
make_item(cls.bundle, {"is_stock_item": 0})
|
||||
for item in cls.bundle_items:
|
||||
make_item(item, {"is_stock_item": 1})
|
||||
|
||||
make_item("_Test Normal Stock Item", {"is_stock_item": 1})
|
||||
|
||||
make_product_bundle(
|
||||
"_Test Product Bundle X",
|
||||
["_Test Bundle Item 1", "_Test Bundle Item 2"],
|
||||
qty=2
|
||||
)
|
||||
make_product_bundle(cls.bundle, cls.bundle_items, qty=2)
|
||||
|
||||
def test_adding_bundle_item(self):
|
||||
"Test impact on packed items if bundle item row is added."
|
||||
so = make_sales_order(item_code = "_Test Product Bundle X", qty=1,
|
||||
so = make_sales_order(item_code = self.bundle, qty=1,
|
||||
do_not_submit=True)
|
||||
|
||||
self.assertEqual(so.items[0].qty, 1)
|
||||
self.assertEqual(len(so.packed_items), 2)
|
||||
self.assertEqual(so.packed_items[0].item_code, "_Test Bundle Item 1")
|
||||
self.assertEqual(so.packed_items[0].item_code, self.bundle_items[0])
|
||||
self.assertEqual(so.packed_items[0].qty, 2)
|
||||
|
||||
def test_updating_bundle_item(self):
|
||||
"Test impact on packed items if bundle item row is updated."
|
||||
so = make_sales_order(item_code = "_Test Product Bundle X", qty=1,
|
||||
do_not_submit=True)
|
||||
so = make_sales_order(item_code=self.bundle, qty=1, do_not_submit=True)
|
||||
|
||||
so.items[0].qty = 2 # change qty
|
||||
so.save()
|
||||
@@ -55,7 +58,7 @@ class TestPackedItem(ERPNextTestCase):
|
||||
so_items = []
|
||||
for qty in [2, 4, 6, 8]:
|
||||
so_items.append({
|
||||
"item_code": "_Test Product Bundle X",
|
||||
"item_code": self.bundle,
|
||||
"qty": qty,
|
||||
"rate": 400,
|
||||
"warehouse": "_Test Warehouse - _TC"
|
||||
@@ -66,7 +69,7 @@ class TestPackedItem(ERPNextTestCase):
|
||||
|
||||
# check alternate rows for qty
|
||||
self.assertEqual(len(so.packed_items), 8)
|
||||
self.assertEqual(so.packed_items[1].item_code, "_Test Bundle Item 2")
|
||||
self.assertEqual(so.packed_items[1].item_code, self.bundle_items[1])
|
||||
self.assertEqual(so.packed_items[1].qty, 4)
|
||||
self.assertEqual(so.packed_items[3].qty, 8)
|
||||
self.assertEqual(so.packed_items[5].qty, 12)
|
||||
@@ -94,8 +97,7 @@ class TestPackedItem(ERPNextTestCase):
|
||||
@change_settings("Selling Settings", {"editable_bundle_item_rates": 1})
|
||||
def test_bundle_item_cumulative_price(self):
|
||||
"Test if Bundle Item rate is cumulative from packed items."
|
||||
so = make_sales_order(item_code = "_Test Product Bundle X", qty=2,
|
||||
do_not_submit=True)
|
||||
so = make_sales_order(item_code=self.bundle, qty=2, do_not_submit=True)
|
||||
|
||||
so.packed_items[0].rate = 150
|
||||
so.packed_items[1].rate = 200
|
||||
@@ -109,7 +111,7 @@ class TestPackedItem(ERPNextTestCase):
|
||||
so_items = []
|
||||
for qty in [2, 4]:
|
||||
so_items.append({
|
||||
"item_code": "_Test Product Bundle X",
|
||||
"item_code": self.bundle,
|
||||
"qty": qty,
|
||||
"rate": 400,
|
||||
"warehouse": "_Test Warehouse - _TC"
|
||||
@@ -124,4 +126,33 @@ class TestPackedItem(ERPNextTestCase):
|
||||
|
||||
self.assertEqual(len(dn.packed_items), 4)
|
||||
self.assertEqual(dn.packed_items[2].qty, 6)
|
||||
self.assertEqual(dn.packed_items[3].qty, 6)
|
||||
self.assertEqual(dn.packed_items[3].qty, 6)
|
||||
|
||||
def test_reposting_packed_items(self):
|
||||
warehouse = "Stores - TCP1"
|
||||
company = "_Test Company with perpetual inventory"
|
||||
|
||||
today = nowdate()
|
||||
yesterday = add_to_date(today, days=-1, as_string=True)
|
||||
|
||||
for item in self.bundle_items:
|
||||
make_stock_entry(item_code=item, to_warehouse=warehouse, qty=10, rate=100, posting_date=today)
|
||||
|
||||
so = make_sales_order(item_code = self.bundle, qty=1, company=company, warehouse=warehouse)
|
||||
|
||||
dn = make_delivery_note(so.name)
|
||||
dn.save()
|
||||
dn.submit()
|
||||
|
||||
gles = get_gl_entries(dn.doctype, dn.name)
|
||||
credit_before_repost = sum(gle.credit for gle in gles)
|
||||
|
||||
# backdated stock entry
|
||||
for item in self.bundle_items:
|
||||
make_stock_entry(item_code=item, to_warehouse=warehouse, qty=10, rate=200, posting_date=yesterday)
|
||||
|
||||
# assert correct reposting
|
||||
gles = get_gl_entries(dn.doctype, dn.name)
|
||||
credit_after_reposting = sum(gle.credit for gle in gles)
|
||||
self.assertNotEqual(credit_before_repost, credit_after_reposting)
|
||||
self.assertAlmostEqual(credit_after_reposting, 2 * credit_before_repost)
|
||||
|
||||
@@ -283,9 +283,6 @@ class PurchaseReceipt(BuyingController):
|
||||
{"voucher_type": "Purchase Receipt", "voucher_no": self.name,
|
||||
"voucher_detail_no": d.name, "warehouse": d.warehouse, "is_cancelled": 0}, "stock_value_difference")
|
||||
|
||||
if not stock_value_diff:
|
||||
continue
|
||||
|
||||
warehouse_account_name = warehouse_account[d.warehouse]["account"]
|
||||
warehouse_account_currency = warehouse_account[d.warehouse]["account_currency"]
|
||||
supplier_warehouse_account = warehouse_account.get(self.supplier_warehouse, {}).get("account")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,8 +9,7 @@ from collections import defaultdict
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import cint, floor, flt, nowdate
|
||||
from six import string_types
|
||||
from frappe.utils import cint, cstr, floor, flt, nowdate
|
||||
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
|
||||
from erpnext.stock.utils import get_stock_balance
|
||||
@@ -75,7 +74,7 @@ def apply_putaway_rule(doctype, items, company, sync=None, purpose=None):
|
||||
purpose: Purpose of Stock Entry
|
||||
sync (optional): Sync with client side only for client side calls
|
||||
"""
|
||||
if isinstance(items, string_types):
|
||||
if isinstance(items, str):
|
||||
items = json.loads(items)
|
||||
|
||||
items_not_accomodated, updated_table = [], []
|
||||
@@ -143,11 +142,44 @@ def apply_putaway_rule(doctype, items, company, sync=None, purpose=None):
|
||||
if items_not_accomodated:
|
||||
show_unassigned_items_message(items_not_accomodated)
|
||||
|
||||
items[:] = updated_table if updated_table else items # modify items table
|
||||
if updated_table and _items_changed(items, updated_table, doctype):
|
||||
items[:] = updated_table
|
||||
frappe.msgprint(_("Applied putaway rules."), alert=True)
|
||||
|
||||
if sync and json.loads(sync): # sync with client side
|
||||
return items
|
||||
|
||||
def _items_changed(old, new, doctype: str) -> bool:
|
||||
""" Check if any items changed by application of putaway rules.
|
||||
|
||||
If not, changing item table can have side effects since `name` items also changes.
|
||||
"""
|
||||
if len(old) != len(new):
|
||||
return True
|
||||
|
||||
old = [frappe._dict(item) if isinstance(item, dict) else item for item in old]
|
||||
|
||||
if doctype == "Stock Entry":
|
||||
compare_keys = ("item_code", "t_warehouse", "transfer_qty", "serial_no")
|
||||
sort_key = lambda item: (item.item_code, cstr(item.t_warehouse), # noqa
|
||||
flt(item.transfer_qty), cstr(item.serial_no))
|
||||
else:
|
||||
# purchase receipt / invoice
|
||||
compare_keys = ("item_code", "warehouse", "stock_qty", "received_qty", "serial_no")
|
||||
sort_key = lambda item: (item.item_code, cstr(item.warehouse), # noqa
|
||||
flt(item.stock_qty), flt(item.received_qty), cstr(item.serial_no))
|
||||
|
||||
old_sorted = sorted(old, key=sort_key)
|
||||
new_sorted = sorted(new, key=sort_key)
|
||||
|
||||
# Once sorted by all relevant keys both tables should align if they are same.
|
||||
for old_item, new_item in zip(old_sorted, new_sorted):
|
||||
for key in compare_keys:
|
||||
if old_item.get(key) != new_item.get(key):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_ordered_putaway_rules(item_code, company, source_warehouse=None):
|
||||
"""Returns an ordered list of putaway rules to apply on an item."""
|
||||
filters = {
|
||||
|
||||
@@ -35,6 +35,18 @@ class TestPutawayRule(ERPNextTestCase):
|
||||
new_uom.uom_name = "Bag"
|
||||
new_uom.save()
|
||||
|
||||
def assertUnchangedItemsOnResave(self, doc):
|
||||
""" Check if same items remain even after reapplication of rules.
|
||||
|
||||
This is required since some business logic like subcontracting
|
||||
depends on `name` of items to be same if item isn't changed.
|
||||
"""
|
||||
doc.reload()
|
||||
old_items = {d.name for d in doc.items}
|
||||
doc.save()
|
||||
new_items = {d.name for d in doc.items}
|
||||
self.assertSetEqual(old_items, new_items)
|
||||
|
||||
def test_putaway_rules_priority(self):
|
||||
"""Test if rule is applied by priority, irrespective of free space."""
|
||||
rule_1 = create_putaway_rule(item_code="_Rice", warehouse=self.warehouse_1, capacity=200,
|
||||
@@ -50,6 +62,8 @@ class TestPutawayRule(ERPNextTestCase):
|
||||
self.assertEqual(pr.items[1].qty, 100)
|
||||
self.assertEqual(pr.items[1].warehouse, self.warehouse_2)
|
||||
|
||||
self.assertUnchangedItemsOnResave(pr)
|
||||
|
||||
pr.delete()
|
||||
rule_1.delete()
|
||||
rule_2.delete()
|
||||
@@ -162,6 +176,8 @@ class TestPutawayRule(ERPNextTestCase):
|
||||
# leftover space was for 500 kg (0.5 Bag)
|
||||
# Since Bag is a whole UOM, 1(out of 2) Bag will be unassigned
|
||||
|
||||
self.assertUnchangedItemsOnResave(pr)
|
||||
|
||||
pr.delete()
|
||||
rule_1.delete()
|
||||
rule_2.delete()
|
||||
@@ -196,6 +212,8 @@ class TestPutawayRule(ERPNextTestCase):
|
||||
self.assertEqual(pr.items[1].warehouse, self.warehouse_1)
|
||||
self.assertEqual(pr.items[1].putaway_rule, rule_1.name)
|
||||
|
||||
self.assertUnchangedItemsOnResave(pr)
|
||||
|
||||
pr.delete()
|
||||
rule_1.delete()
|
||||
|
||||
@@ -239,6 +257,8 @@ class TestPutawayRule(ERPNextTestCase):
|
||||
self.assertEqual(stock_entry_item.qty, 100) # unassigned 100 out of 200 Kg
|
||||
self.assertEqual(stock_entry_item.putaway_rule, rule_2.name)
|
||||
|
||||
self.assertUnchangedItemsOnResave(stock_entry)
|
||||
|
||||
stock_entry.delete()
|
||||
rule_1.delete()
|
||||
rule_2.delete()
|
||||
@@ -294,6 +314,8 @@ class TestPutawayRule(ERPNextTestCase):
|
||||
self.assertEqual(stock_entry.items[2].qty, 200)
|
||||
self.assertEqual(stock_entry.items[2].putaway_rule, rule_2.name)
|
||||
|
||||
self.assertUnchangedItemsOnResave(stock_entry)
|
||||
|
||||
stock_entry.delete()
|
||||
rule_1.delete()
|
||||
rule_2.delete()
|
||||
@@ -344,6 +366,8 @@ class TestPutawayRule(ERPNextTestCase):
|
||||
self.assertEqual(stock_entry.items[1].serial_no, "\n".join(serial_nos[3:]))
|
||||
self.assertEqual(stock_entry.items[1].batch_no, "BOTTL-BATCH-1")
|
||||
|
||||
self.assertUnchangedItemsOnResave(stock_entry)
|
||||
|
||||
stock_entry.delete()
|
||||
pr.cancel()
|
||||
rule_1.delete()
|
||||
@@ -366,6 +390,8 @@ class TestPutawayRule(ERPNextTestCase):
|
||||
self.assertEqual(stock_entry_item.qty, 100)
|
||||
self.assertEqual(stock_entry_item.putaway_rule, rule_1.name)
|
||||
|
||||
self.assertUnchangedItemsOnResave(stock_entry)
|
||||
|
||||
stock_entry.delete()
|
||||
rule_1.delete()
|
||||
rule_2.delete()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -1116,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"]:
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"items",
|
||||
"section_break_9",
|
||||
"expense_account",
|
||||
"reconciliation_json",
|
||||
"column_break_13",
|
||||
"difference_amount",
|
||||
"amended_from",
|
||||
@@ -111,15 +110,6 @@
|
||||
"label": "Cost Center",
|
||||
"options": "Cost Center"
|
||||
},
|
||||
{
|
||||
"fieldname": "reconciliation_json",
|
||||
"fieldtype": "Long Text",
|
||||
"hidden": 1,
|
||||
"label": "Reconciliation JSON",
|
||||
"no_copy": 1,
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_13",
|
||||
"fieldtype": "Column Break"
|
||||
@@ -155,7 +145,7 @@
|
||||
"idx": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2021-11-30 01:33:51.437194",
|
||||
"modified": "2022-02-06 14:28:19.043905",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Stock Reconciliation",
|
||||
@@ -178,5 +168,6 @@
|
||||
"search_fields": "posting_date",
|
||||
"show_name_in_global_search": 1,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC"
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
@@ -25,8 +25,8 @@ from erpnext.tests.utils import ERPNextTestCase, change_settings
|
||||
class TestStockReconciliation(ERPNextTestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
create_batch_or_serial_no_items()
|
||||
super().setUpClass()
|
||||
frappe.db.set_value("Stock Settings", None, "allow_negative_stock", 1)
|
||||
|
||||
def tearDown(self):
|
||||
|
||||
@@ -344,6 +344,7 @@ def get_basic_details(args, item, overwrite_warehouse=True):
|
||||
|
||||
args.conversion_factor = out.conversion_factor
|
||||
out.stock_qty = out.qty * out.conversion_factor
|
||||
args.stock_qty = out.stock_qty
|
||||
|
||||
# calculate last purchase rate
|
||||
if args.get('doctype') in purchase_doctypes:
|
||||
|
||||
@@ -12,6 +12,7 @@ from frappe.utils import cint, date_diff, flt
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
|
||||
|
||||
Filters = frappe._dict
|
||||
precision = cint(frappe.db.get_single_value("System Settings", "float_precision"))
|
||||
|
||||
def execute(filters: Filters = None) -> Tuple:
|
||||
to_date = filters["to_date"]
|
||||
@@ -48,10 +49,13 @@ def format_report_data(filters: Filters, item_details: Dict, to_date: str) -> Li
|
||||
if filters.get("show_warehouse_wise_stock"):
|
||||
row.append(details.warehouse)
|
||||
|
||||
row.extend([item_dict.get("total_qty"), average_age,
|
||||
row.extend([
|
||||
flt(item_dict.get("total_qty"), precision),
|
||||
average_age,
|
||||
range1, range2, range3, above_range3,
|
||||
earliest_age, latest_age,
|
||||
details.stock_uom])
|
||||
details.stock_uom
|
||||
])
|
||||
|
||||
data.append(row)
|
||||
|
||||
@@ -79,13 +83,13 @@ def get_range_age(filters: Filters, fifo_queue: List, to_date: str, item_dict: D
|
||||
qty = flt(item[0]) if not item_dict["has_serial_no"] else 1.0
|
||||
|
||||
if age <= filters.range1:
|
||||
range1 += qty
|
||||
range1 = flt(range1 + qty, precision)
|
||||
elif age <= filters.range2:
|
||||
range2 += qty
|
||||
range2 = flt(range2 + qty, precision)
|
||||
elif age <= filters.range3:
|
||||
range3 += qty
|
||||
range3 = flt(range3 + qty, precision)
|
||||
else:
|
||||
above_range3 += qty
|
||||
above_range3 = flt(above_range3 + qty, precision)
|
||||
|
||||
return range1, range2, range3, above_range3
|
||||
|
||||
@@ -252,6 +256,7 @@ class FIFOSlots:
|
||||
key, fifo_queue, transferred_item_key = self.__init_key_stores(d)
|
||||
|
||||
if d.voucher_type == "Stock Reconciliation":
|
||||
# get difference in qty shift as actual qty
|
||||
prev_balance_qty = self.item_details[key].get("qty_after_transaction", 0)
|
||||
d.actual_qty = flt(d.qty_after_transaction) - flt(prev_balance_qty)
|
||||
|
||||
@@ -264,12 +269,16 @@ class FIFOSlots:
|
||||
|
||||
self.__update_balances(d, key)
|
||||
|
||||
if not self.filters.get("show_warehouse_wise_stock"):
|
||||
# (Item 1, WH 1), (Item 1, WH 2) => (Item 1)
|
||||
self.item_details = self.__aggregate_details_by_item(self.item_details)
|
||||
|
||||
return self.item_details
|
||||
|
||||
def __init_key_stores(self, row: Dict) -> Tuple:
|
||||
"Initialise keys and FIFO Queue."
|
||||
|
||||
key = (row.name, row.warehouse) if self.filters.get('show_warehouse_wise_stock') else row.name
|
||||
key = (row.name, row.warehouse)
|
||||
self.item_details.setdefault(key, {"details": row, "fifo_queue": []})
|
||||
fifo_queue = self.item_details[key]["fifo_queue"]
|
||||
|
||||
@@ -281,14 +290,16 @@ class FIFOSlots:
|
||||
def __compute_incoming_stock(self, row: Dict, fifo_queue: List, transfer_key: Tuple, serial_nos: List):
|
||||
"Update FIFO Queue on inward stock."
|
||||
|
||||
if self.transferred_item_details.get(transfer_key):
|
||||
transfer_data = self.transferred_item_details.get(transfer_key)
|
||||
if transfer_data:
|
||||
# inward/outward from same voucher, item & warehouse
|
||||
slot = self.transferred_item_details[transfer_key].pop(0)
|
||||
fifo_queue.append(slot)
|
||||
# eg: Repack with same item, Stock reco for batch item
|
||||
# consume transfer data and add stock to fifo queue
|
||||
self.__adjust_incoming_transfer_qty(transfer_data, fifo_queue, row)
|
||||
else:
|
||||
if not serial_nos:
|
||||
if fifo_queue and flt(fifo_queue[0][0]) < 0:
|
||||
# neutralize negative stock by adding positive stock
|
||||
if fifo_queue and flt(fifo_queue[0][0]) <= 0:
|
||||
# neutralize 0/negative stock by adding positive stock
|
||||
fifo_queue[0][0] += flt(row.actual_qty)
|
||||
fifo_queue[0][1] = row.posting_date
|
||||
else:
|
||||
@@ -319,7 +330,7 @@ class FIFOSlots:
|
||||
elif not fifo_queue:
|
||||
# negative stock, no balance but qty yet to consume
|
||||
fifo_queue.append([-(qty_to_pop), row.posting_date])
|
||||
self.transferred_item_details[transfer_key].append([row.actual_qty, row.posting_date])
|
||||
self.transferred_item_details[transfer_key].append([qty_to_pop, row.posting_date])
|
||||
qty_to_pop = 0
|
||||
else:
|
||||
# qty to pop < slot qty, ample balance
|
||||
@@ -328,6 +339,33 @@ class FIFOSlots:
|
||||
self.transferred_item_details[transfer_key].append([qty_to_pop, slot[1]])
|
||||
qty_to_pop = 0
|
||||
|
||||
def __adjust_incoming_transfer_qty(self, transfer_data: Dict, fifo_queue: List, row: Dict):
|
||||
"Add previously removed stock back to FIFO Queue."
|
||||
transfer_qty_to_pop = flt(row.actual_qty)
|
||||
|
||||
def add_to_fifo_queue(slot):
|
||||
if fifo_queue and flt(fifo_queue[0][0]) <= 0:
|
||||
# neutralize 0/negative stock by adding positive stock
|
||||
fifo_queue[0][0] += flt(slot[0])
|
||||
fifo_queue[0][1] = slot[1]
|
||||
else:
|
||||
fifo_queue.append(slot)
|
||||
|
||||
while transfer_qty_to_pop:
|
||||
if transfer_data and 0 < transfer_data[0][0] <= transfer_qty_to_pop:
|
||||
# bucket qty is not enough, consume whole
|
||||
transfer_qty_to_pop -= transfer_data[0][0]
|
||||
add_to_fifo_queue(transfer_data.pop(0))
|
||||
elif not transfer_data:
|
||||
# transfer bucket is empty, extra incoming qty
|
||||
add_to_fifo_queue([transfer_qty_to_pop, row.posting_date])
|
||||
transfer_qty_to_pop = 0
|
||||
else:
|
||||
# ample bucket qty to consume
|
||||
transfer_data[0][0] -= transfer_qty_to_pop
|
||||
add_to_fifo_queue([transfer_qty_to_pop, transfer_data[0][1]])
|
||||
transfer_qty_to_pop = 0
|
||||
|
||||
def __update_balances(self, row: Dict, key: Union[Tuple, str]):
|
||||
self.item_details[key]["qty_after_transaction"] = row.qty_after_transaction
|
||||
|
||||
@@ -338,6 +376,27 @@ class FIFOSlots:
|
||||
|
||||
self.item_details[key]["has_serial_no"] = row.has_serial_no
|
||||
|
||||
def __aggregate_details_by_item(self, wh_wise_data: Dict) -> Dict:
|
||||
"Aggregate Item-Wh wise data into single Item entry."
|
||||
item_aggregated_data = {}
|
||||
for key,row in wh_wise_data.items():
|
||||
item = key[0]
|
||||
if not item_aggregated_data.get(item):
|
||||
item_aggregated_data.setdefault(item, {
|
||||
"details": frappe._dict(),
|
||||
"fifo_queue": [],
|
||||
"qty_after_transaction": 0.0,
|
||||
"total_qty": 0.0
|
||||
})
|
||||
item_row = item_aggregated_data.get(item)
|
||||
item_row["details"].update(row["details"])
|
||||
item_row["fifo_queue"].extend(row["fifo_queue"])
|
||||
item_row["qty_after_transaction"] += flt(row["qty_after_transaction"])
|
||||
item_row["total_qty"] += flt(row["total_qty"])
|
||||
item_row["has_serial_no"] = row["has_serial_no"]
|
||||
|
||||
return item_aggregated_data
|
||||
|
||||
def __get_stock_ledger_entries(self) -> List[Dict]:
|
||||
sle = frappe.qb.DocType("Stock Ledger Entry")
|
||||
item = self.__get_item_query() # used as derived table in sle query
|
||||
|
||||
@@ -15,6 +15,7 @@ Here, the balance qty is 70.
|
||||
50 qty is (today-the 1st) days old
|
||||
20 qty is (today-the 2nd) days old
|
||||
|
||||
> Note: We generate FIFO slots warehouse wise as stock reconciliations from different warehouses can cause incorrect values.
|
||||
### Calculation of FIFO Slots
|
||||
|
||||
#### Case 1: Outward from sufficient balance qty
|
||||
@@ -70,4 +71,39 @@ Date | Qty | Queue
|
||||
2nd | -60 | [[-10, 1-12-2021]]
|
||||
3rd | +5 | [[-5, 3-12-2021]]
|
||||
4th | +10 | [[5, 4-12-2021]]
|
||||
4th | +20 | [[5, 4-12-2021], [20, 4-12-2021]]
|
||||
4th | +20 | [[5, 4-12-2021], [20, 4-12-2021]]
|
||||
|
||||
### Concept of Transfer Qty Bucket
|
||||
In the case of **Repack**, Quantity that comes in, isn't really incoming. It is just new stock repurposed from old stock, due to incoming-outgoing of the same warehouse.
|
||||
|
||||
Here, stock is consumed from the FIFO Queue. It is then re-added back to the queue.
|
||||
While adding stock back to the queue we need to know how much to add.
|
||||
For this we need to keep track of how much was previously consumed.
|
||||
Hence we use **Transfer Qty Bucket**.
|
||||
|
||||
While re-adding stock, we try to add buckets that were consumed earlier (date intact), to maintain correctness.
|
||||
|
||||
#### Case 1: Same Item-Warehouse in Repack
|
||||
Eg:
|
||||
-------------------------------------------------------------------------------------
|
||||
Date | Qty | Voucher | FIFO Queue | Transfer Qty Buckets
|
||||
-------------------------------------------------------------------------------------
|
||||
1st | +500 | PR | [[500, 1-12-2021]] |
|
||||
2nd | -50 | Repack | [[450, 1-12-2021]] | [[50, 1-12-2021]]
|
||||
2nd | +50 | Repack | [[450, 1-12-2021], [50, 1-12-2021]] | []
|
||||
|
||||
- The balance at the end is restored back to 500
|
||||
- However, the initial 500 qty bucket is now split into 450 and 50, with the same date
|
||||
- The net effect is the same as that before the Repack
|
||||
|
||||
#### Case 2: Same Item-Warehouse in Repack with Split Consumption rows
|
||||
Eg:
|
||||
-------------------------------------------------------------------------------------
|
||||
Date | Qty | Voucher | FIFO Queue | Transfer Qty Buckets
|
||||
-------------------------------------------------------------------------------------
|
||||
1st | +500 | PR | [[500, 1-12-2021]] |
|
||||
2nd | -50 | Repack | [[450, 1-12-2021]] | [[50, 1-12-2021]]
|
||||
2nd | -50 | Repack | [[400, 1-12-2021]] | [[50, 1-12-2021],
|
||||
- | | | |[50, 1-12-2021]]
|
||||
2nd | +100 | Repack | [[400, 1-12-2021], [50, 1-12-2021], | []
|
||||
- | | | [50, 1-12-2021]] |
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots
|
||||
from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots, format_report_data
|
||||
from erpnext.tests.utils import ERPNextTestCase
|
||||
|
||||
|
||||
@@ -11,15 +11,17 @@ class TestStockAgeing(ERPNextTestCase):
|
||||
def setUp(self) -> None:
|
||||
self.filters = frappe._dict(
|
||||
company="_Test Company",
|
||||
to_date="2021-12-10"
|
||||
to_date="2021-12-10",
|
||||
range1=30, range2=60, range3=90
|
||||
)
|
||||
|
||||
def test_normal_inward_outward_queue(self):
|
||||
"Reference: Case 1 in stock_ageing_fifo_logic.md"
|
||||
"Reference: Case 1 in stock_ageing_fifo_logic.md (same wh)"
|
||||
sle = [
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=30, qty_after_transaction=30,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-01", voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False, serial_no=None
|
||||
@@ -27,6 +29,7 @@ class TestStockAgeing(ERPNextTestCase):
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=20, qty_after_transaction=50,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-02", voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False, serial_no=None
|
||||
@@ -34,6 +37,7 @@ class TestStockAgeing(ERPNextTestCase):
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=(-10), qty_after_transaction=40,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-03", voucher_type="Stock Entry",
|
||||
voucher_no="003",
|
||||
has_serial_no=False, serial_no=None
|
||||
@@ -50,11 +54,12 @@ class TestStockAgeing(ERPNextTestCase):
|
||||
self.assertEqual(queue[0][0], 20.0)
|
||||
|
||||
def test_insufficient_balance(self):
|
||||
"Reference: Case 3 in stock_ageing_fifo_logic.md"
|
||||
"Reference: Case 3 in stock_ageing_fifo_logic.md (same wh)"
|
||||
sle = [
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=(-30), qty_after_transaction=(-30),
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-01", voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False, serial_no=None
|
||||
@@ -62,6 +67,7 @@ class TestStockAgeing(ERPNextTestCase):
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=20, qty_after_transaction=(-10),
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-02", voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False, serial_no=None
|
||||
@@ -69,6 +75,7 @@ class TestStockAgeing(ERPNextTestCase):
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=20, qty_after_transaction=10,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-03", voucher_type="Stock Entry",
|
||||
voucher_no="003",
|
||||
has_serial_no=False, serial_no=None
|
||||
@@ -76,6 +83,7 @@ class TestStockAgeing(ERPNextTestCase):
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=10, qty_after_transaction=20,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-03", voucher_type="Stock Entry",
|
||||
voucher_no="004",
|
||||
has_serial_no=False, serial_no=None
|
||||
@@ -91,11 +99,16 @@ class TestStockAgeing(ERPNextTestCase):
|
||||
self.assertEqual(queue[0][0], 10.0)
|
||||
self.assertEqual(queue[1][0], 10.0)
|
||||
|
||||
def test_stock_reconciliation(self):
|
||||
def test_basic_stock_reconciliation(self):
|
||||
"""
|
||||
Ledger (same wh): [+30, reco reset >> 50, -10]
|
||||
Bal: 40
|
||||
"""
|
||||
sle = [
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=30, qty_after_transaction=30,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-01", voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False, serial_no=None
|
||||
@@ -103,6 +116,7 @@ class TestStockAgeing(ERPNextTestCase):
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=0, qty_after_transaction=50,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-02", voucher_type="Stock Reconciliation",
|
||||
voucher_no="002",
|
||||
has_serial_no=False, serial_no=None
|
||||
@@ -110,6 +124,7 @@ class TestStockAgeing(ERPNextTestCase):
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=(-10), qty_after_transaction=40,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-03", voucher_type="Stock Entry",
|
||||
voucher_no="003",
|
||||
has_serial_no=False, serial_no=None
|
||||
@@ -122,5 +137,477 @@ class TestStockAgeing(ERPNextTestCase):
|
||||
queue = result["fifo_queue"]
|
||||
|
||||
self.assertEqual(result["qty_after_transaction"], result["total_qty"])
|
||||
self.assertEqual(result["total_qty"], 40.0)
|
||||
self.assertEqual(queue[0][0], 20.0)
|
||||
self.assertEqual(queue[1][0], 20.0)
|
||||
|
||||
def test_sequential_stock_reco_same_warehouse(self):
|
||||
"""
|
||||
Test back to back stock recos (same warehouse).
|
||||
Ledger: [reco opening >> +1000, reco reset >> 400, -10]
|
||||
Bal: 390
|
||||
"""
|
||||
sle = [
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=0, qty_after_transaction=1000,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-01", voucher_type="Stock Reconciliation",
|
||||
voucher_no="002",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=0, qty_after_transaction=400,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-02", voucher_type="Stock Reconciliation",
|
||||
voucher_no="003",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=(-10), qty_after_transaction=390,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-03", voucher_type="Stock Entry",
|
||||
voucher_no="003",
|
||||
has_serial_no=False, serial_no=None
|
||||
)
|
||||
]
|
||||
slots = FIFOSlots(self.filters, sle).generate()
|
||||
|
||||
result = slots["Flask Item"]
|
||||
queue = result["fifo_queue"]
|
||||
|
||||
self.assertEqual(result["qty_after_transaction"], result["total_qty"])
|
||||
self.assertEqual(result["total_qty"], 390.0)
|
||||
self.assertEqual(queue[0][0], 390.0)
|
||||
|
||||
def test_sequential_stock_reco_different_warehouse(self):
|
||||
"""
|
||||
Ledger:
|
||||
WH | Voucher | Qty
|
||||
-------------------
|
||||
WH1 | Reco | 1000
|
||||
WH2 | Reco | 400
|
||||
WH1 | SE | -10
|
||||
|
||||
Bal: WH1 bal + WH2 bal = 990 + 400 = 1390
|
||||
"""
|
||||
sle = [
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=0, qty_after_transaction=1000,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-01", voucher_type="Stock Reconciliation",
|
||||
voucher_no="002",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=0, qty_after_transaction=400,
|
||||
warehouse="WH 2",
|
||||
posting_date="2021-12-02", voucher_type="Stock Reconciliation",
|
||||
voucher_no="003",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=(-10), qty_after_transaction=990,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-03", voucher_type="Stock Entry",
|
||||
voucher_no="004",
|
||||
has_serial_no=False, serial_no=None
|
||||
)
|
||||
]
|
||||
|
||||
item_wise_slots, item_wh_wise_slots = generate_item_and_item_wh_wise_slots(
|
||||
filters=self.filters,sle=sle
|
||||
)
|
||||
|
||||
# test without 'show_warehouse_wise_stock'
|
||||
item_result = item_wise_slots["Flask Item"]
|
||||
queue = item_result["fifo_queue"]
|
||||
|
||||
self.assertEqual(item_result["qty_after_transaction"], item_result["total_qty"])
|
||||
self.assertEqual(item_result["total_qty"], 1390.0)
|
||||
self.assertEqual(queue[0][0], 990.0)
|
||||
self.assertEqual(queue[1][0], 400.0)
|
||||
|
||||
# test with 'show_warehouse_wise_stock' checked
|
||||
item_wh_balances = [item_wh_wise_slots.get(i).get("qty_after_transaction") for i in item_wh_wise_slots]
|
||||
self.assertEqual(sum(item_wh_balances), item_result["qty_after_transaction"])
|
||||
|
||||
def test_repack_entry_same_item_split_rows(self):
|
||||
"""
|
||||
Split consumption rows and have single repacked item row (same warehouse).
|
||||
Ledger:
|
||||
Item | Qty | Voucher
|
||||
------------------------
|
||||
Item 1 | 500 | 001
|
||||
Item 1 | -50 | 002 (repack)
|
||||
Item 1 | -50 | 002 (repack)
|
||||
Item 1 | 100 | 002 (repack)
|
||||
|
||||
Case most likely for batch items. Test time bucket computation.
|
||||
"""
|
||||
sle = [
|
||||
frappe._dict( # stock up item
|
||||
name="Flask Item",
|
||||
actual_qty=500, qty_after_transaction=500,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-03", voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=(-50), qty_after_transaction=450,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-04", voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=(-50), qty_after_transaction=400,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-04", voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=100, qty_after_transaction=500,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-04", voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
]
|
||||
slots = FIFOSlots(self.filters, sle).generate()
|
||||
item_result = slots["Flask Item"]
|
||||
queue = item_result["fifo_queue"]
|
||||
|
||||
self.assertEqual(item_result["total_qty"], 500.0)
|
||||
self.assertEqual(queue[0][0], 400.0)
|
||||
self.assertEqual(queue[1][0], 50.0)
|
||||
self.assertEqual(queue[2][0], 50.0)
|
||||
# check if time buckets add up to balance qty
|
||||
self.assertEqual(sum([i[0] for i in queue]), 500.0)
|
||||
|
||||
def test_repack_entry_same_item_overconsume(self):
|
||||
"""
|
||||
Over consume item and have less repacked item qty (same warehouse).
|
||||
Ledger:
|
||||
Item | Qty | Voucher
|
||||
------------------------
|
||||
Item 1 | 500 | 001
|
||||
Item 1 | -100 | 002 (repack)
|
||||
Item 1 | 50 | 002 (repack)
|
||||
|
||||
Case most likely for batch items. Test time bucket computation.
|
||||
"""
|
||||
sle = [
|
||||
frappe._dict( # stock up item
|
||||
name="Flask Item",
|
||||
actual_qty=500, qty_after_transaction=500,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-03", voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=(-100), qty_after_transaction=400,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-04", voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=50, qty_after_transaction=450,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-04", voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
]
|
||||
slots = FIFOSlots(self.filters, sle).generate()
|
||||
item_result = slots["Flask Item"]
|
||||
queue = item_result["fifo_queue"]
|
||||
|
||||
self.assertEqual(item_result["total_qty"], 450.0)
|
||||
self.assertEqual(queue[0][0], 400.0)
|
||||
self.assertEqual(queue[1][0], 50.0)
|
||||
# check if time buckets add up to balance qty
|
||||
self.assertEqual(sum([i[0] for i in queue]), 450.0)
|
||||
|
||||
def test_repack_entry_same_item_overconsume_with_split_rows(self):
|
||||
"""
|
||||
Over consume item and have less repacked item qty (same warehouse).
|
||||
Ledger:
|
||||
Item | Qty | Voucher
|
||||
------------------------
|
||||
Item 1 | 20 | 001
|
||||
Item 1 | -50 | 002 (repack)
|
||||
Item 1 | -50 | 002 (repack)
|
||||
Item 1 | 50 | 002 (repack)
|
||||
"""
|
||||
sle = [
|
||||
frappe._dict( # stock up item
|
||||
name="Flask Item",
|
||||
actual_qty=20, qty_after_transaction=20,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-03", voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=(-50), qty_after_transaction=(-30),
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-04", voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=(-50), qty_after_transaction=(-80),
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-04", voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=50, qty_after_transaction=(-30),
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-04", voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
]
|
||||
fifo_slots = FIFOSlots(self.filters, sle)
|
||||
slots = fifo_slots.generate()
|
||||
item_result = slots["Flask Item"]
|
||||
queue = item_result["fifo_queue"]
|
||||
|
||||
self.assertEqual(item_result["total_qty"], -30.0)
|
||||
self.assertEqual(queue[0][0], -30.0)
|
||||
|
||||
# check transfer bucket
|
||||
transfer_bucket = fifo_slots.transferred_item_details[('002', 'Flask Item', 'WH 1')]
|
||||
self.assertEqual(transfer_bucket[0][0], 50)
|
||||
|
||||
def test_repack_entry_same_item_overproduce(self):
|
||||
"""
|
||||
Under consume item and have more repacked item qty (same warehouse).
|
||||
Ledger:
|
||||
Item | Qty | Voucher
|
||||
------------------------
|
||||
Item 1 | 500 | 001
|
||||
Item 1 | -50 | 002 (repack)
|
||||
Item 1 | 100 | 002 (repack)
|
||||
|
||||
Case most likely for batch items. Test time bucket computation.
|
||||
"""
|
||||
sle = [
|
||||
frappe._dict( # stock up item
|
||||
name="Flask Item",
|
||||
actual_qty=500, qty_after_transaction=500,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-03", voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=(-50), qty_after_transaction=450,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-04", voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=100, qty_after_transaction=550,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-04", voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
]
|
||||
slots = FIFOSlots(self.filters, sle).generate()
|
||||
item_result = slots["Flask Item"]
|
||||
queue = item_result["fifo_queue"]
|
||||
|
||||
self.assertEqual(item_result["total_qty"], 550.0)
|
||||
self.assertEqual(queue[0][0], 450.0)
|
||||
self.assertEqual(queue[1][0], 50.0)
|
||||
self.assertEqual(queue[2][0], 50.0)
|
||||
# check if time buckets add up to balance qty
|
||||
self.assertEqual(sum([i[0] for i in queue]), 550.0)
|
||||
|
||||
def test_repack_entry_same_item_overproduce_with_split_rows(self):
|
||||
"""
|
||||
Over consume item and have less repacked item qty (same warehouse).
|
||||
Ledger:
|
||||
Item | Qty | Voucher
|
||||
------------------------
|
||||
Item 1 | 20 | 001
|
||||
Item 1 | -50 | 002 (repack)
|
||||
Item 1 | 50 | 002 (repack)
|
||||
Item 1 | 50 | 002 (repack)
|
||||
"""
|
||||
sle = [
|
||||
frappe._dict( # stock up item
|
||||
name="Flask Item",
|
||||
actual_qty=20, qty_after_transaction=20,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-03", voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=(-50), qty_after_transaction=(-30),
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-04", voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=50, qty_after_transaction=20,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-04", voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=50, qty_after_transaction=70,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-04", voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
]
|
||||
fifo_slots = FIFOSlots(self.filters, sle)
|
||||
slots = fifo_slots.generate()
|
||||
item_result = slots["Flask Item"]
|
||||
queue = item_result["fifo_queue"]
|
||||
|
||||
self.assertEqual(item_result["total_qty"], 70.0)
|
||||
self.assertEqual(queue[0][0], 20.0)
|
||||
self.assertEqual(queue[1][0], 50.0)
|
||||
|
||||
# check transfer bucket
|
||||
transfer_bucket = fifo_slots.transferred_item_details[('002', 'Flask Item', 'WH 1')]
|
||||
self.assertFalse(transfer_bucket)
|
||||
|
||||
def test_negative_stock_same_voucher(self):
|
||||
"""
|
||||
Test negative stock scenario in transfer bucket via repack entry (same wh).
|
||||
Ledger:
|
||||
Item | Qty | Voucher
|
||||
------------------------
|
||||
Item 1 | -50 | 001
|
||||
Item 1 | -50 | 001
|
||||
Item 1 | 30 | 001
|
||||
Item 1 | 80 | 001
|
||||
"""
|
||||
sle = [
|
||||
frappe._dict( # stock up item
|
||||
name="Flask Item",
|
||||
actual_qty=(-50), qty_after_transaction=(-50),
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-01", voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
frappe._dict( # stock up item
|
||||
name="Flask Item",
|
||||
actual_qty=(-50), qty_after_transaction=(-100),
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-01", voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
frappe._dict( # stock up item
|
||||
name="Flask Item",
|
||||
actual_qty=30, qty_after_transaction=(-70),
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-01", voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
]
|
||||
fifo_slots = FIFOSlots(self.filters, sle)
|
||||
slots = fifo_slots.generate()
|
||||
item_result = slots["Flask Item"]
|
||||
|
||||
# check transfer bucket
|
||||
transfer_bucket = fifo_slots.transferred_item_details[('001', 'Flask Item', 'WH 1')]
|
||||
self.assertEqual(transfer_bucket[0][0], 20)
|
||||
self.assertEqual(transfer_bucket[1][0], 50)
|
||||
self.assertEqual(item_result["fifo_queue"][0][0], -70.0)
|
||||
|
||||
sle.append(frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=80, qty_after_transaction=10,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-01", voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False, serial_no=None
|
||||
))
|
||||
|
||||
fifo_slots = FIFOSlots(self.filters, sle)
|
||||
slots = fifo_slots.generate()
|
||||
item_result = slots["Flask Item"]
|
||||
|
||||
transfer_bucket = fifo_slots.transferred_item_details[('001', 'Flask Item', 'WH 1')]
|
||||
self.assertFalse(transfer_bucket)
|
||||
self.assertEqual(item_result["fifo_queue"][0][0], 10.0)
|
||||
|
||||
def test_precision(self):
|
||||
"Test if final balance qty is rounded off correctly."
|
||||
sle = [
|
||||
frappe._dict( # stock up item
|
||||
name="Flask Item",
|
||||
actual_qty=0.3, qty_after_transaction=0.3,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-01", voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
frappe._dict( # stock up item
|
||||
name="Flask Item",
|
||||
actual_qty=0.6, qty_after_transaction=0.9,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-01", voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
]
|
||||
|
||||
slots = FIFOSlots(self.filters, sle).generate()
|
||||
report_data = format_report_data(self.filters, slots, self.filters["to_date"])
|
||||
row = report_data[0] # first row in report
|
||||
bal_qty = row[5]
|
||||
range_qty_sum = sum([i for i in row[7:11]]) # get sum of range balance
|
||||
|
||||
# check if value of Available Qty column matches with range bucket post format
|
||||
self.assertEqual(bal_qty, 0.9)
|
||||
self.assertEqual(bal_qty, range_qty_sum)
|
||||
|
||||
def generate_item_and_item_wh_wise_slots(filters, sle):
|
||||
"Return results with and without 'show_warehouse_wise_stock'"
|
||||
item_wise_slots = FIFOSlots(filters, sle).generate()
|
||||
|
||||
filters.show_warehouse_wise_stock = True
|
||||
item_wh_wise_slots = FIFOSlots(filters, sle).generate()
|
||||
filters.show_warehouse_wise_stock = False
|
||||
|
||||
return item_wise_slots, item_wh_wise_slots
|
||||
@@ -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()
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
# 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
|
||||
from erpnext.tests.utils import ERPNextTestCase
|
||||
|
||||
|
||||
class TestPointOfSale(ERPNextTestCase):
|
||||
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.
|
||||
|
||||
@@ -13,6 +13,12 @@ frappe.ui.form.on("Rename Tool", {
|
||||
},
|
||||
refresh: function(frm) {
|
||||
frm.disable_save();
|
||||
|
||||
frm.get_field("file_to_rename").df.options = {
|
||||
restrictions: {
|
||||
allowed_file_types: [".csv"],
|
||||
},
|
||||
};
|
||||
if (!frm.doc.file_to_rename) {
|
||||
frm.get_field("rename_log").$wrapper.html("");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user