Merge branch 'develop' into leave-policy-assgn-fixes

This commit is contained in:
Rucha Mahabal 2022-02-11 21:00:49 +05:30 committed by GitHub
commit 10ee2fbeac
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 779 additions and 193 deletions

View File

@ -14,6 +14,10 @@ frappe.ui.form.on("Bank Reconciliation Tool", {
}); });
}, },
onload: function (frm) {
frm.trigger('bank_account');
},
refresh: function (frm) { refresh: function (frm) {
frappe.require("bank-reconciliation-tool.bundle.js", () => frappe.require("bank-reconciliation-tool.bundle.js", () =>
frm.trigger("make_reconciliation_tool") frm.trigger("make_reconciliation_tool")
@ -51,7 +55,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", {
bank_account: function (frm) { bank_account: function (frm) {
frappe.db.get_value( frappe.db.get_value(
"Bank Account", "Bank Account",
frm.bank_account, frm.doc.bank_account,
"account", "account",
(r) => { (r) => {
frappe.db.get_value( frappe.db.get_value(

View File

@ -586,23 +586,29 @@ class TestPOSInvoice(unittest.TestCase):
item_price.insert() item_price.insert()
pr = make_pricing_rule(selling=1, priority=5, discount_percentage=10) pr = make_pricing_rule(selling=1, priority=5, discount_percentage=10)
pr.save() pr.save()
pos_inv = create_pos_invoice(qty=1, do_not_submit=1)
pos_inv.items[0].rate = 300
pos_inv.save()
self.assertEquals(pos_inv.items[0].discount_percentage, 10)
# rate shouldn't change
self.assertEquals(pos_inv.items[0].rate, 405)
pos_inv.ignore_pricing_rule = 1 try:
pos_inv.items[0].rate = 300 pos_inv = create_pos_invoice(qty=1, do_not_submit=1)
pos_inv.save() pos_inv.items[0].rate = 300
self.assertEquals(pos_inv.ignore_pricing_rule, 1) pos_inv.save()
# rate should change since pricing rules are ignored self.assertEquals(pos_inv.items[0].discount_percentage, 10)
self.assertEquals(pos_inv.items[0].rate, 300) # rate shouldn't change
self.assertEquals(pos_inv.items[0].rate, 405)
item_price.delete() pos_inv.ignore_pricing_rule = 1
pos_inv.delete() pos_inv.save()
pr.delete() self.assertEquals(pos_inv.ignore_pricing_rule, 1)
# rate should reset since pricing rules are ignored
self.assertEquals(pos_inv.items[0].rate, 450)
pos_inv.items[0].rate = 300
pos_inv.save()
self.assertEquals(pos_inv.items[0].rate, 300)
finally:
item_price.delete()
pos_inv.delete()
pr.delete()
def create_pos_invoice(**args): def create_pos_invoice(**args):

View File

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

View File

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

View File

@ -249,13 +249,17 @@ def get_pricing_rule_for_item(args, price_list_rate=0, doc=None, for_validate=Fa
"free_item_data": [], "free_item_data": [],
"parent": args.parent, "parent": args.parent,
"parenttype": args.parenttype, "parenttype": args.parenttype,
"child_docname": args.get('child_docname') "child_docname": args.get('child_docname'),
}) })
if args.ignore_pricing_rule or not args.item_code: if args.ignore_pricing_rule or not args.item_code:
if frappe.db.exists(args.doctype, args.name) and args.get("pricing_rules"): if frappe.db.exists(args.doctype, args.name) and args.get("pricing_rules"):
item_details = remove_pricing_rule_for_item(args.get("pricing_rules"), item_details = remove_pricing_rule_for_item(
item_details, args.get('item_code')) args.get("pricing_rules"),
item_details,
item_code=args.get("item_code"),
rate=args.get("price_list_rate"),
)
return item_details return item_details
update_args_for_pricing_rule(args) update_args_for_pricing_rule(args)
@ -308,8 +312,12 @@ def get_pricing_rule_for_item(args, price_list_rate=0, doc=None, for_validate=Fa
if not doc: return item_details if not doc: return item_details
elif args.get("pricing_rules"): elif args.get("pricing_rules"):
item_details = remove_pricing_rule_for_item(args.get("pricing_rules"), item_details = remove_pricing_rule_for_item(
item_details, args.get('item_code')) args.get("pricing_rules"),
item_details,
item_code=args.get("item_code"),
rate=args.get("price_list_rate"),
)
return item_details return item_details
@ -390,7 +398,7 @@ def apply_price_discount_rule(pricing_rule, item_details, args):
item_details[field] += (pricing_rule.get(field, 0) item_details[field] += (pricing_rule.get(field, 0)
if pricing_rule else args.get(field, 0)) if pricing_rule else args.get(field, 0))
def remove_pricing_rule_for_item(pricing_rules, item_details, item_code=None): def remove_pricing_rule_for_item(pricing_rules, item_details, item_code=None, rate=None):
from erpnext.accounts.doctype.pricing_rule.utils import ( from erpnext.accounts.doctype.pricing_rule.utils import (
get_applied_pricing_rules, get_applied_pricing_rules,
get_pricing_rule_items, get_pricing_rule_items,
@ -403,6 +411,7 @@ def remove_pricing_rule_for_item(pricing_rules, item_details, item_code=None):
if pricing_rule.rate_or_discount == 'Discount Percentage': if pricing_rule.rate_or_discount == 'Discount Percentage':
item_details.discount_percentage = 0.0 item_details.discount_percentage = 0.0
item_details.discount_amount = 0.0 item_details.discount_amount = 0.0
item_details.rate = rate or 0.0
if pricing_rule.rate_or_discount == 'Discount Amount': if pricing_rule.rate_or_discount == 'Discount Amount':
item_details.discount_amount = 0.0 item_details.discount_amount = 0.0
@ -421,6 +430,7 @@ def remove_pricing_rule_for_item(pricing_rules, item_details, item_code=None):
item_details.applied_on_items = ','.join(items) item_details.applied_on_items = ','.join(items)
item_details.pricing_rules = '' item_details.pricing_rules = ''
item_details.pricing_rule_removed = True
return item_details return item_details
@ -432,9 +442,12 @@ def remove_pricing_rules(item_list):
out = [] out = []
for item in item_list: for item in item_list:
item = frappe._dict(item) item = frappe._dict(item)
if item.get('pricing_rules'): if item.get("pricing_rules"):
out.append(remove_pricing_rule_for_item(item.get("pricing_rules"), out.append(
item, item.item_code)) remove_pricing_rule_for_item(
item.get("pricing_rules"), item, item.item_code, item.get("price_list_rate")
)
)
return out return out

View File

@ -628,6 +628,46 @@ class TestPricingRule(unittest.TestCase):
for doc in [si, si1]: for doc in [si, si1]:
doc.delete() doc.delete()
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()
def test_multiple_pricing_rules_with_min_qty(self): def test_multiple_pricing_rules_with_min_qty(self):
make_pricing_rule(discount_percentage=20, selling=1, priority=1, min_qty=4, make_pricing_rule(discount_percentage=20, selling=1, priority=1, min_qty=4,
apply_multiple_pricing_rules=1, title="_Test Pricing Rule with Min Qty - 1") apply_multiple_pricing_rules=1, title="_Test Pricing Rule with Min Qty - 1")
@ -648,6 +688,7 @@ class TestPricingRule(unittest.TestCase):
frappe.delete_doc_if_exists("Pricing Rule", "_Test Pricing Rule with Min Qty - 1") frappe.delete_doc_if_exists("Pricing Rule", "_Test Pricing Rule with Min Qty - 1")
frappe.delete_doc_if_exists("Pricing Rule", "_Test Pricing Rule with Min Qty - 2") frappe.delete_doc_if_exists("Pricing Rule", "_Test Pricing Rule with Min Qty - 2")
test_dependencies = ["Campaign"] test_dependencies = ["Campaign"]
def make_pricing_rule(**args): def make_pricing_rule(**args):

View File

@ -285,7 +285,7 @@ class SalesInvoice(SellingController):
filters={ invoice_or_credit_note: self.name }, filters={ invoice_or_credit_note: self.name },
pluck="pos_closing_entry" pluck="pos_closing_entry"
) )
if pos_closing_entry: if pos_closing_entry and pos_closing_entry[0]:
msg = _("To cancel a {} you need to cancel the POS Closing Entry {}.").format( msg = _("To cancel a {} you need to cancel the POS Closing Entry {}.").format(
frappe.bold("Consolidated Sales Invoice"), frappe.bold("Consolidated Sales Invoice"),
get_link_to_form("POS Closing Entry", pos_closing_entry[0]) get_link_to_form("POS Closing Entry", pos_closing_entry[0])

View File

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

View File

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

View File

@ -407,6 +407,22 @@ class AccountsController(TransactionBase):
if item_qty != len(get_serial_nos(item.get('serial_no'))): if item_qty != len(get_serial_nos(item.get('serial_no'))):
item.set(fieldname, value) item.set(fieldname, value)
elif (
ret.get("pricing_rule_removed")
and value is not None
and fieldname
in [
"discount_percentage",
"discount_amount",
"rate",
"margin_rate_or_amount",
"margin_type",
"remove_free_item",
]
):
# reset pricing rule fields if pricing_rule_removed
item.set(fieldname, value)
if self.doctype in ["Purchase Invoice", "Sales Invoice"] and item.meta.get_field('is_fixed_asset'): if self.doctype in ["Purchase Invoice", "Sales Invoice"] and item.meta.get_field('is_fixed_asset'):
item.set('is_fixed_asset', ret.get('is_fixed_asset', 0)) item.set('is_fixed_asset', ret.get('is_fixed_asset', 0))

View File

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

View File

@ -341,6 +341,7 @@ class ProductionPlan(Document):
def get_production_items(self): def get_production_items(self):
item_dict = {} item_dict = {}
for d in self.po_items: for d in self.po_items:
item_details = { item_details = {
"production_item" : d.item_code, "production_item" : d.item_code,
@ -357,12 +358,12 @@ class ProductionPlan(Document):
"production_plan" : self.name, "production_plan" : self.name,
"production_plan_item" : d.name, "production_plan_item" : d.name,
"product_bundle_item" : d.product_bundle_item, "product_bundle_item" : d.product_bundle_item,
"planned_start_date" : d.planned_start_date "planned_start_date" : d.planned_start_date,
"project" : self.project
} }
item_details.update({ if not item_details['project'] and d.sales_order:
"project": self.project or frappe.db.get_value("Sales Order", d.sales_order, "project") item_details['project'] = frappe.get_cached_value("Sales Order", d.sales_order, "project")
})
if self.get_items_from == "Material Request": if self.get_items_from == "Material Request":
item_details.update({ item_details.update({
@ -380,39 +381,59 @@ class ProductionPlan(Document):
@frappe.whitelist() @frappe.whitelist()
def make_work_order(self): def make_work_order(self):
from erpnext.manufacturing.doctype.work_order.work_order import get_default_warehouse
wo_list, po_list = [], [] wo_list, po_list = [], []
subcontracted_po = {} subcontracted_po = {}
default_warehouses = get_default_warehouse()
self.validate_data() self.make_work_order_for_finished_goods(wo_list, default_warehouses)
self.make_work_order_for_finished_goods(wo_list) self.make_work_order_for_subassembly_items(wo_list, subcontracted_po, default_warehouses)
self.make_work_order_for_subassembly_items(wo_list, subcontracted_po)
self.make_subcontracted_purchase_order(subcontracted_po, po_list) self.make_subcontracted_purchase_order(subcontracted_po, po_list)
self.show_list_created_message('Work Order', wo_list) self.show_list_created_message('Work Order', wo_list)
self.show_list_created_message('Purchase Order', po_list) self.show_list_created_message('Purchase Order', po_list)
def make_work_order_for_finished_goods(self, wo_list): def make_work_order_for_finished_goods(self, wo_list, default_warehouses):
items_data = self.get_production_items() items_data = self.get_production_items()
for key, item in items_data.items(): for key, item in items_data.items():
if self.sub_assembly_items: if self.sub_assembly_items:
item['use_multi_level_bom'] = 0 item['use_multi_level_bom'] = 0
set_default_warehouses(item, default_warehouses)
work_order = self.create_work_order(item) work_order = self.create_work_order(item)
if work_order: if work_order:
wo_list.append(work_order) wo_list.append(work_order)
def make_work_order_for_subassembly_items(self, wo_list, subcontracted_po): def make_work_order_for_subassembly_items(self, wo_list, subcontracted_po, default_warehouses):
for row in self.sub_assembly_items: for row in self.sub_assembly_items:
if row.type_of_manufacturing == 'Subcontract': if row.type_of_manufacturing == 'Subcontract':
subcontracted_po.setdefault(row.supplier, []).append(row) subcontracted_po.setdefault(row.supplier, []).append(row)
continue continue
args = {} work_order_data = {
self.prepare_args_for_sub_assembly_items(row, args) 'wip_warehouse': default_warehouses.get('wip_warehouse'),
work_order = self.create_work_order(args) 'fg_warehouse': default_warehouses.get('fg_warehouse')
}
self.prepare_data_for_sub_assembly_items(row, work_order_data)
work_order = self.create_work_order(work_order_data)
if work_order: if work_order:
wo_list.append(work_order) wo_list.append(work_order)
def prepare_data_for_sub_assembly_items(self, row, wo_data):
for field in ["production_item", "item_name", "qty", "fg_warehouse",
"description", "bom_no", "stock_uom", "bom_level",
"production_plan_item", "schedule_date"]:
if row.get(field):
wo_data[field] = row.get(field)
wo_data.update({
"use_multi_level_bom": 0,
"production_plan": self.name,
"production_plan_sub_assembly_item": row.name
})
def make_subcontracted_purchase_order(self, subcontracted_po, purchase_orders): def make_subcontracted_purchase_order(self, subcontracted_po, purchase_orders):
if not subcontracted_po: if not subcontracted_po:
return return
@ -423,7 +444,7 @@ class ProductionPlan(Document):
po.schedule_date = getdate(po_list[0].schedule_date) if po_list[0].schedule_date else nowdate() po.schedule_date = getdate(po_list[0].schedule_date) if po_list[0].schedule_date else nowdate()
po.is_subcontracted = 'Yes' po.is_subcontracted = 'Yes'
for row in po_list: for row in po_list:
args = { po_data = {
'item_code': row.production_item, 'item_code': row.production_item,
'warehouse': row.fg_warehouse, 'warehouse': row.fg_warehouse,
'production_plan_sub_assembly_item': row.name, 'production_plan_sub_assembly_item': row.name,
@ -433,9 +454,9 @@ class ProductionPlan(Document):
for field in ['schedule_date', 'qty', 'uom', 'stock_uom', 'item_name', for field in ['schedule_date', 'qty', 'uom', 'stock_uom', 'item_name',
'description', 'production_plan_item']: 'description', 'production_plan_item']:
args[field] = row.get(field) po_data[field] = row.get(field)
po.append('items', args) po.append('items', po_data)
po.set_missing_values() po.set_missing_values()
po.flags.ignore_mandatory = True po.flags.ignore_mandatory = True
@ -452,24 +473,9 @@ class ProductionPlan(Document):
doc_list = [get_link_to_form(doctype, p) for p in doc_list] doc_list = [get_link_to_form(doctype, p) for p in doc_list]
msgprint(_("{0} created").format(comma_and(doc_list))) msgprint(_("{0} created").format(comma_and(doc_list)))
def prepare_args_for_sub_assembly_items(self, row, args):
for field in ["production_item", "item_name", "qty", "fg_warehouse",
"description", "bom_no", "stock_uom", "bom_level",
"production_plan_item", "schedule_date"]:
args[field] = row.get(field)
args.update({
"use_multi_level_bom": 0,
"production_plan": self.name,
"production_plan_sub_assembly_item": row.name
})
def create_work_order(self, item): def create_work_order(self, item):
from erpnext.manufacturing.doctype.work_order.work_order import ( from erpnext.manufacturing.doctype.work_order.work_order import OverProductionError
OverProductionError,
get_default_warehouse,
)
warehouse = get_default_warehouse()
wo = frappe.new_doc("Work Order") wo = frappe.new_doc("Work Order")
wo.update(item) wo.update(item)
wo.planned_start_date = item.get('planned_start_date') or item.get('schedule_date') wo.planned_start_date = item.get('planned_start_date') or item.get('schedule_date')
@ -478,11 +484,11 @@ class ProductionPlan(Document):
wo.fg_warehouse = item.get("warehouse") wo.fg_warehouse = item.get("warehouse")
wo.set_work_order_operations() wo.set_work_order_operations()
wo.set_required_items()
if not wo.fg_warehouse:
wo.fg_warehouse = warehouse.get('fg_warehouse')
try: try:
wo.flags.ignore_mandatory = True wo.flags.ignore_mandatory = True
wo.flags.ignore_validate = True
wo.insert() wo.insert()
return wo.name return wo.name
except OverProductionError: except OverProductionError:
@ -1023,3 +1029,8 @@ def get_sub_assembly_items(bom_no, bom_data, to_produce_qty, indent=0):
if d.value: if d.value:
get_sub_assembly_items(d.value, bom_data, stock_qty, indent=indent+1) get_sub_assembly_items(d.value, bom_data, stock_qty, indent=indent+1)
def set_default_warehouses(row, default_warehouses):
for field in ['wip_warehouse', 'fg_warehouse']:
if not row.get(field):
row[field] = default_warehouses.get(field)

View File

@ -201,6 +201,21 @@ class TestWorkOrder(ERPNextTestCase):
self.assertEqual(cint(bin1_on_end_production.reserved_qty_for_production), self.assertEqual(cint(bin1_on_end_production.reserved_qty_for_production),
cint(bin1_on_start_production.reserved_qty_for_production)) cint(bin1_on_start_production.reserved_qty_for_production))
def test_reserved_qty_for_production_closed(self):
wo1 = make_wo_order_test_record(item="_Test FG Item", qty=2,
source_warehouse=self.warehouse)
item = wo1.required_items[0].item_code
bin_before = get_bin(item, self.warehouse)
bin_before.update_reserved_qty_for_production()
make_wo_order_test_record(item="_Test FG Item", qty=2,
source_warehouse=self.warehouse)
close_work_order(wo1.name, "Closed")
bin_after = get_bin(item, self.warehouse)
self.assertEqual(bin_before.reserved_qty_for_production, bin_after.reserved_qty_for_production)
def test_backflush_qty_for_overpduction_manufacture(self): def test_backflush_qty_for_overpduction_manufacture(self):
cancel_stock_entry = [] cancel_stock_entry = []
allow_overproduction("overproduction_percentage_for_work_order", 30) allow_overproduction("overproduction_percentage_for_work_order", 30)

View File

@ -8,6 +8,8 @@ from dateutil.relativedelta import relativedelta
from frappe import _ from frappe import _
from frappe.model.document import Document from frappe.model.document import Document
from frappe.model.mapper import get_mapped_doc from frappe.model.mapper import get_mapped_doc
from frappe.query_builder import Case
from frappe.query_builder.functions import Sum
from frappe.utils import ( from frappe.utils import (
cint, cint,
date_diff, date_diff,
@ -74,7 +76,6 @@ class WorkOrder(Document):
self.set_required_items(reset_only_qty = len(self.get("required_items"))) self.set_required_items(reset_only_qty = len(self.get("required_items")))
def validate_sales_order(self): def validate_sales_order(self):
if self.sales_order: if self.sales_order:
self.check_sales_order_on_hold_or_close() self.check_sales_order_on_hold_or_close()
@ -544,7 +545,7 @@ class WorkOrder(Document):
if node.is_bom: if node.is_bom:
operations.extend(_get_operations(node.name, qty=node.exploded_qty)) operations.extend(_get_operations(node.name, qty=node.exploded_qty))
bom_qty = frappe.db.get_value("BOM", self.bom_no, "quantity") bom_qty = frappe.get_cached_value("BOM", self.bom_no, "quantity")
operations.extend(_get_operations(self.bom_no, qty=1.0/bom_qty)) operations.extend(_get_operations(self.bom_no, qty=1.0/bom_qty))
for correct_index, operation in enumerate(operations, start=1): for correct_index, operation in enumerate(operations, start=1):
@ -625,7 +626,7 @@ class WorkOrder(Document):
frappe.delete_doc("Job Card", d.name) frappe.delete_doc("Job Card", d.name)
def validate_production_item(self): def validate_production_item(self):
if frappe.db.get_value("Item", self.production_item, "has_variants"): if frappe.get_cached_value("Item", self.production_item, "has_variants"):
frappe.throw(_("Work Order cannot be raised against a Item Template"), ItemHasVariantError) frappe.throw(_("Work Order cannot be raised against a Item Template"), ItemHasVariantError)
if self.production_item: if self.production_item:
@ -1175,3 +1176,27 @@ def create_pick_list(source_name, target_doc=None, for_qty=None):
doc.set_item_locations() doc.set_item_locations()
return doc return doc
def get_reserved_qty_for_production(item_code: str, warehouse: str) -> float:
"""Get total reserved quantity for any item in specified warehouse"""
wo = frappe.qb.DocType("Work Order")
wo_item = frappe.qb.DocType("Work Order Item")
return (
frappe.qb
.from_(wo)
.from_(wo_item)
.select(Sum(Case()
.when(wo.skip_transfer == 0, wo_item.required_qty - wo_item.transferred_qty)
.else_(wo_item.required_qty - wo_item.consumed_qty))
)
.where(
(wo_item.item_code == item_code)
& (wo_item.parent == wo.name)
& (wo.docstatus == 1)
& (wo_item.source_warehouse == warehouse)
& (wo.status.notin(["Stopped", "Completed", "Closed"]))
& ((wo_item.required_qty > wo_item.transferred_qty)
| (wo_item.required_qty > wo_item.consumed_qty))
)
).run()[0][0] or 0.0

View File

@ -350,3 +350,4 @@ erpnext.patches.v14_0.migrate_cost_center_allocations
erpnext.patches.v13_0.convert_to_website_item_in_item_card_group_template erpnext.patches.v13_0.convert_to_website_item_in_item_card_group_template
erpnext.patches.v13_0.shopping_cart_to_ecommerce erpnext.patches.v13_0.shopping_cart_to_ecommerce
erpnext.patches.v13_0.update_disbursement_account erpnext.patches.v13_0.update_disbursement_account
erpnext.patches.v13_0.update_reserved_qty_closed_wo

View File

@ -0,0 +1,28 @@
import frappe
from erpnext.stock.utils import get_bin
def execute():
wo = frappe.qb.DocType("Work Order")
wo_item = frappe.qb.DocType("Work Order Item")
incorrect_item_wh = (
frappe.qb
.from_(wo)
.join(wo_item).on(wo.name == wo_item.parent)
.select(wo_item.item_code, wo.source_warehouse).distinct()
.where(
(wo.status == "Closed")
& (wo.docstatus == 1)
& (wo.source_warehouse.notnull())
)
).run()
for item_code, warehouse in incorrect_item_wh:
if not (item_code and warehouse):
continue
bin = get_bin(item_code, warehouse)
bin.update_reserved_qty_for_production()

View File

@ -1463,7 +1463,8 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
"item_code": d.item_code, "item_code": d.item_code,
"pricing_rules": d.pricing_rules, "pricing_rules": d.pricing_rules,
"parenttype": d.parenttype, "parenttype": d.parenttype,
"parent": d.parent "parent": d.parent,
"price_list_rate": d.price_list_rate
}) })
} }
}); });

View File

@ -219,7 +219,6 @@ def get_regional_address_details(party_details, doctype, company):
if not party_details.place_of_supply: return party_details if not party_details.place_of_supply: return party_details
if not party_details.company_gstin: 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 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", and party_details.company_gstin[:2] != party_details.place_of_supply[:2]) or (doctype in ("Purchase Invoice",

View File

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

View File

@ -20,43 +20,12 @@ class Bin(Document):
+ flt(self.indented_qty) + flt(self.planned_qty) - flt(self.reserved_qty) + flt(self.indented_qty) + flt(self.planned_qty) - flt(self.reserved_qty)
- flt(self.reserved_qty_for_production) - flt(self.reserved_qty_for_sub_contract)) - flt(self.reserved_qty_for_production) - flt(self.reserved_qty_for_sub_contract))
def get_first_sle(self):
sle = frappe.qb.DocType("Stock Ledger Entry")
first_sle = (
frappe.qb.from_(sle)
.select("*")
.where((sle.item_code == self.item_code) & (sle.warehouse == self.warehouse))
.orderby(sle.posting_date, sle.posting_time, sle.creation)
.limit(1)
).run(as_dict=True)
return first_sle and first_sle[0] or None
def update_reserved_qty_for_production(self): def update_reserved_qty_for_production(self):
'''Update qty reserved for production from Production Item tables '''Update qty reserved for production from Production Item tables
in open work orders''' in open work orders'''
from erpnext.manufacturing.doctype.work_order.work_order import get_reserved_qty_for_production
wo = frappe.qb.DocType("Work Order") self.reserved_qty_for_production = get_reserved_qty_for_production(self.item_code, self.warehouse)
wo_item = frappe.qb.DocType("Work Order Item")
self.reserved_qty_for_production = (
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 == self.item_code)
& (wo_item.parent == wo.name)
& (wo.docstatus == 1)
& (wo_item.source_warehouse == self.warehouse)
& (wo.status.notin(["Stopped", "Completed"]))
& ((wo_item.required_qty > wo_item.transferred_qty)
| (wo_item.required_qty > wo_item.consumed_qty))
)
).run()[0][0] or 0.0
self.set_projected_qty() self.set_projected_qty()
@ -126,13 +95,6 @@ def on_doctype_update():
frappe.db.add_unique("Bin", ["item_code", "warehouse"], constraint_name="unique_item_warehouse") frappe.db.add_unique("Bin", ["item_code", "warehouse"], constraint_name="unique_item_warehouse")
def update_stock(bin_name, args, allow_negative_stock=False, via_landed_cost_voucher=False):
"""WARNING: This function is deprecated. Inline this function instead of using it."""
from erpnext.stock.stock_ledger import repost_current_voucher
repost_current_voucher(args, allow_negative_stock, via_landed_cost_voucher)
update_qty(bin_name, args)
def get_bin_details(bin_name): def get_bin_details(bin_name):
return frappe.db.get_value('Bin', bin_name, ['actual_qty', 'ordered_qty', return frappe.db.get_value('Bin', bin_name, ['actual_qty', 'ordered_qty',
'reserved_qty', 'indented_qty', 'planned_qty', 'reserved_qty_for_production', 'reserved_qty', 'indented_qty', 'planned_qty', 'reserved_qty_for_production',

View File

@ -346,7 +346,7 @@
"fieldname": "valuation_method", "fieldname": "valuation_method",
"fieldtype": "Select", "fieldtype": "Select",
"label": "Valuation Method", "label": "Valuation Method",
"options": "\nFIFO\nMoving Average" "options": "\nFIFO\nMoving Average\nLIFO"
}, },
{ {
"depends_on": "is_stock_item", "depends_on": "is_stock_item",

View File

@ -99,7 +99,7 @@
"fieldname": "valuation_method", "fieldname": "valuation_method",
"fieldtype": "Select", "fieldtype": "Select",
"label": "Default Valuation Method", "label": "Default Valuation Method",
"options": "FIFO\nMoving Average" "options": "FIFO\nMoving Average\nLIFO"
}, },
{ {
"description": "The percentage you are allowed to receive or deliver more against the quantity ordered. For example, if you have ordered 100 units, and your Allowance is 10%, then you are allowed to receive 110 units.", "description": "The percentage you are allowed to receive or deliver more against the quantity ordered. For example, if you have ordered 100 units, and your Allowance is 10%, then you are allowed to receive 110 units.",
@ -346,7 +346,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2022-02-04 15:33:43.692736", "modified": "2022-02-05 15:33:43.692736",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Stock Settings", "name": "Stock Settings",

View File

@ -167,7 +167,7 @@ def get_columns():
{ {
"fieldname": "stock_queue", "fieldname": "stock_queue",
"fieldtype": "Data", "fieldtype": "Data",
"label": "FIFO Queue", "label": "FIFO/LIFO Queue",
}, },
{ {

View File

@ -3,10 +3,9 @@
import frappe import frappe
from frappe.utils import cstr, flt, nowdate, nowtime from frappe.utils import cstr, flt, now, nowdate, nowtime
from erpnext.controllers.stock_controller import create_repost_item_valuation_entry from erpnext.controllers.stock_controller import create_repost_item_valuation_entry
from erpnext.stock.utils import update_bin
def repost(only_actual=False, allow_negative_stock=False, allow_zero_rate=False, only_bin=False): def repost(only_actual=False, allow_negative_stock=False, allow_zero_rate=False, only_bin=False):
@ -175,6 +174,7 @@ def update_bin_qty(item_code, warehouse, qty_dict=None):
bin.set(field, flt(value)) bin.set(field, flt(value))
mismatch = True mismatch = True
bin.modified = now()
if mismatch: if mismatch:
bin.set_projected_qty() bin.set_projected_qty()
bin.db_update() bin.db_update()
@ -227,8 +227,6 @@ def set_stock_balance_as_per_serial_no(item_code=None, posting_date=None, postin
"sle_id": sle_doc.name "sle_id": sle_doc.name
}) })
update_bin(args)
create_repost_item_valuation_entry({ create_repost_item_valuation_entry({
"item_code": d[0], "item_code": d[0],
"warehouse": d[1], "warehouse": d[1],

View File

@ -16,7 +16,7 @@ from erpnext.stock.utils import (
get_or_make_bin, get_or_make_bin,
get_valuation_method, get_valuation_method,
) )
from erpnext.stock.valuation import FIFOValuation from erpnext.stock.valuation import FIFOValuation, LIFOValuation
class NegativeStockError(frappe.ValidationError): pass class NegativeStockError(frappe.ValidationError): pass
@ -461,7 +461,7 @@ class update_entries_after(object):
self.wh_data.qty_after_transaction += flt(sle.actual_qty) self.wh_data.qty_after_transaction += flt(sle.actual_qty)
self.wh_data.stock_value = flt(self.wh_data.qty_after_transaction) * flt(self.wh_data.valuation_rate) self.wh_data.stock_value = flt(self.wh_data.qty_after_transaction) * flt(self.wh_data.valuation_rate)
else: else:
self.update_fifo_values(sle) self.update_queue_values(sle)
self.wh_data.qty_after_transaction += flt(sle.actual_qty) self.wh_data.qty_after_transaction += flt(sle.actual_qty)
# rounding as per precision # rounding as per precision
@ -701,14 +701,18 @@ class update_entries_after(object):
sle.voucher_type, sle.voucher_no, self.allow_zero_rate, sle.voucher_type, sle.voucher_no, self.allow_zero_rate,
currency=erpnext.get_company_currency(sle.company), company=sle.company) currency=erpnext.get_company_currency(sle.company), company=sle.company)
def update_fifo_values(self, sle): def update_queue_values(self, sle):
incoming_rate = flt(sle.incoming_rate) incoming_rate = flt(sle.incoming_rate)
actual_qty = flt(sle.actual_qty) actual_qty = flt(sle.actual_qty)
outgoing_rate = flt(sle.outgoing_rate) outgoing_rate = flt(sle.outgoing_rate)
fifo_queue = FIFOValuation(self.wh_data.stock_queue) if self.valuation_method == "LIFO":
stock_queue = LIFOValuation(self.wh_data.stock_queue)
else:
stock_queue = FIFOValuation(self.wh_data.stock_queue)
if actual_qty > 0: if actual_qty > 0:
fifo_queue.add_stock(qty=actual_qty, rate=incoming_rate) stock_queue.add_stock(qty=actual_qty, rate=incoming_rate)
else: else:
def rate_generator() -> float: def rate_generator() -> float:
allow_zero_valuation_rate = self.check_if_allow_zero_valuation_rate(sle.voucher_type, sle.voucher_detail_no) allow_zero_valuation_rate = self.check_if_allow_zero_valuation_rate(sle.voucher_type, sle.voucher_detail_no)
@ -719,11 +723,11 @@ class update_entries_after(object):
else: else:
return 0.0 return 0.0
fifo_queue.remove_stock(qty=abs(actual_qty), outgoing_rate=outgoing_rate, rate_generator=rate_generator) stock_queue.remove_stock(qty=abs(actual_qty), outgoing_rate=outgoing_rate, rate_generator=rate_generator)
stock_qty, stock_value = fifo_queue.get_total_stock_and_value() stock_qty, stock_value = stock_queue.get_total_stock_and_value()
self.wh_data.stock_queue = fifo_queue.get_state() self.wh_data.stock_queue = stock_queue.state
self.wh_data.stock_value = stock_value self.wh_data.stock_value = stock_value
if stock_qty: if stock_qty:
self.wh_data.valuation_rate = stock_value / stock_qty self.wh_data.valuation_rate = stock_value / stock_qty

View File

@ -1,16 +1,21 @@
import json
import unittest import unittest
import frappe
from hypothesis import given from hypothesis import given
from hypothesis import strategies as st from hypothesis import strategies as st
from erpnext.stock.valuation import FIFOValuation, _round_off_if_near_zero 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.stock.valuation import FIFOValuation, LIFOValuation, _round_off_if_near_zero
from erpnext.tests.utils import ERPNextTestCase
qty_gen = st.floats(min_value=-1e6, max_value=1e6) qty_gen = st.floats(min_value=-1e6, max_value=1e6)
value_gen = st.floats(min_value=1, max_value=1e6) value_gen = st.floats(min_value=1, max_value=1e6)
stock_queue_generator = st.lists(st.tuples(qty_gen, value_gen), min_size=10) stock_queue_generator = st.lists(st.tuples(qty_gen, value_gen), min_size=10)
class TestFifoValuation(unittest.TestCase): class TestFIFOValuation(unittest.TestCase):
def setUp(self): def setUp(self):
self.queue = FIFOValuation([]) self.queue = FIFOValuation([])
@ -164,3 +169,184 @@ class TestFifoValuation(unittest.TestCase):
total_value -= sum(q * r for q, r in consumed) total_value -= sum(q * r for q, r in consumed)
self.assertTotalQty(total_qty) self.assertTotalQty(total_qty)
self.assertTotalValue(total_value) self.assertTotalValue(total_value)
class TestLIFOValuation(unittest.TestCase):
def setUp(self):
self.stack = LIFOValuation([])
def tearDown(self):
qty, value = self.stack.get_total_stock_and_value()
self.assertTotalQty(qty)
self.assertTotalValue(value)
def assertTotalQty(self, qty):
self.assertAlmostEqual(sum(q for q, _ in self.stack), qty, msg=f"stack: {self.stack}", places=4)
def assertTotalValue(self, value):
self.assertAlmostEqual(sum(q * r for q, r in self.stack), value, msg=f"stack: {self.stack}", places=2)
def test_simple_addition(self):
self.stack.add_stock(1, 10)
self.assertTotalQty(1)
def test_merge_new_stock(self):
self.stack.add_stock(1, 10)
self.stack.add_stock(1, 10)
self.assertEqual(self.stack, [[2, 10]])
def test_simple_removal(self):
self.stack.add_stock(1, 10)
self.stack.remove_stock(1)
self.assertTotalQty(0)
def test_adding_negative_stock_keeps_rate(self):
self.stack = LIFOValuation([[-5.0, 100]])
self.stack.add_stock(1, 10)
self.assertEqual(self.stack, [[-4, 100]])
def test_adding_negative_stock_updates_rate(self):
self.stack = LIFOValuation([[-5.0, 100]])
self.stack.add_stock(6, 10)
self.assertEqual(self.stack, [[1, 10]])
def test_rounding_off(self):
self.stack.add_stock(1.0, 1.0)
self.stack.remove_stock(1.0 - 1e-9)
self.assertTotalQty(0)
def test_lifo_consumption(self):
self.stack.add_stock(10, 10)
self.stack.add_stock(10, 20)
consumed = self.stack.remove_stock(15)
self.assertEqual(consumed, [[10, 20], [5, 10]])
self.assertTotalQty(5)
def test_lifo_consumption_going_negative(self):
self.stack.add_stock(10, 10)
self.stack.add_stock(10, 20)
consumed = self.stack.remove_stock(25)
self.assertEqual(consumed, [[10, 20], [10, 10], [5, 10]])
self.assertTotalQty(-5)
def test_lifo_consumption_multiple(self):
self.stack.add_stock(1, 1)
self.stack.add_stock(2, 2)
consumed = self.stack.remove_stock(1)
self.assertEqual(consumed, [[1, 2]])
self.stack.add_stock(3, 3)
consumed = self.stack.remove_stock(4)
self.assertEqual(consumed, [[3, 3], [1, 2]])
self.stack.add_stock(4, 4)
consumed = self.stack.remove_stock(5)
self.assertEqual(consumed, [[4, 4], [1, 1]])
self.stack.add_stock(5, 5)
consumed = self.stack.remove_stock(5)
self.assertEqual(consumed, [[5, 5]])
@given(stock_queue_generator)
def test_lifo_qty_hypothesis(self, stock_stack):
self.stack = LIFOValuation([])
total_qty = 0
for qty, rate in stock_stack:
if qty == 0:
continue
if qty > 0:
self.stack.add_stock(qty, rate)
total_qty += qty
else:
qty = abs(qty)
consumed = self.stack.remove_stock(qty)
self.assertAlmostEqual(qty, sum(q for q, _ in consumed), msg=f"incorrect consumption {consumed}")
total_qty -= qty
self.assertTotalQty(total_qty)
@given(stock_queue_generator)
def test_lifo_qty_value_nonneg_hypothesis(self, stock_stack):
self.stack = LIFOValuation([])
total_qty = 0.0
total_value = 0.0
for qty, rate in stock_stack:
# don't allow negative stock
if qty == 0 or total_qty + qty < 0 or abs(qty) < 0.1:
continue
if qty > 0:
self.stack.add_stock(qty, rate)
total_qty += qty
total_value += qty * rate
else:
qty = abs(qty)
consumed = self.stack.remove_stock(qty)
self.assertAlmostEqual(qty, sum(q for q, _ in consumed), msg=f"incorrect consumption {consumed}")
total_qty -= qty
total_value -= sum(q * r for q, r in consumed)
self.assertTotalQty(total_qty)
self.assertTotalValue(total_value)
class TestLIFOValuationSLE(ERPNextTestCase):
ITEM_CODE = "_Test LIFO item"
WAREHOUSE = "_Test Warehouse - _TC"
@classmethod
def setUpClass(cls) -> None:
super().setUpClass()
make_item(cls.ITEM_CODE, {"valuation_method": "LIFO"})
def _make_stock_entry(self, qty, rate=None):
kwargs = {
"item_code": self.ITEM_CODE,
"from_warehouse" if qty < 0 else "to_warehouse": self.WAREHOUSE,
"rate": rate,
"qty": abs(qty),
}
return make_stock_entry(**kwargs)
def assertStockQueue(self, se, expected_queue):
sle_name = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": se.name, "is_cancelled": 0, "voucher_type": "Stock Entry"})
sle = frappe.get_doc("Stock Ledger Entry", sle_name)
stock_queue = json.loads(sle.stock_queue)
total_qty, total_value = LIFOValuation(stock_queue).get_total_stock_and_value()
self.assertEqual(sle.qty_after_transaction, total_qty)
self.assertEqual(sle.stock_value, total_value)
if total_qty > 0:
self.assertEqual(stock_queue, expected_queue)
def test_lifo_values(self):
in1 = self._make_stock_entry(1, 1)
self.assertStockQueue(in1, [[1, 1]])
in2 = self._make_stock_entry(2, 2)
self.assertStockQueue(in2, [[1, 1], [2, 2]])
out1 = self._make_stock_entry(-1)
self.assertStockQueue(out1, [[1, 1], [1, 2]])
in3 = self._make_stock_entry(3, 3)
self.assertStockQueue(in3, [[1, 1], [1, 2], [3, 3]])
out2 = self._make_stock_entry(-4)
self.assertStockQueue(out2, [[1, 1]])
in4 = self._make_stock_entry(4, 4)
self.assertStockQueue(in4, [[1, 1], [4,4]])
out3 = self._make_stock_entry(-5)
self.assertStockQueue(out3, [])
in5 = self._make_stock_entry(5, 5)
self.assertStockQueue(in5, [[5, 5]])
out5 = self._make_stock_entry(-5)
self.assertStockQueue(out5, [])

View File

@ -9,6 +9,7 @@ from frappe import _
from frappe.utils import cstr, flt, get_link_to_form, nowdate, nowtime from frappe.utils import cstr, flt, get_link_to_form, nowdate, nowtime
import erpnext import erpnext
from erpnext.stock.valuation import FIFOValuation, LIFOValuation
class InvalidWarehouseCompany(frappe.ValidationError): pass class InvalidWarehouseCompany(frappe.ValidationError): pass
@ -205,16 +206,6 @@ def _create_bin(item_code, warehouse):
return bin_obj return bin_obj
def update_bin(args, allow_negative_stock=False, via_landed_cost_voucher=False):
"""WARNING: This function is deprecated. Inline this function instead of using it."""
from erpnext.stock.doctype.bin.bin import update_stock
is_stock_item = frappe.get_cached_value('Item', args.get("item_code"), 'is_stock_item')
if is_stock_item:
bin_name = get_or_make_bin(args.get("item_code"), args.get("warehouse"))
update_stock(bin_name, args, allow_negative_stock, via_landed_cost_voucher)
else:
frappe.msgprint(_("Item {0} ignored since it is not a stock item").format(args.get("item_code")))
@frappe.whitelist() @frappe.whitelist()
def get_incoming_rate(args, raise_error_if_no_rate=True): def get_incoming_rate(args, raise_error_if_no_rate=True):
"""Get Incoming Rate based on valuation method""" """Get Incoming Rate based on valuation method"""
@ -228,10 +219,10 @@ def get_incoming_rate(args, raise_error_if_no_rate=True):
else: else:
valuation_method = get_valuation_method(args.get("item_code")) valuation_method = get_valuation_method(args.get("item_code"))
previous_sle = get_previous_sle(args) previous_sle = get_previous_sle(args)
if valuation_method == 'FIFO': if valuation_method in ('FIFO', 'LIFO'):
if previous_sle: if previous_sle:
previous_stock_queue = json.loads(previous_sle.get('stock_queue', '[]') or '[]') previous_stock_queue = json.loads(previous_sle.get('stock_queue', '[]') or '[]')
in_rate = get_fifo_rate(previous_stock_queue, args.get("qty") or 0) if previous_stock_queue else 0 in_rate = _get_fifo_lifo_rate(previous_stock_queue, args.get("qty") or 0, valuation_method) if previous_stock_queue else 0
elif valuation_method == 'Moving Average': elif valuation_method == 'Moving Average':
in_rate = previous_sle.get('valuation_rate') or 0 in_rate = previous_sle.get('valuation_rate') or 0
@ -261,29 +252,25 @@ def get_valuation_method(item_code):
def get_fifo_rate(previous_stock_queue, qty): def get_fifo_rate(previous_stock_queue, qty):
"""get FIFO (average) Rate from Queue""" """get FIFO (average) Rate from Queue"""
if flt(qty) >= 0: return _get_fifo_lifo_rate(previous_stock_queue, qty, "FIFO")
total = sum(f[0] for f in previous_stock_queue)
return sum(flt(f[0]) * flt(f[1]) for f in previous_stock_queue) / flt(total) if total else 0.0
else:
available_qty_for_outgoing, outgoing_cost = 0, 0
qty_to_pop = abs(flt(qty))
while qty_to_pop and previous_stock_queue:
batch = previous_stock_queue[0]
if 0 < batch[0] <= qty_to_pop:
# if batch qty > 0
# not enough or exactly same qty in current batch, clear batch
available_qty_for_outgoing += flt(batch[0])
outgoing_cost += flt(batch[0]) * flt(batch[1])
qty_to_pop -= batch[0]
previous_stock_queue.pop(0)
else:
# all from current batch
available_qty_for_outgoing += flt(qty_to_pop)
outgoing_cost += flt(qty_to_pop) * flt(batch[1])
batch[0] -= qty_to_pop
qty_to_pop = 0
return outgoing_cost / available_qty_for_outgoing def get_lifo_rate(previous_stock_queue, qty):
"""get LIFO (average) Rate from Queue"""
return _get_fifo_lifo_rate(previous_stock_queue, qty, "LIFO")
def _get_fifo_lifo_rate(previous_stock_queue, qty, method):
ValuationKlass = LIFOValuation if method == "LIFO" else FIFOValuation
stock_queue = ValuationKlass(previous_stock_queue)
if flt(qty) >= 0:
total_qty, total_value = stock_queue.get_total_stock_and_value()
return total_value / total_qty if total_qty else 0.0
else:
popped_bins = stock_queue.remove_stock(abs(flt(qty)))
total_qty, total_value = ValuationKlass(popped_bins).get_total_stock_and_value()
return total_value / total_qty if total_qty else 0.0
def get_valid_serial_nos(sr_nos, qty=0, item_code=''): def get_valid_serial_nos(sr_nos, qty=0, item_code=''):
"""split serial nos, validate and return list of valid serial nos""" """split serial nos, validate and return list of valid serial nos"""

View File

@ -1,15 +1,54 @@
from abc import ABC, abstractmethod, abstractproperty
from typing import Callable, List, NewType, Optional, Tuple from typing import Callable, List, NewType, Optional, Tuple
from frappe.utils import flt from frappe.utils import flt
FifoBin = NewType("FifoBin", List[float]) StockBin = NewType("StockBin", List[float]) # [[qty, rate], ...]
# Indexes of values inside FIFO bin 2-tuple # Indexes of values inside FIFO bin 2-tuple
QTY = 0 QTY = 0
RATE = 1 RATE = 1
class FIFOValuation: class BinWiseValuation(ABC):
@abstractmethod
def add_stock(self, qty: float, rate: float) -> None:
pass
@abstractmethod
def remove_stock(
self, qty: float, outgoing_rate: float = 0.0, rate_generator: Callable[[], float] = None
) -> List[StockBin]:
pass
@abstractproperty
def state(self) -> List[StockBin]:
pass
def get_total_stock_and_value(self) -> Tuple[float, float]:
total_qty = 0.0
total_value = 0.0
for qty, rate in self.state:
total_qty += flt(qty)
total_value += flt(qty) * flt(rate)
return _round_off_if_near_zero(total_qty), _round_off_if_near_zero(total_value)
def __repr__(self):
return str(self.state)
def __iter__(self):
return iter(self.state)
def __eq__(self, other):
if isinstance(other, list):
return self.state == other
return type(self) == type(other) and self.state == other.state
class FIFOValuation(BinWiseValuation):
"""Valuation method where a queue of all the incoming stock is maintained. """Valuation method where a queue of all the incoming stock is maintained.
New stock is added at end of the queue. New stock is added at end of the queue.
@ -24,34 +63,14 @@ class FIFOValuation:
# ref: https://docs.python.org/3/reference/datamodel.html#slots # ref: https://docs.python.org/3/reference/datamodel.html#slots
__slots__ = ["queue",] __slots__ = ["queue",]
def __init__(self, state: Optional[List[FifoBin]]): def __init__(self, state: Optional[List[StockBin]]):
self.queue: List[FifoBin] = state if state is not None else [] self.queue: List[StockBin] = state if state is not None else []
def __repr__(self): @property
return str(self.queue) def state(self) -> List[StockBin]:
def __iter__(self):
return iter(self.queue)
def __eq__(self, other):
if isinstance(other, list):
return self.queue == other
return self.queue == other.queue
def get_state(self) -> List[FifoBin]:
"""Get current state of queue.""" """Get current state of queue."""
return self.queue return self.queue
def get_total_stock_and_value(self) -> Tuple[float, float]:
total_qty = 0.0
total_value = 0.0
for qty, rate in self.queue:
total_qty += flt(qty)
total_value += flt(qty) * flt(rate)
return _round_off_if_near_zero(total_qty), _round_off_if_near_zero(total_value)
def add_stock(self, qty: float, rate: float) -> None: def add_stock(self, qty: float, rate: float) -> None:
"""Update fifo queue with new stock. """Update fifo queue with new stock.
@ -78,7 +97,7 @@ class FIFOValuation:
def remove_stock( def remove_stock(
self, qty: float, outgoing_rate: float = 0.0, rate_generator: Callable[[], float] = None self, qty: float, outgoing_rate: float = 0.0, rate_generator: Callable[[], float] = None
) -> List[FifoBin]: ) -> List[StockBin]:
"""Remove stock from the queue and return popped bins. """Remove stock from the queue and return popped bins.
args: args:
@ -136,6 +155,101 @@ class FIFOValuation:
return consumed_bins return consumed_bins
class LIFOValuation(BinWiseValuation):
"""Valuation method where a *stack* of all the incoming stock is maintained.
New stock is added at top of the stack.
Qty consumption happens on Last In First Out basis.
Stack is implemented using "bins" of [qty, rate].
ref: https://en.wikipedia.org/wiki/FIFO_and_LIFO_accounting
Implementation detail: appends and pops both at end of list.
"""
# specifying the attributes to save resources
# ref: https://docs.python.org/3/reference/datamodel.html#slots
__slots__ = ["stack",]
def __init__(self, state: Optional[List[StockBin]]):
self.stack: List[StockBin] = state if state is not None else []
@property
def state(self) -> List[StockBin]:
"""Get current state of stack."""
return self.stack
def add_stock(self, qty: float, rate: float) -> None:
"""Update lifo stack with new stock.
args:
qty: new quantity to add
rate: incoming rate of new quantity.
Behaviour of this is same as FIFO valuation.
"""
if not len(self.stack):
self.stack.append([0, 0])
# last row has the same rate, merge new bin.
if self.stack[-1][RATE] == rate:
self.stack[-1][QTY] += qty
else:
# Item has a positive balance qty, add new entry
if self.stack[-1][QTY] > 0:
self.stack.append([qty, rate])
else: # negative balance qty
qty = self.stack[-1][QTY] + qty
if qty > 0: # new balance qty is positive
self.stack[-1] = [qty, rate]
else: # new balance qty is still negative, maintain same rate
self.stack[-1][QTY] = qty
def remove_stock(
self, qty: float, outgoing_rate: float = 0.0, rate_generator: Callable[[], float] = None
) -> List[StockBin]:
"""Remove stock from the stack and return popped bins.
args:
qty: quantity to remove
rate: outgoing rate - ignored. Kept for backwards compatibility.
rate_generator: function to be called if stack is not found and rate is required.
"""
if not rate_generator:
rate_generator = lambda : 0.0 # noqa
consumed_bins = []
while qty:
if not len(self.stack):
# rely on rate generator.
self.stack.append([0, rate_generator()])
# start at the end.
index = -1
stock_bin = self.stack[index]
if qty >= stock_bin[QTY]:
# consume current bin
qty = _round_off_if_near_zero(qty - stock_bin[QTY])
to_consume = self.stack.pop(index)
consumed_bins.append(list(to_consume))
if not self.stack and qty:
# stock finished, qty still remains to be withdrawn
# negative stock, keep in as a negative bin
self.stack.append([-qty, outgoing_rate or stock_bin[RATE]])
consumed_bins.append([qty, outgoing_rate or stock_bin[RATE]])
break
else:
# qty found in current bin consume it and exit
stock_bin[QTY] = _round_off_if_near_zero(stock_bin[QTY] - qty)
consumed_bins.append([qty, stock_bin[RATE]])
qty = 0
return consumed_bins
def _round_off_if_near_zero(number: float, precision: int = 7) -> float: def _round_off_if_near_zero(number: float, precision: int = 7) -> float:
"""Rounds off the number to zero only if number is close to zero for decimal """Rounds off the number to zero only if number is close to zero for decimal
specified in precision. Precision defaults to 7. specified in precision. Precision defaults to 7.

View File

@ -1,15 +1,25 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# MIT License. See license.txt # MIT License. See license.txt
import unittest
import frappe
from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile 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.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.item.test_item import make_item
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry 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): def test_item_search(self):
""" """
Test Stock and Service Item Search. Test Stock and Service Item Search.

View File

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