chore: merge branch 'version-13-hotfix' into 'version-13-pre-release' (#27173)
* feat: add provision for process loss in manufac * feat: add is process loss autoset and validation * fix: add warehouse and unset is scrap for process loss items * refactor: shift auto entry of is process loss check, update validations * test: add bom tests for process loss val, add se test for qty calc * fix: add more validations, remove source wh req for pl item * fix: sider * refactor: polyfill ?? * fix: sider * refactor: validation error message formatting * test: check manufacture completion qty in se and wo * fix: wo tests, sider, account for pl in se validation * fix: reword error messages, fix test values * feat: add procss_loss_qty field in work order * feat: process loss report, fix set pl query condition * fix: correct value in test * fix: get filters to work - reorder and rename columns - add work order filter * fix: Shopping cart Exchange rate validation (#27050) * fix: Shopping cart Exchange rate validation - Use `get_exchange_rate` to check for price list exchange rate in cart settings - Move cart exchange rate validation for Price List from hooks to doc event - Call cart exchange rate validation on PL update only if PL is in cart and currency is changed * chore: Comment out obsolete test - Modifying this test means considering extreme edge cases, which seems pointless now * fix: Remove snippet that got in due to cherry-pick from `develop` - This snippet is not present in v13-hotfix. Via https://github.com/frappe/erpnext/pull/26520 Co-authored-by: Nabin Hait <nabinhait@gmail.com> * feat: initialize party link for customer & suppliers * feat: toggle to enable common party accounting * feat: auto create advance entry on invoice submission * test: creation of advance entry on invoice submission * fix: remove unwanted filter query * feat: validate multiple links * fix: party link permissions * perf: reduce number of queries to get party link * fix: cost center & naming series * fix: cost center in test_sales_invoice_against_supplier * fix: Don't create inward SLE against SI unless is internal customer enabled (#27086) * fix: Dont create inward SLE against SI unless is internal customer enabled - Check if is internal customer enabled apart from target warehouse - Test to check if inward SLE is made if target warehouse is accidentally set but customer is not internal * test: Use internal customer for delivery of bundle items to target warehouse - created `create_internal_customer` util - reused it in delivery note and sales invoice tests - use internal customer for target warehouse test in delivery note (cherry picked from commitf4dc9ee2aa) # Conflicts: # erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py * fix: prevent over riding scrap table values, name kwargs, set currency * fix(regional): minor fixes and test for South Africa VAT report (#26933) (#27162) * fix: allow to change incoming rate manually in case of stand-alone credit note (#27164) * fix: allow to change rate manually in case of stand-alone credit note (#27036) Co-authored-by: Marica <maricadsouza221197@gmail.com> (cherry picked from commitfe4540d74d) # Conflicts: # erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json * fix: resolve conflicts Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com> Co-authored-by: Ankush Menat <ankush@iwebnotes.com> * fix: Fee Validity fixes (#27161) * fix: Fee Validity fixes (#27156) * chore: update Fee Validity form labels * fix: first appointment should not be considered for Fee Validity * fix: Fee Validity test cases * fix: appointment test case (cherry picked from commit642b4c805c) * fix: overlapping appointments Co-authored-by: Rucha Mahabal <ruchamahabal2@gmail.com> * fix: Merge conflicts and place internal customer creation util in test_customer.py * fix: internal customer util returns 'str' not doc object * fix: negative qty validation on stock reco cancellation (#27170) (#27171) * test: negative stock validation on SR cancel * fix: negative stock setting ignored in stock reco In stock reconcilation cancellation negative stock setting is ignored as `db.get_value` is returning string `'0'` which is not casted to int/bool for further logic. This causes negative qty, which evantually gets caught by reposting but by design this should stop cancellation. * test: typo and minor refactor (cherry picked from commite7109c18db) Co-authored-by: Ankush Menat <ankush@iwebnotes.com> Co-authored-by: 18alantom <2.alan.tom@gmail.com> Co-authored-by: Marica <maricadsouza221197@gmail.com> Co-authored-by: Nabin Hait <nabinhait@gmail.com> Co-authored-by: Saqib Ansari <nextchamp.saqib@gmail.com> Co-authored-by: Frappe PR Bot <frappe.pr.bot@gmail.com> Co-authored-by: Ankush Menat <ankush@iwebnotes.com> Co-authored-by: Rucha Mahabal <ruchamahabal2@gmail.com>
This commit is contained in:
@@ -18,6 +18,7 @@
|
||||
"delete_linked_ledger_entries",
|
||||
"book_asset_depreciation_entry_automatically",
|
||||
"unlink_advance_payment_on_cancelation_of_order",
|
||||
"enable_common_party_accounting",
|
||||
"post_change_gl_entries",
|
||||
"enable_discount_accounting",
|
||||
"tax_settings_section",
|
||||
@@ -269,6 +270,12 @@
|
||||
"fieldname": "enable_discount_accounting",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enable Discount Accounting"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "enable_common_party_accounting",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enable Common Party Accounting"
|
||||
}
|
||||
],
|
||||
"icon": "icon-cog",
|
||||
@@ -276,7 +283,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2021-07-12 18:54:29.084958",
|
||||
"modified": "2021-08-19 11:17:38.788054",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Accounts Settings",
|
||||
|
||||
0
erpnext/accounts/doctype/party_link/__init__.py
Normal file
0
erpnext/accounts/doctype/party_link/__init__.py
Normal file
33
erpnext/accounts/doctype/party_link/party_link.js
Normal file
33
erpnext/accounts/doctype/party_link/party_link.js
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.ui.form.on('Party Link', {
|
||||
refresh: function(frm) {
|
||||
frm.set_query('primary_role', () => {
|
||||
return {
|
||||
filters: {
|
||||
name: ['in', ['Customer', 'Supplier']]
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
frm.set_query('secondary_role', () => {
|
||||
let party_types = Object.keys(frappe.boot.party_account_types)
|
||||
.filter(p => p != frm.doc.primary_role);
|
||||
return {
|
||||
filters: {
|
||||
name: ['in', party_types]
|
||||
}
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
primary_role(frm) {
|
||||
frm.set_value('primary_party', '');
|
||||
frm.set_value('secondary_role', '');
|
||||
},
|
||||
|
||||
secondary_role(frm) {
|
||||
frm.set_value('secondary_party', '');
|
||||
}
|
||||
});
|
||||
102
erpnext/accounts/doctype/party_link/party_link.json
Normal file
102
erpnext/accounts/doctype/party_link/party_link.json
Normal file
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"actions": [],
|
||||
"autoname": "ACC-PT-LNK-.###.",
|
||||
"creation": "2021-08-18 21:06:53.027695",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"primary_role",
|
||||
"secondary_role",
|
||||
"column_break_2",
|
||||
"primary_party",
|
||||
"secondary_party"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "primary_role",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Primary Role",
|
||||
"options": "DocType",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_2",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"depends_on": "primary_role",
|
||||
"fieldname": "secondary_role",
|
||||
"fieldtype": "Link",
|
||||
"label": "Secondary Role",
|
||||
"mandatory_depends_on": "primary_role",
|
||||
"options": "DocType"
|
||||
},
|
||||
{
|
||||
"depends_on": "primary_role",
|
||||
"fieldname": "primary_party",
|
||||
"fieldtype": "Dynamic Link",
|
||||
"label": "Primary Party",
|
||||
"mandatory_depends_on": "primary_role",
|
||||
"options": "primary_role"
|
||||
},
|
||||
{
|
||||
"depends_on": "secondary_role",
|
||||
"fieldname": "secondary_party",
|
||||
"fieldtype": "Dynamic Link",
|
||||
"label": "Secondary Party",
|
||||
"mandatory_depends_on": "secondary_role",
|
||||
"options": "secondary_role"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2021-08-25 20:08:56.761150",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Party Link",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Accounts Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Accounts User",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"title_field": "primary_party",
|
||||
"track_changes": 1
|
||||
}
|
||||
26
erpnext/accounts/doctype/party_link/party_link.py
Normal file
26
erpnext/accounts/doctype/party_link/party_link.py
Normal file
@@ -0,0 +1,26 @@
|
||||
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
|
||||
class PartyLink(Document):
|
||||
def validate(self):
|
||||
if self.primary_role not in ['Customer', 'Supplier']:
|
||||
frappe.throw(_("Allowed primary roles are 'Customer' and 'Supplier'. Please select one of these roles only."),
|
||||
title=_("Invalid Primary Role"))
|
||||
|
||||
existing_party_link = frappe.get_all('Party Link', {
|
||||
'primary_party': self.secondary_party
|
||||
}, pluck="primary_role")
|
||||
if existing_party_link:
|
||||
frappe.throw(_('{} {} is already linked with another {}')
|
||||
.format(self.secondary_role, self.secondary_party, existing_party_link[0]))
|
||||
|
||||
existing_party_link = frappe.get_all('Party Link', {
|
||||
'secondary_party': self.primary_party
|
||||
}, pluck="primary_role")
|
||||
if existing_party_link:
|
||||
frappe.throw(_('{} {} is already linked with another {}')
|
||||
.format(self.primary_role, self.primary_party, existing_party_link[0]))
|
||||
8
erpnext/accounts/doctype/party_link/test_party_link.py
Normal file
8
erpnext/accounts/doctype/party_link/test_party_link.py
Normal file
@@ -0,0 +1,8 @@
|
||||
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
import unittest
|
||||
|
||||
class TestPartyLink(unittest.TestCase):
|
||||
pass
|
||||
@@ -413,6 +413,8 @@ class PurchaseInvoice(BuyingController):
|
||||
self.update_project()
|
||||
update_linked_doc(self.doctype, self.name, self.inter_company_invoice_reference)
|
||||
|
||||
self.process_common_party_accounting()
|
||||
|
||||
def make_gl_entries(self, gl_entries=None, from_repost=False):
|
||||
if not gl_entries:
|
||||
gl_entries = self.get_gl_entries()
|
||||
|
||||
@@ -253,6 +253,8 @@ class SalesInvoice(SellingController):
|
||||
if "Healthcare" in active_domains:
|
||||
manage_invoice_submit_cancel(self, "on_submit")
|
||||
|
||||
self.process_common_party_accounting()
|
||||
|
||||
def validate_pos_return(self):
|
||||
|
||||
if self.is_pos and self.is_return:
|
||||
|
||||
@@ -1107,6 +1107,18 @@ class TestSalesInvoice(unittest.TestCase):
|
||||
self.assertEqual(frappe.db.get_value("Sales Invoice", si.name, "outstanding_amount"), 1500)
|
||||
|
||||
|
||||
def test_incoming_rate_for_stand_alone_credit_note(self):
|
||||
return_si = create_sales_invoice(is_return=1, update_stock=1, qty=-1, rate=90000, incoming_rate=10,
|
||||
company='_Test Company with perpetual inventory', warehouse='Stores - TCP1', debit_to='Debtors - TCP1',
|
||||
income_account='Sales - TCP1', expense_account='Cost of Goods Sold - TCP1', cost_center='Main - TCP1')
|
||||
|
||||
incoming_rate = frappe.db.get_value('Stock Ledger Entry', {'voucher_no': return_si.name}, 'incoming_rate')
|
||||
debit_amount = frappe.db.get_value('GL Entry',
|
||||
{'voucher_no': return_si.name, 'account': 'Stock In Hand - TCP1'}, 'debit')
|
||||
|
||||
self.assertEqual(debit_amount, 10.0)
|
||||
self.assertEqual(incoming_rate, 10.0)
|
||||
|
||||
def test_discount_on_net_total(self):
|
||||
si = frappe.copy_doc(test_records[2])
|
||||
si.apply_discount_on = "Net Total"
|
||||
@@ -1783,23 +1795,13 @@ class TestSalesInvoice(unittest.TestCase):
|
||||
acc_settings.save()
|
||||
|
||||
def test_inter_company_transaction(self):
|
||||
from erpnext.selling.doctype.customer.test_customer import create_internal_customer
|
||||
|
||||
if not frappe.db.exists("Customer", "_Test Internal Customer"):
|
||||
customer = frappe.get_doc({
|
||||
"customer_group": "_Test Customer Group",
|
||||
"customer_name": "_Test Internal Customer",
|
||||
"customer_type": "Individual",
|
||||
"doctype": "Customer",
|
||||
"territory": "_Test Territory",
|
||||
"is_internal_customer": 1,
|
||||
"represents_company": "_Test Company 1"
|
||||
})
|
||||
|
||||
customer.append("companies", {
|
||||
"company": "Wind Power LLC"
|
||||
})
|
||||
|
||||
customer.insert()
|
||||
create_internal_customer(
|
||||
customer_name="_Test Internal Customer",
|
||||
represents_company="_Test Company 1",
|
||||
allowed_to_interact_with="Wind Power LLC"
|
||||
)
|
||||
|
||||
if not frappe.db.exists("Supplier", "_Test Internal Supplier"):
|
||||
supplier = frappe.get_doc({
|
||||
@@ -1842,8 +1844,43 @@ class TestSalesInvoice(unittest.TestCase):
|
||||
self.assertEqual(target_doc.company, "_Test Company 1")
|
||||
self.assertEqual(target_doc.supplier, "_Test Internal Supplier")
|
||||
|
||||
def test_sle_if_target_warehouse_exists_accidentally(self):
|
||||
"""
|
||||
Check if inward entry exists if Target Warehouse accidentally exists
|
||||
but Customer is not an internal customer.
|
||||
"""
|
||||
se = make_stock_entry(
|
||||
item_code="138-CMS Shoe",
|
||||
target="Finished Goods - _TC",
|
||||
company = "_Test Company",
|
||||
qty=1,
|
||||
basic_rate=500
|
||||
)
|
||||
|
||||
si = frappe.copy_doc(test_records[0])
|
||||
si.update_stock = 1
|
||||
si.set_warehouse = "Finished Goods - _TC"
|
||||
si.set_target_warehouse = "Stores - _TC"
|
||||
si.get("items")[0].warehouse = "Finished Goods - _TC"
|
||||
si.get("items")[0].target_warehouse = "Stores - _TC"
|
||||
si.insert()
|
||||
si.submit()
|
||||
|
||||
sles = frappe.get_all("Stock Ledger Entry", filters={"voucher_no": si.name},
|
||||
fields=["name", "actual_qty"])
|
||||
|
||||
# check if only one SLE for outward entry is created
|
||||
self.assertEqual(len(sles), 1)
|
||||
self.assertEqual(sles[0].actual_qty, -1)
|
||||
|
||||
# tear down
|
||||
si.cancel()
|
||||
se.cancel()
|
||||
|
||||
def test_internal_transfer_gl_entry(self):
|
||||
## Create internal transfer account
|
||||
from erpnext.selling.doctype.customer.test_customer import create_internal_customer
|
||||
|
||||
account = create_account(account_name="Unrealized Profit",
|
||||
parent_account="Current Liabilities - TCP1", company="_Test Company with perpetual inventory")
|
||||
|
||||
@@ -2071,6 +2108,50 @@ class TestSalesInvoice(unittest.TestCase):
|
||||
check_gl_entries(self, si.name, expected_gle, add_days(nowdate(), -1))
|
||||
enable_discount_accounting(enable=0)
|
||||
|
||||
def test_sales_invoice_against_supplier(self):
|
||||
from erpnext.accounts.doctype.opening_invoice_creation_tool.test_opening_invoice_creation_tool import make_customer
|
||||
from erpnext.buying.doctype.supplier.test_supplier import create_supplier
|
||||
|
||||
# create a customer
|
||||
customer = make_customer(customer="_Test Common Supplier")
|
||||
# create a supplier
|
||||
supplier = create_supplier(supplier_name="_Test Common Supplier").name
|
||||
|
||||
# create a party link between customer & supplier
|
||||
# set primary role as supplier
|
||||
party_link = frappe.new_doc("Party Link")
|
||||
party_link.primary_role = "Supplier"
|
||||
party_link.primary_party = supplier
|
||||
party_link.secondary_role = "Customer"
|
||||
party_link.secondary_party = customer
|
||||
party_link.save()
|
||||
|
||||
# enable common party accounting
|
||||
frappe.db.set_value('Accounts Settings', None, 'enable_common_party_accounting', 1)
|
||||
|
||||
# create a sales invoice
|
||||
si = create_sales_invoice(customer=customer, parent_cost_center="_Test Cost Center - _TC")
|
||||
|
||||
# check outstanding of sales invoice
|
||||
si.reload()
|
||||
self.assertEqual(si.status, 'Paid')
|
||||
self.assertEqual(flt(si.outstanding_amount), 0.0)
|
||||
|
||||
# check creation of journal entry
|
||||
jv = frappe.get_all('Journal Entry Account', {
|
||||
'account': si.debit_to,
|
||||
'party_type': 'Customer',
|
||||
'party': si.customer,
|
||||
'reference_type': si.doctype,
|
||||
'reference_name': si.name
|
||||
}, pluck='credit_in_account_currency')
|
||||
|
||||
self.assertTrue(jv)
|
||||
self.assertEqual(jv[0], si.grand_total)
|
||||
|
||||
party_link.delete()
|
||||
frappe.db.set_value('Accounts Settings', None, 'enable_common_party_accounting', 0)
|
||||
|
||||
def get_sales_invoice_for_e_invoice():
|
||||
si = make_sales_invoice_for_ewaybill()
|
||||
si.naming_series = 'INV-2020-.#####'
|
||||
@@ -2283,7 +2364,8 @@ def create_sales_invoice(**args):
|
||||
"discount_amount": args.discount_amount or 0,
|
||||
"cost_center": args.cost_center or "_Test Cost Center - _TC",
|
||||
"serial_no": args.serial_no,
|
||||
"conversion_factor": 1
|
||||
"conversion_factor": 1,
|
||||
"incoming_rate": args.incoming_rate or 0
|
||||
})
|
||||
|
||||
if not args.do_not_save:
|
||||
@@ -2380,29 +2462,6 @@ def get_taxes_and_charges():
|
||||
"row_id": 1
|
||||
}]
|
||||
|
||||
def create_internal_customer(customer_name, represents_company, allowed_to_interact_with):
|
||||
if not frappe.db.exists("Customer", customer_name):
|
||||
customer = frappe.get_doc({
|
||||
"customer_group": "_Test Customer Group",
|
||||
"customer_name": customer_name,
|
||||
"customer_type": "Individual",
|
||||
"doctype": "Customer",
|
||||
"territory": "_Test Territory",
|
||||
"is_internal_customer": 1,
|
||||
"represents_company": represents_company
|
||||
})
|
||||
|
||||
customer.append("companies", {
|
||||
"company": allowed_to_interact_with
|
||||
})
|
||||
|
||||
customer.insert()
|
||||
customer_name = customer.name
|
||||
else:
|
||||
customer_name = frappe.db.get_value("Customer", customer_name)
|
||||
|
||||
return customer_name
|
||||
|
||||
def create_internal_supplier(supplier_name, represents_company, allowed_to_interact_with):
|
||||
if not frappe.db.exists("Supplier", supplier_name):
|
||||
supplier = frappe.get_doc({
|
||||
|
||||
@@ -53,7 +53,6 @@
|
||||
"column_break_24",
|
||||
"base_net_rate",
|
||||
"base_net_amount",
|
||||
"incoming_rate",
|
||||
"drop_ship",
|
||||
"delivered_by_supplier",
|
||||
"accounting",
|
||||
@@ -81,6 +80,7 @@
|
||||
"target_warehouse",
|
||||
"quality_inspection",
|
||||
"batch_no",
|
||||
"incoming_rate",
|
||||
"col_break5",
|
||||
"allow_zero_valuation_rate",
|
||||
"serial_no",
|
||||
@@ -808,12 +808,12 @@
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:parent.is_return && parent.update_stock && !parent.return_against",
|
||||
"fieldname": "incoming_rate",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Incoming Rate",
|
||||
"label": "Incoming Rate (Costing)",
|
||||
"no_copy": 1,
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
"print_hide": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval: doc.uom != doc.stock_uom",
|
||||
@@ -834,7 +834,7 @@
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2021-08-12 20:15:42.668399",
|
||||
"modified": "2021-08-19 13:41:53.435827",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Sales Invoice Item",
|
||||
|
||||
@@ -14,7 +14,7 @@ from erpnext.accounts.utils import get_fiscal_years, validate_fiscal_year, get_a
|
||||
from erpnext.utilities.transaction_base import TransactionBase
|
||||
from erpnext.buying.utils import update_last_purchase_rate
|
||||
from erpnext.controllers.sales_and_purchase_return import validate_return
|
||||
from erpnext.accounts.party import get_party_account_currency, validate_party_frozen_disabled
|
||||
from erpnext.accounts.party import get_party_account_currency, validate_party_frozen_disabled, get_party_account
|
||||
from erpnext.accounts.doctype.pricing_rule.utils import (apply_pricing_rule_on_transaction,
|
||||
apply_pricing_rule_for_free_items, get_applied_pricing_rules)
|
||||
from erpnext.exceptions import InvalidCurrency
|
||||
@@ -1368,6 +1368,67 @@ class AccountsController(TransactionBase):
|
||||
|
||||
return False
|
||||
|
||||
def process_common_party_accounting(self):
|
||||
is_invoice = self.doctype in ['Sales Invoice', 'Purchase Invoice']
|
||||
if not is_invoice:
|
||||
return
|
||||
|
||||
if frappe.db.get_single_value('Accounts Settings', 'enable_common_party_accounting'):
|
||||
party_link = self.get_common_party_link()
|
||||
if party_link and self.outstanding_amount:
|
||||
self.create_advance_and_reconcile(party_link)
|
||||
|
||||
def get_common_party_link(self):
|
||||
party_type, party = self.get_party()
|
||||
return frappe.db.get_value(
|
||||
doctype='Party Link',
|
||||
filters={'secondary_role': party_type, 'secondary_party': party},
|
||||
fieldname=['primary_role', 'primary_party'],
|
||||
as_dict=True
|
||||
)
|
||||
|
||||
def create_advance_and_reconcile(self, party_link):
|
||||
secondary_party_type, secondary_party = self.get_party()
|
||||
primary_party_type, primary_party = party_link.primary_role, party_link.primary_party
|
||||
|
||||
primary_account = get_party_account(primary_party_type, primary_party, self.company)
|
||||
secondary_account = get_party_account(secondary_party_type, secondary_party, self.company)
|
||||
|
||||
jv = frappe.new_doc('Journal Entry')
|
||||
jv.voucher_type = 'Journal Entry'
|
||||
jv.posting_date = self.posting_date
|
||||
jv.company = self.company
|
||||
jv.remark = 'Adjustment for {} {}'.format(self.doctype, self.name)
|
||||
|
||||
reconcilation_entry = frappe._dict()
|
||||
advance_entry = frappe._dict()
|
||||
|
||||
reconcilation_entry.account = secondary_account
|
||||
reconcilation_entry.party_type = secondary_party_type
|
||||
reconcilation_entry.party = secondary_party
|
||||
reconcilation_entry.reference_type = self.doctype
|
||||
reconcilation_entry.reference_name = self.name
|
||||
reconcilation_entry.cost_center = self.cost_center
|
||||
|
||||
advance_entry.account = primary_account
|
||||
advance_entry.party_type = primary_party_type
|
||||
advance_entry.party = primary_party
|
||||
advance_entry.cost_center = self.cost_center
|
||||
advance_entry.is_advance = 'Yes'
|
||||
|
||||
if self.doctype == 'Sales Invoice':
|
||||
reconcilation_entry.credit_in_account_currency = self.outstanding_amount
|
||||
advance_entry.debit_in_account_currency = self.outstanding_amount
|
||||
else:
|
||||
advance_entry.credit_in_account_currency = self.outstanding_amount
|
||||
reconcilation_entry.debit_in_account_currency = self.outstanding_amount
|
||||
|
||||
jv.append('accounts', reconcilation_entry)
|
||||
jv.append('accounts', advance_entry)
|
||||
|
||||
jv.save()
|
||||
jv.submit()
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_tax_rate(account_head):
|
||||
return frappe.db.get_value("Account", account_head, ["tax_rate", "account_name"], as_dict=True)
|
||||
|
||||
@@ -394,8 +394,22 @@ def get_rate_for_return(voucher_type, voucher_no, item_code, return_against=None
|
||||
if not return_against:
|
||||
return_against = frappe.get_cached_value(voucher_type, voucher_no, "return_against")
|
||||
|
||||
if not return_against and voucher_type == 'Sales Invoice' and sle:
|
||||
return get_incoming_rate({
|
||||
return_against_item_field = get_return_against_item_fields(voucher_type)
|
||||
|
||||
filters = get_filters(voucher_type, voucher_no, voucher_detail_no,
|
||||
return_against, item_code, return_against_item_field, item_row)
|
||||
|
||||
if voucher_type in ("Purchase Receipt", "Purchase Invoice"):
|
||||
select_field = "incoming_rate"
|
||||
else:
|
||||
select_field = "abs(stock_value_difference / actual_qty)"
|
||||
|
||||
rate = flt(frappe.db.get_value("Stock Ledger Entry", filters, select_field))
|
||||
if not (rate and return_against) and voucher_type in ['Sales Invoice', 'Delivery Note']:
|
||||
rate = frappe.db.get_value(f'{voucher_type} Item', voucher_detail_no, 'incoming_rate')
|
||||
|
||||
if not rate and sle:
|
||||
rate = get_incoming_rate({
|
||||
"item_code": sle.item_code,
|
||||
"warehouse": sle.warehouse,
|
||||
"posting_date": sle.get('posting_date'),
|
||||
@@ -407,17 +421,7 @@ def get_rate_for_return(voucher_type, voucher_no, item_code, return_against=None
|
||||
"voucher_no": sle.voucher_no
|
||||
}, raise_error_if_no_rate=False)
|
||||
|
||||
return_against_item_field = get_return_against_item_fields(voucher_type)
|
||||
|
||||
filters = get_filters(voucher_type, voucher_no, voucher_detail_no,
|
||||
return_against, item_code, return_against_item_field, item_row)
|
||||
|
||||
if voucher_type in ("Purchase Receipt", "Purchase Invoice"):
|
||||
select_field = "incoming_rate"
|
||||
else:
|
||||
select_field = "abs(stock_value_difference / actual_qty)"
|
||||
|
||||
return flt(frappe.db.get_value("Stock Ledger Entry", filters, select_field))
|
||||
return rate
|
||||
|
||||
def get_return_against_item_fields(voucher_type):
|
||||
return_against_item_fields = {
|
||||
|
||||
@@ -362,7 +362,7 @@ class SellingController(StockController):
|
||||
sales_order.update_reserved_qty(so_item_rows)
|
||||
|
||||
def set_incoming_rate(self):
|
||||
if self.doctype not in ("Delivery Note", "Sales Invoice", "Sales Order"):
|
||||
if self.doctype not in ("Delivery Note", "Sales Invoice"):
|
||||
return
|
||||
|
||||
items = self.get("items") + (self.get("packed_items") or [])
|
||||
@@ -371,6 +371,7 @@ class SellingController(StockController):
|
||||
# Get incoming rate based on original item cost based on valuation method
|
||||
qty = flt(d.get('stock_qty') or d.get('actual_qty'))
|
||||
|
||||
if not d.incoming_rate:
|
||||
d.incoming_rate = get_incoming_rate({
|
||||
"item_code": d.item_code,
|
||||
"warehouse": d.warehouse,
|
||||
@@ -422,7 +423,7 @@ class SellingController(StockController):
|
||||
or (cint(self.is_return) and self.docstatus==2)):
|
||||
sl_entries.append(self.get_sle_for_source_warehouse(d))
|
||||
|
||||
if d.target_warehouse:
|
||||
if d.target_warehouse and self.get("is_internal_customer"):
|
||||
sl_entries.append(self.get_sle_for_target_warehouse(d))
|
||||
|
||||
if d.warehouse and ((not cint(self.is_return) and self.docstatus==2)
|
||||
|
||||
@@ -46,13 +46,13 @@
|
||||
{
|
||||
"fieldname": "visited",
|
||||
"fieldtype": "Int",
|
||||
"label": "Visited yet",
|
||||
"label": "Visits Completed",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "valid_till",
|
||||
"fieldtype": "Date",
|
||||
"label": "Valid till",
|
||||
"label": "Valid Till",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
@@ -106,7 +106,7 @@
|
||||
],
|
||||
"in_create": 1,
|
||||
"links": [],
|
||||
"modified": "2020-03-17 20:25:06.487418",
|
||||
"modified": "2021-08-26 10:51:05.609349",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Healthcare",
|
||||
"name": "Fee Validity",
|
||||
|
||||
@@ -11,7 +11,6 @@ import datetime
|
||||
class FeeValidity(Document):
|
||||
def validate(self):
|
||||
self.update_status()
|
||||
self.set_start_date()
|
||||
|
||||
def update_status(self):
|
||||
if self.visited >= self.max_visits:
|
||||
@@ -19,13 +18,6 @@ class FeeValidity(Document):
|
||||
else:
|
||||
self.status = 'Pending'
|
||||
|
||||
def set_start_date(self):
|
||||
self.start_date = getdate()
|
||||
for appointment in self.ref_appointments:
|
||||
appointment_date = frappe.db.get_value('Patient Appointment', appointment.appointment, 'appointment_date')
|
||||
if getdate(appointment_date) < self.start_date:
|
||||
self.start_date = getdate(appointment_date)
|
||||
|
||||
|
||||
def create_fee_validity(appointment):
|
||||
if not check_is_new_patient(appointment):
|
||||
@@ -36,11 +28,9 @@ def create_fee_validity(appointment):
|
||||
fee_validity.patient = appointment.patient
|
||||
fee_validity.max_visits = frappe.db.get_single_value('Healthcare Settings', 'max_visits') or 1
|
||||
valid_days = frappe.db.get_single_value('Healthcare Settings', 'valid_days') or 1
|
||||
fee_validity.visited = 1
|
||||
fee_validity.visited = 0
|
||||
fee_validity.start_date = getdate(appointment.appointment_date)
|
||||
fee_validity.valid_till = getdate(appointment.appointment_date) + datetime.timedelta(days=int(valid_days))
|
||||
fee_validity.append('ref_appointments', {
|
||||
'appointment': appointment.name
|
||||
})
|
||||
fee_validity.save(ignore_permissions=True)
|
||||
return fee_validity
|
||||
|
||||
|
||||
@@ -22,14 +22,14 @@ class TestFeeValidity(unittest.TestCase):
|
||||
item = create_healthcare_service_items()
|
||||
healthcare_settings = frappe.get_single("Healthcare Settings")
|
||||
healthcare_settings.enable_free_follow_ups = 1
|
||||
healthcare_settings.max_visits = 2
|
||||
healthcare_settings.max_visits = 1
|
||||
healthcare_settings.valid_days = 7
|
||||
healthcare_settings.automate_appointment_invoicing = 1
|
||||
healthcare_settings.op_consulting_charge_item = item
|
||||
healthcare_settings.save(ignore_permissions=True)
|
||||
patient, practitioner = create_healthcare_docs()
|
||||
|
||||
# For first appointment, invoice is generated
|
||||
# For first appointment, invoice is generated. First appointment not considered in fee validity
|
||||
appointment = create_appointment(patient, practitioner, nowdate())
|
||||
invoiced = frappe.db.get_value("Patient Appointment", appointment.name, "invoiced")
|
||||
self.assertEqual(invoiced, 1)
|
||||
|
||||
@@ -137,9 +137,13 @@ class PatientAppointment(Document):
|
||||
frappe.db.set_value('Patient Appointment', self.name, 'notes', comments)
|
||||
|
||||
def update_fee_validity(self):
|
||||
if not frappe.db.get_single_value('Healthcare Settings', 'enable_free_follow_ups'):
|
||||
return
|
||||
|
||||
fee_validity = manage_fee_validity(self)
|
||||
if fee_validity:
|
||||
frappe.msgprint(_('{0} has fee validity till {1}').format(self.patient, fee_validity.valid_till))
|
||||
frappe.msgprint(_('{0}: {1} has fee validity till {2}').format(self.patient,
|
||||
frappe.bold(self.patient_name), fee_validity.valid_till))
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_therapy_types(self):
|
||||
|
||||
@@ -110,18 +110,21 @@ class TestPatientAppointment(unittest.TestCase):
|
||||
patient, practitioner = create_healthcare_docs()
|
||||
frappe.db.set_value('Healthcare Settings', None, 'enable_free_follow_ups', 1)
|
||||
appointment = create_appointment(patient, practitioner, nowdate())
|
||||
fee_validity = frappe.db.get_value('Fee Validity Reference', {'appointment': appointment.name}, 'parent')
|
||||
fee_validity = frappe.db.get_value('Fee Validity', {'patient': patient, 'practitioner': practitioner})
|
||||
# fee validity created
|
||||
self.assertTrue(fee_validity)
|
||||
|
||||
visited = frappe.db.get_value('Fee Validity', fee_validity, 'visited')
|
||||
# first follow up appointment
|
||||
appointment = create_appointment(patient, practitioner, add_days(nowdate(), 1))
|
||||
self.assertEqual(frappe.db.get_value('Fee Validity', fee_validity, 'visited'), 1)
|
||||
|
||||
update_status(appointment.name, 'Cancelled')
|
||||
# check fee validity updated
|
||||
self.assertEqual(frappe.db.get_value('Fee Validity', fee_validity, 'visited'), visited - 1)
|
||||
self.assertEqual(frappe.db.get_value('Fee Validity', fee_validity, 'visited'), 0)
|
||||
|
||||
frappe.db.set_value('Healthcare Settings', None, 'enable_free_follow_ups', 0)
|
||||
frappe.db.set_value('Healthcare Settings', None, 'automate_appointment_invoicing', 1)
|
||||
appointment = create_appointment(patient, practitioner, nowdate(), invoice=1)
|
||||
appointment = create_appointment(patient, practitioner, add_days(nowdate(), 1), invoice=1)
|
||||
update_status(appointment.name, 'Cancelled')
|
||||
# check invoice cancelled
|
||||
sales_invoice_name = frappe.db.get_value('Sales Invoice Item', {'reference_dn': appointment.name}, 'parent')
|
||||
|
||||
@@ -243,7 +243,7 @@ doc_events = {
|
||||
"on_update": ["erpnext.hr.doctype.employee.employee.update_user_permissions",
|
||||
"erpnext.portal.utils.set_default_role"]
|
||||
},
|
||||
("Sales Taxes and Charges Template", 'Price List'): {
|
||||
"Sales Taxes and Charges Template": {
|
||||
"on_update": "erpnext.shopping_cart.doctype.shopping_cart_settings.shopping_cart_settings.validate_cart_settings"
|
||||
},
|
||||
"Website Settings": {
|
||||
|
||||
@@ -446,6 +446,11 @@ var get_bom_material_detail = function(doc, cdt, cdn, scrap_items) {
|
||||
},
|
||||
callback: function(r) {
|
||||
d = locals[cdt][cdn];
|
||||
if (d.is_process_loss) {
|
||||
r.message.rate = 0;
|
||||
r.message.base_rate = 0;
|
||||
}
|
||||
|
||||
$.extend(d, r.message);
|
||||
refresh_field("items");
|
||||
refresh_field("scrap_items");
|
||||
@@ -655,3 +660,35 @@ frappe.ui.form.on("BOM", "with_operations", function(frm) {
|
||||
frm.set_value("operations", []);
|
||||
}
|
||||
});
|
||||
|
||||
frappe.ui.form.on("BOM Scrap Item", {
|
||||
item_code(frm, cdt, cdn) {
|
||||
const { item_code } = locals[cdt][cdn];
|
||||
if (item_code === frm.doc.item) {
|
||||
locals[cdt][cdn].is_process_loss = 1;
|
||||
trigger_process_loss_qty_prompt(frm, cdt, cdn, item_code);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function trigger_process_loss_qty_prompt(frm, cdt, cdn, item_code) {
|
||||
frappe.prompt(
|
||||
{
|
||||
fieldname: "percent",
|
||||
fieldtype: "Percent",
|
||||
label: __("% Finished Item Quantity"),
|
||||
description:
|
||||
__("Set quantity of process loss item:") +
|
||||
` ${item_code} ` +
|
||||
__("as a percentage of finished item quantity"),
|
||||
},
|
||||
(data) => {
|
||||
const row = locals[cdt][cdn];
|
||||
row.stock_qty = (frm.doc.quantity * data.percent) / 100;
|
||||
row.qty = row.stock_qty / (row.conversion_factor || 1);
|
||||
refresh_field("scrap_items");
|
||||
},
|
||||
__("Set Process Loss Item Quantity"),
|
||||
__("Set Quantity")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -154,9 +154,11 @@ class BOM(WebsiteGenerator):
|
||||
self.validate_operations()
|
||||
self.calculate_cost()
|
||||
self.update_stock_qty()
|
||||
self.validate_scrap_items()
|
||||
self.update_cost(update_parent=False, from_child_bom=True, update_hour_rate = False, save=False)
|
||||
self.set_bom_level()
|
||||
|
||||
|
||||
def get_context(self, context):
|
||||
context.parents = [{'name': 'boms', 'title': _('All BOMs') }]
|
||||
|
||||
@@ -230,7 +232,7 @@ class BOM(WebsiteGenerator):
|
||||
}
|
||||
ret = self.get_bom_material_detail(args)
|
||||
for key, value in ret.items():
|
||||
if not item.get(key):
|
||||
if item.get(key) is None:
|
||||
item.set(key, value)
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -687,6 +689,33 @@ class BOM(WebsiteGenerator):
|
||||
if not d.batch_size or d.batch_size <= 0:
|
||||
d.batch_size = 1
|
||||
|
||||
|
||||
def validate_scrap_items(self):
|
||||
for item in self.scrap_items:
|
||||
msg = ""
|
||||
if item.item_code == self.item and not item.is_process_loss:
|
||||
msg = _('Scrap/Loss Item: {0} should have Is Process Loss checked as it is the same as the item to be manufactured or repacked.') \
|
||||
.format(frappe.bold(item.item_code))
|
||||
elif item.item_code != self.item and item.is_process_loss:
|
||||
msg = _('Scrap/Loss Item: {0} should not have Is Process Loss checked as it is different from the item to be manufactured or repacked') \
|
||||
.format(frappe.bold(item.item_code))
|
||||
|
||||
must_be_whole_number = frappe.get_value("UOM", item.stock_uom, "must_be_whole_number")
|
||||
if item.is_process_loss and must_be_whole_number:
|
||||
msg = _("Item: {0} with Stock UOM: {1} cannot be a Scrap/Loss Item as {1} is a whole UOM.") \
|
||||
.format(frappe.bold(item.item_code), frappe.bold(item.stock_uom))
|
||||
|
||||
if item.is_process_loss and (item.stock_qty >= self.quantity):
|
||||
msg = _("Scrap/Loss Item: {0} should have Qty less than finished goods Quantity.") \
|
||||
.format(frappe.bold(item.item_code))
|
||||
|
||||
if item.is_process_loss and (item.rate > 0):
|
||||
msg = _("Scrap/Loss Item: {0} should have Rate set to 0 because Is Process Loss is checked.") \
|
||||
.format(frappe.bold(item.item_code))
|
||||
|
||||
if msg:
|
||||
frappe.throw(msg, title=_("Note"))
|
||||
|
||||
def get_tree_representation(self) -> BOMTree:
|
||||
"""Get a complete tree representation preserving order of child items."""
|
||||
return BOMTree(self.name)
|
||||
@@ -822,8 +851,11 @@ def get_bom_items_as_dict(bom, company, qty=1, fetch_exploded=1, fetch_scrap_ite
|
||||
|
||||
items = frappe.db.sql(query, { "parent": bom, "qty": qty, "bom": bom, "company": company }, as_dict=True)
|
||||
elif fetch_scrap_items:
|
||||
query = query.format(table="BOM Scrap Item", where_conditions="",
|
||||
select_columns=", bom_item.idx, item.description", is_stock_item=is_stock_item, qty_field="stock_qty")
|
||||
query = query.format(
|
||||
table="BOM Scrap Item", where_conditions="",
|
||||
select_columns=", bom_item.idx, item.description, is_process_loss",
|
||||
is_stock_item=is_stock_item, qty_field="stock_qty"
|
||||
)
|
||||
|
||||
items = frappe.db.sql(query, { "qty": qty, "bom": bom, "company": company }, as_dict=True)
|
||||
else:
|
||||
|
||||
@@ -226,6 +226,40 @@ class TestBOM(unittest.TestCase):
|
||||
supplied_items = sorted([d.rm_item_code for d in po.supplied_items])
|
||||
self.assertEqual(bom_items, supplied_items)
|
||||
|
||||
|
||||
def test_bom_with_process_loss_item(self):
|
||||
fg_item_non_whole, fg_item_whole, bom_item = create_process_loss_bom_items()
|
||||
|
||||
if not frappe.db.exists("BOM", f"BOM-{fg_item_non_whole.item_code}-001"):
|
||||
bom_doc = create_bom_with_process_loss_item(
|
||||
fg_item_non_whole, bom_item, scrap_qty=0.25, scrap_rate=0, fg_qty=1
|
||||
)
|
||||
bom_doc.submit()
|
||||
|
||||
bom_doc = create_bom_with_process_loss_item(
|
||||
fg_item_non_whole, bom_item, scrap_qty=2, scrap_rate=0
|
||||
)
|
||||
# PL Item qty can't be >= FG Item qty
|
||||
self.assertRaises(frappe.ValidationError, bom_doc.submit)
|
||||
|
||||
bom_doc = create_bom_with_process_loss_item(
|
||||
fg_item_non_whole, bom_item, scrap_qty=1, scrap_rate=100
|
||||
)
|
||||
# PL Item rate has to be 0
|
||||
self.assertRaises(frappe.ValidationError, bom_doc.submit)
|
||||
|
||||
bom_doc = create_bom_with_process_loss_item(
|
||||
fg_item_whole, bom_item, scrap_qty=0.25, scrap_rate=0
|
||||
)
|
||||
# Items with whole UOMs can't be PL Items
|
||||
self.assertRaises(frappe.ValidationError, bom_doc.submit)
|
||||
|
||||
bom_doc = create_bom_with_process_loss_item(
|
||||
fg_item_non_whole, bom_item, scrap_qty=0.25, scrap_rate=0, is_process_loss=0
|
||||
)
|
||||
# FG Items in Scrap/Loss Table should have Is Process Loss set
|
||||
self.assertRaises(frappe.ValidationError, bom_doc.submit)
|
||||
|
||||
def test_bom_tree_representation(self):
|
||||
bom_tree = {
|
||||
"Assembly": {
|
||||
@@ -248,13 +282,9 @@ class TestBOM(unittest.TestCase):
|
||||
for reqd_item, created_item in zip(reqd_order, created_order):
|
||||
self.assertEqual(reqd_item, created_item.item_code)
|
||||
|
||||
|
||||
def get_default_bom(item_code="_Test FG Item 2"):
|
||||
return frappe.db.get_value("BOM", {"item": item_code, "is_active": 1, "is_default": 1})
|
||||
|
||||
|
||||
|
||||
|
||||
def level_order_traversal(node):
|
||||
traversal = []
|
||||
q = deque()
|
||||
@@ -300,6 +330,7 @@ def create_nested_bom(tree, prefix="_Test bom "):
|
||||
bom = frappe.get_doc(doctype="BOM", item=bom_item_code)
|
||||
for child_item in child_items.keys():
|
||||
bom.append("items", {"item_code": prefix + child_item})
|
||||
bom.currency = "INR"
|
||||
bom.insert()
|
||||
bom.submit()
|
||||
|
||||
@@ -321,3 +352,45 @@ def reset_item_valuation_rate(item_code, warehouse_list=None, qty=None, rate=Non
|
||||
|
||||
for warehouse in warehouse_list:
|
||||
create_stock_reconciliation(item_code=item_code, warehouse=warehouse, qty=qty, rate=rate)
|
||||
|
||||
def create_bom_with_process_loss_item(
|
||||
fg_item, bom_item, scrap_qty, scrap_rate, fg_qty=2, is_process_loss=1):
|
||||
bom_doc = frappe.new_doc("BOM")
|
||||
bom_doc.item = fg_item.item_code
|
||||
bom_doc.quantity = fg_qty
|
||||
bom_doc.append("items", {
|
||||
"item_code": bom_item.item_code,
|
||||
"qty": 1,
|
||||
"uom": bom_item.stock_uom,
|
||||
"stock_uom": bom_item.stock_uom,
|
||||
"rate": 100.0
|
||||
})
|
||||
bom_doc.append("scrap_items", {
|
||||
"item_code": fg_item.item_code,
|
||||
"qty": scrap_qty,
|
||||
"stock_qty": scrap_qty,
|
||||
"uom": fg_item.stock_uom,
|
||||
"stock_uom": fg_item.stock_uom,
|
||||
"rate": scrap_rate,
|
||||
"is_process_loss": is_process_loss
|
||||
})
|
||||
bom_doc.currency = "INR"
|
||||
return bom_doc
|
||||
|
||||
def create_process_loss_bom_items():
|
||||
item_list = [
|
||||
("_Test Item - Non Whole UOM", "Kg"),
|
||||
("_Test Item - Whole UOM", "Unit"),
|
||||
("_Test PL BOM Item", "Unit")
|
||||
]
|
||||
return [create_process_loss_bom_item(it) for it in item_list]
|
||||
|
||||
def create_process_loss_bom_item(item_tuple):
|
||||
item_code, stock_uom = item_tuple
|
||||
if frappe.db.exists("Item", item_code) is None:
|
||||
return make_item(
|
||||
item_code,
|
||||
{'stock_uom':stock_uom, 'valuation_rate':100}
|
||||
)
|
||||
else:
|
||||
return frappe.get_doc("Item", item_code)
|
||||
|
||||
@@ -1,345 +1,112 @@
|
||||
{
|
||||
"allow_copy": 0,
|
||||
"allow_guest_to_view": 0,
|
||||
"allow_import": 0,
|
||||
"allow_rename": 0,
|
||||
"beta": 0,
|
||||
"actions": [],
|
||||
"creation": "2016-09-26 02:19:21.642081",
|
||||
"custom": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "DocType",
|
||||
"document_type": "",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"item_code",
|
||||
"column_break_2",
|
||||
"item_name",
|
||||
"is_process_loss",
|
||||
"quantity_and_rate",
|
||||
"stock_qty",
|
||||
"rate",
|
||||
"amount",
|
||||
"column_break_6",
|
||||
"stock_uom",
|
||||
"base_rate",
|
||||
"base_amount"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"allow_bulk_edit": 0,
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"fieldname": "item_code",
|
||||
"fieldtype": "Link",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_filter": 0,
|
||||
"in_global_search": 0,
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 0,
|
||||
"label": "Item Code",
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"options": "Item",
|
||||
"permlevel": 0,
|
||||
"precision": "",
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"read_only": 0,
|
||||
"remember_last_selected_value": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 1,
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"unique": 0
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"allow_bulk_edit": 0,
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"fieldname": "item_name",
|
||||
"fieldtype": "Data",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_filter": 0,
|
||||
"in_global_search": 0,
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 0,
|
||||
"label": "Item Name",
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"permlevel": 0,
|
||||
"precision": "",
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"read_only": 0,
|
||||
"remember_last_selected_value": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 0,
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"unique": 0
|
||||
"label": "Item Name"
|
||||
},
|
||||
{
|
||||
"allow_bulk_edit": 0,
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"fieldname": "quantity_and_rate",
|
||||
"fieldtype": "Section Break",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_filter": 0,
|
||||
"in_global_search": 0,
|
||||
"in_list_view": 0,
|
||||
"in_standard_filter": 0,
|
||||
"label": "Quantity and Rate",
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"permlevel": 0,
|
||||
"precision": "",
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"read_only": 0,
|
||||
"remember_last_selected_value": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 0,
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"unique": 0
|
||||
"label": "Quantity and Rate"
|
||||
},
|
||||
{
|
||||
"allow_bulk_edit": 0,
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"fieldname": "stock_qty",
|
||||
"fieldtype": "Float",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_filter": 0,
|
||||
"in_global_search": 0,
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 0,
|
||||
"label": "Qty",
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"permlevel": 0,
|
||||
"precision": "",
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"read_only": 0,
|
||||
"remember_last_selected_value": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 1,
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"unique": 0
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"allow_bulk_edit": 0,
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"fieldname": "rate",
|
||||
"fieldtype": "Currency",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_filter": 0,
|
||||
"in_global_search": 0,
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 0,
|
||||
"label": "Rate",
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"options": "currency",
|
||||
"permlevel": 0,
|
||||
"precision": "",
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"read_only": 0,
|
||||
"remember_last_selected_value": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 0,
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"unique": 0
|
||||
"options": "currency"
|
||||
},
|
||||
{
|
||||
"allow_bulk_edit": 0,
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"fieldname": "amount",
|
||||
"fieldtype": "Currency",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_filter": 0,
|
||||
"in_global_search": 0,
|
||||
"in_list_view": 0,
|
||||
"in_standard_filter": 0,
|
||||
"label": "Amount",
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"options": "currency",
|
||||
"permlevel": 0,
|
||||
"precision": "",
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"read_only": 1,
|
||||
"remember_last_selected_value": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 0,
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"unique": 0
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"allow_bulk_edit": 0,
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"fieldname": "column_break_6",
|
||||
"fieldtype": "Column Break",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_filter": 0,
|
||||
"in_global_search": 0,
|
||||
"in_list_view": 0,
|
||||
"in_standard_filter": 0,
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"permlevel": 0,
|
||||
"precision": "",
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"read_only": 0,
|
||||
"remember_last_selected_value": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 0,
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"unique": 0
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"allow_bulk_edit": 0,
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"fieldname": "stock_uom",
|
||||
"fieldtype": "Link",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_filter": 0,
|
||||
"in_global_search": 0,
|
||||
"in_list_view": 0,
|
||||
"in_standard_filter": 0,
|
||||
"label": "Stock UOM",
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"options": "UOM",
|
||||
"permlevel": 0,
|
||||
"precision": "",
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"read_only": 1,
|
||||
"remember_last_selected_value": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 0,
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"unique": 0
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"allow_bulk_edit": 0,
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"fieldname": "base_rate",
|
||||
"fieldtype": "Currency",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_filter": 0,
|
||||
"in_global_search": 0,
|
||||
"in_list_view": 0,
|
||||
"in_standard_filter": 0,
|
||||
"label": "Basic Rate (Company Currency)",
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"options": "Company:company:default_currency",
|
||||
"permlevel": 0,
|
||||
"precision": "",
|
||||
"print_hide": 1,
|
||||
"print_hide_if_no_value": 0,
|
||||
"read_only": 1,
|
||||
"remember_last_selected_value": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 0,
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"unique": 0
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"allow_bulk_edit": 0,
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"fieldname": "base_amount",
|
||||
"fieldtype": "Currency",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_filter": 0,
|
||||
"in_global_search": 0,
|
||||
"in_list_view": 0,
|
||||
"in_standard_filter": 0,
|
||||
"label": "Basic Amount (Company Currency)",
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"options": "Company:company:default_currency",
|
||||
"permlevel": 0,
|
||||
"precision": "",
|
||||
"print_hide": 1,
|
||||
"print_hide_if_no_value": 0,
|
||||
"read_only": 1,
|
||||
"remember_last_selected_value": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 0,
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"unique": 0
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_2",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "is_process_loss",
|
||||
"fieldtype": "Check",
|
||||
"label": "Is Process Loss"
|
||||
}
|
||||
],
|
||||
"has_web_view": 0,
|
||||
"hide_heading": 0,
|
||||
"hide_toolbar": 0,
|
||||
"idx": 0,
|
||||
"image_view": 0,
|
||||
"in_create": 0,
|
||||
"is_submittable": 0,
|
||||
"issingle": 0,
|
||||
"istable": 1,
|
||||
"max_attachments": 0,
|
||||
"modified": "2017-07-04 16:04:32.442287",
|
||||
"links": [],
|
||||
"modified": "2021-06-22 16:46:12.153311",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "BOM Scrap Item",
|
||||
"name_case": "",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"quick_entry": 1,
|
||||
"read_only": 0,
|
||||
"read_only_onload": 0,
|
||||
"show_name_in_global_search": 0,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"track_changes": 1,
|
||||
"track_seen": 0
|
||||
"track_changes": 1
|
||||
}
|
||||
@@ -690,6 +690,71 @@ class TestWorkOrder(unittest.TestCase):
|
||||
|
||||
self.assertRaises(frappe.ValidationError, make_stock_entry, wo.name, 'Material Transfer for Manufacture')
|
||||
|
||||
def test_wo_completion_with_pl_bom(self):
|
||||
from erpnext.manufacturing.doctype.bom.test_bom import create_process_loss_bom_items
|
||||
from erpnext.manufacturing.doctype.bom.test_bom import create_bom_with_process_loss_item
|
||||
|
||||
qty = 4
|
||||
scrap_qty = 0.25 # bom item qty = 1, consider as 25% of FG
|
||||
source_warehouse = "Stores - _TC"
|
||||
wip_warehouse = "_Test Warehouse - _TC"
|
||||
fg_item_non_whole, _, bom_item = create_process_loss_bom_items()
|
||||
|
||||
test_stock_entry.make_stock_entry(item_code=bom_item.item_code,
|
||||
target=source_warehouse, qty=4, basic_rate=100)
|
||||
|
||||
bom_no = f"BOM-{fg_item_non_whole.item_code}-001"
|
||||
if not frappe.db.exists("BOM", bom_no):
|
||||
bom_doc = create_bom_with_process_loss_item(
|
||||
fg_item_non_whole, bom_item, scrap_qty=scrap_qty,
|
||||
scrap_rate=0, fg_qty=1, is_process_loss=1
|
||||
)
|
||||
bom_doc.submit()
|
||||
|
||||
wo = make_wo_order_test_record(
|
||||
production_item=fg_item_non_whole.item_code,
|
||||
bom_no=bom_no,
|
||||
wip_warehouse=wip_warehouse,
|
||||
qty=qty,
|
||||
skip_transfer=1,
|
||||
stock_uom=fg_item_non_whole.stock_uom,
|
||||
)
|
||||
|
||||
se = frappe.get_doc(
|
||||
make_stock_entry(wo.name, "Material Transfer for Manufacture", qty)
|
||||
)
|
||||
se.get("items")[0].s_warehouse = "Stores - _TC"
|
||||
se.insert()
|
||||
se.submit()
|
||||
|
||||
se = frappe.get_doc(
|
||||
make_stock_entry(wo.name, "Manufacture", qty)
|
||||
)
|
||||
se.insert()
|
||||
se.submit()
|
||||
|
||||
# Testing stock entry values
|
||||
items = se.get("items")
|
||||
self.assertEqual(len(items), 3, "There should be 3 items including process loss.")
|
||||
|
||||
source_item, fg_item, pl_item = items
|
||||
|
||||
total_pl_qty = qty * scrap_qty
|
||||
actual_fg_qty = qty - total_pl_qty
|
||||
|
||||
self.assertEqual(pl_item.qty, total_pl_qty)
|
||||
self.assertEqual(fg_item.qty, actual_fg_qty)
|
||||
|
||||
# Testing Work Order values
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Work Order", wo.name, "produced_qty"),
|
||||
qty
|
||||
)
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Work Order", wo.name, "process_loss_qty"),
|
||||
total_pl_qty
|
||||
)
|
||||
|
||||
def get_scrap_item_details(bom_no):
|
||||
scrap_items = {}
|
||||
for item in frappe.db.sql("""select item_code, stock_qty from `tabBOM Scrap Item`
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"qty",
|
||||
"material_transferred_for_manufacturing",
|
||||
"produced_qty",
|
||||
"process_loss_qty",
|
||||
"sales_order",
|
||||
"project",
|
||||
"serial_no_and_batch_for_finished_good_section",
|
||||
@@ -64,16 +65,12 @@
|
||||
"description",
|
||||
"stock_uom",
|
||||
"column_break2",
|
||||
"references_section",
|
||||
"material_request",
|
||||
"material_request_item",
|
||||
"sales_order_item",
|
||||
"column_break_61",
|
||||
"production_plan",
|
||||
"production_plan_item",
|
||||
"production_plan_sub_assembly_item",
|
||||
"parent_work_order",
|
||||
"bom_level",
|
||||
"product_bundle_item",
|
||||
"amended_from"
|
||||
],
|
||||
@@ -559,6 +556,15 @@
|
||||
"no_copy": 1,
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval: doc.process_loss_qty",
|
||||
"fieldname": "process_loss_qty",
|
||||
"fieldtype": "Float",
|
||||
"label": "Process Loss Qty",
|
||||
"no_copy": 1,
|
||||
"non_negative": 1,
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-cogs",
|
||||
@@ -566,7 +572,7 @@
|
||||
"image_field": "image",
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2021-06-28 16:19:14.902699",
|
||||
"modified": "2021-08-24 15:14:03.844937",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Work Order",
|
||||
|
||||
@@ -214,6 +214,7 @@ class WorkOrder(Document):
|
||||
self.meta.get_label(fieldname), qty, completed_qty, self.name), StockOverProductionError)
|
||||
|
||||
self.db_set(fieldname, qty)
|
||||
self.set_process_loss_qty()
|
||||
|
||||
from erpnext.selling.doctype.sales_order.sales_order import update_produced_qty_in_so_item
|
||||
|
||||
@@ -223,6 +224,22 @@ class WorkOrder(Document):
|
||||
if self.production_plan:
|
||||
self.update_production_plan_status()
|
||||
|
||||
def set_process_loss_qty(self):
|
||||
process_loss_qty = flt(frappe.db.sql("""
|
||||
SELECT sum(qty) FROM `tabStock Entry Detail`
|
||||
WHERE
|
||||
is_process_loss=1
|
||||
AND parent IN (
|
||||
SELECT name FROM `tabStock Entry`
|
||||
WHERE
|
||||
work_order=%s
|
||||
AND purpose='Manufacture'
|
||||
AND docstatus=1
|
||||
)
|
||||
""", (self.name, ))[0][0])
|
||||
if process_loss_qty is not None:
|
||||
self.db_set('process_loss_qty', process_loss_qty)
|
||||
|
||||
def update_production_plan_status(self):
|
||||
production_plan = frappe.get_doc('Production Plan', self.production_plan)
|
||||
produced_qty = 0
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
from __future__ import unicode_literals
|
||||
import frappe
|
||||
from unittest import TestCase
|
||||
from frappe.utils import today
|
||||
|
||||
from erpnext.accounts.doctype.account.test_account import create_account
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
||||
|
||||
from erpnext.regional.report.vat_audit_report.vat_audit_report import execute
|
||||
|
||||
class TestVATAuditReport(TestCase):
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
make_company("_Test Company SA VAT", "_TCSV")
|
||||
|
||||
create_account(account_name="VAT - 0%", account_type="Tax",
|
||||
parent_account="Duties and Taxes - _TCSV", company="_Test Company SA VAT")
|
||||
create_account(account_name="VAT - 15%", account_type="Tax",
|
||||
parent_account="Duties and Taxes - _TCSV", company="_Test Company SA VAT")
|
||||
set_sa_vat_accounts()
|
||||
|
||||
make_item("_Test SA VAT Item")
|
||||
make_item("_Test SA VAT Zero Rated Item", properties = {"is_zero_rated": 1})
|
||||
|
||||
make_customer()
|
||||
make_supplier()
|
||||
|
||||
make_sales_invoices()
|
||||
create_purchase_invoices()
|
||||
|
||||
def tearDown(self):
|
||||
frappe.db.sql("delete from `tabSales Invoice` where company='_Test Company SA VAT'")
|
||||
frappe.db.sql("delete from `tabPurchase Invoice` where company='_Test Company SA VAT'")
|
||||
|
||||
def test_vat_audit_report(self):
|
||||
filters = {
|
||||
"company": "_Test Company SA VAT",
|
||||
"from_date": today(),
|
||||
"to_date": today()
|
||||
}
|
||||
columns, data = execute(filters)
|
||||
total_tax_amount = 0
|
||||
total_row_tax = 0
|
||||
for row in data:
|
||||
keys = row.keys()
|
||||
# skips total row tax_amount in if.. and skips section header in elif..
|
||||
if 'voucher_no' in keys:
|
||||
total_tax_amount = total_tax_amount + row['tax_amount']
|
||||
elif 'tax_amount' in keys:
|
||||
total_row_tax = total_row_tax + row['tax_amount']
|
||||
|
||||
self.assertEqual(total_tax_amount, total_row_tax)
|
||||
|
||||
def make_company(company_name, abbr):
|
||||
if not frappe.db.exists("Company", company_name):
|
||||
company = frappe.get_doc({
|
||||
"doctype": "Company",
|
||||
"company_name": company_name,
|
||||
"abbr": abbr,
|
||||
"default_currency": "ZAR",
|
||||
"country": "South Africa",
|
||||
"create_chart_of_accounts_based_on": "Standard Template"
|
||||
})
|
||||
company.insert()
|
||||
else:
|
||||
company = frappe.get_doc("Company", company_name)
|
||||
|
||||
company.create_default_warehouses()
|
||||
|
||||
if not frappe.db.get_value("Cost Center", {"is_group": 0, "company": company.name}):
|
||||
company.create_default_cost_center()
|
||||
|
||||
company.save()
|
||||
|
||||
return company
|
||||
|
||||
def set_sa_vat_accounts():
|
||||
if not frappe.db.exists("South Africa VAT Settings", "_Test Company SA VAT"):
|
||||
vat_accounts = frappe.get_all(
|
||||
"Account",
|
||||
fields=["name"],
|
||||
filters = {
|
||||
"company": "_Test Company SA VAT",
|
||||
"is_group": 0,
|
||||
"account_type": "Tax"
|
||||
}
|
||||
)
|
||||
|
||||
sa_vat_accounts = []
|
||||
for account in vat_accounts:
|
||||
sa_vat_accounts.append({
|
||||
"doctype": "South Africa VAT Account",
|
||||
"account": account.name
|
||||
})
|
||||
|
||||
frappe.get_doc({
|
||||
"company": "_Test Company SA VAT",
|
||||
"vat_accounts": sa_vat_accounts,
|
||||
"doctype": "South Africa VAT Settings",
|
||||
}).insert()
|
||||
|
||||
def make_customer():
|
||||
if not frappe.db.exists("Customer", "_Test SA Customer"):
|
||||
frappe.get_doc({
|
||||
"doctype": "Customer",
|
||||
"customer_name": "_Test SA Customer",
|
||||
"customer_type": "Company",
|
||||
}).insert()
|
||||
|
||||
def make_supplier():
|
||||
if not frappe.db.exists("Supplier", "_Test SA Supplier"):
|
||||
frappe.get_doc({
|
||||
"doctype": "Supplier",
|
||||
"supplier_name": "_Test SA Supplier",
|
||||
"supplier_type": "Company",
|
||||
"supplier_group":"All Supplier Groups"
|
||||
}).insert()
|
||||
|
||||
def make_item(item_code, properties=None):
|
||||
if not frappe.db.exists("Item", item_code):
|
||||
item = frappe.get_doc({
|
||||
"doctype": "Item",
|
||||
"item_code": item_code,
|
||||
"item_name": item_code,
|
||||
"description": item_code,
|
||||
"item_group": "Products"
|
||||
})
|
||||
|
||||
if properties:
|
||||
item.update(properties)
|
||||
|
||||
item.insert()
|
||||
|
||||
def make_sales_invoices():
|
||||
def make_sales_invoices_wrapper(item, rate, tax_account, tax_rate, tax=True):
|
||||
si = create_sales_invoice(
|
||||
company="_Test Company SA VAT",
|
||||
customer = "_Test SA Customer",
|
||||
currency = "ZAR",
|
||||
item=item,
|
||||
rate=rate,
|
||||
warehouse = "Finished Goods - _TCSV",
|
||||
debit_to = "Debtors - _TCSV",
|
||||
income_account = "Sales - _TCSV",
|
||||
expense_account = "Cost of Goods Sold - _TCSV",
|
||||
cost_center = "Main - _TCSV",
|
||||
do_not_save=1
|
||||
)
|
||||
if tax:
|
||||
si.append("taxes", {
|
||||
"charge_type": "On Net Total",
|
||||
"account_head": tax_account,
|
||||
"cost_center": "Main - _TCSV",
|
||||
"description": "VAT 15% @ 15.0",
|
||||
"rate": tax_rate
|
||||
})
|
||||
|
||||
si.submit()
|
||||
|
||||
test_item = "_Test SA VAT Item"
|
||||
test_zero_rated_item = "_Test SA VAT Zero Rated Item"
|
||||
|
||||
make_sales_invoices_wrapper(test_item, 100.0, "VAT - 15% - _TCSV", 15.0)
|
||||
make_sales_invoices_wrapper(test_zero_rated_item, 100.0, "VAT - 0% - _TCSV", 0.0)
|
||||
|
||||
def create_purchase_invoices():
|
||||
pi = make_purchase_invoice(
|
||||
company = "_Test Company SA VAT",
|
||||
supplier = "_Test SA Supplier",
|
||||
supplier_warehouse = "Finished Goods - _TCSV",
|
||||
warehouse = "Finished Goods - _TCSV",
|
||||
currency = "ZAR",
|
||||
cost_center = "Main - _TCSV",
|
||||
expense_account = "Cost of Goods Sold - _TCSV",
|
||||
item = "_Test SA VAT Item",
|
||||
qty = 1,
|
||||
rate = 100,
|
||||
uom = "Nos",
|
||||
do_not_save = 1
|
||||
)
|
||||
pi.append("taxes", {
|
||||
"charge_type": "On Net Total",
|
||||
"account_head": "VAT - 15% - _TCSV",
|
||||
"cost_center": "Main - _TCSV",
|
||||
"description": "VAT 15% @ 15.0",
|
||||
"rate": 15.0
|
||||
})
|
||||
|
||||
pi.submit()
|
||||
@@ -1,11 +1,11 @@
|
||||
# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
from __future__ import unicode_literals
|
||||
import frappe
|
||||
import json
|
||||
from frappe import _
|
||||
from frappe.utils import formatdate
|
||||
from frappe.utils import formatdate, get_link_to_form
|
||||
|
||||
def execute(filters=None):
|
||||
return VATAuditReport(filters).run()
|
||||
@@ -42,7 +42,8 @@ class VATAuditReport(object):
|
||||
self.sa_vat_accounts = frappe.get_list("South Africa VAT Account",
|
||||
filters = {"parent": self.filters.company}, pluck="account")
|
||||
if not self.sa_vat_accounts and not frappe.flags.in_test and not frappe.flags.in_migrate:
|
||||
frappe.throw(_("Please set VAT Accounts in South Africa VAT Settings"))
|
||||
link_to_settings = get_link_to_form("South Africa VAT Settings", "", label="South Africa VAT Settings")
|
||||
frappe.throw(_("Please set VAT Accounts in {0}").format(link_to_settings))
|
||||
|
||||
def get_invoice_data(self, doctype):
|
||||
conditions = self.get_conditions()
|
||||
@@ -69,7 +70,7 @@ class VATAuditReport(object):
|
||||
|
||||
items = frappe.db.sql("""
|
||||
SELECT
|
||||
item_code, parent, taxable_value, base_net_amount, is_zero_rated
|
||||
item_code, parent, base_net_amount, is_zero_rated
|
||||
FROM
|
||||
`tab%s Item`
|
||||
WHERE
|
||||
@@ -79,7 +80,7 @@ class VATAuditReport(object):
|
||||
if d.item_code not in self.invoice_items.get(d.parent, {}):
|
||||
self.invoice_items.setdefault(d.parent, {}).setdefault(d.item_code, {
|
||||
'net_amount': 0.0})
|
||||
self.invoice_items[d.parent][d.item_code]['net_amount'] += d.get('taxable_value', 0) or d.get('base_net_amount', 0)
|
||||
self.invoice_items[d.parent][d.item_code]['net_amount'] += d.get('base_net_amount', 0)
|
||||
self.invoice_items[d.parent][d.item_code]['is_zero_rated'] = d.is_zero_rated
|
||||
|
||||
def get_items_based_on_tax_rate(self, doctype):
|
||||
|
||||
@@ -352,3 +352,26 @@ def set_credit_limit(customer, company, credit_limit):
|
||||
'credit_limit': credit_limit
|
||||
})
|
||||
customer.credit_limits[-1].db_insert()
|
||||
|
||||
def create_internal_customer(customer_name, represents_company, allowed_to_interact_with):
|
||||
if not frappe.db.exists("Customer", customer_name):
|
||||
customer = frappe.get_doc({
|
||||
"doctype": "Customer",
|
||||
"customer_group": "_Test Customer Group",
|
||||
"customer_name": customer_name,
|
||||
"customer_type": "Individual",
|
||||
"territory": "_Test Territory",
|
||||
"is_internal_customer": 1,
|
||||
"represents_company": represents_company
|
||||
})
|
||||
|
||||
customer.append("companies", {
|
||||
"company": allowed_to_interact_with
|
||||
})
|
||||
|
||||
customer.insert()
|
||||
customer_name = customer.name
|
||||
else:
|
||||
customer_name = frappe.db.get_value("Customer", customer_name)
|
||||
|
||||
return customer_name
|
||||
@@ -6,7 +6,7 @@
|
||||
from __future__ import unicode_literals
|
||||
import frappe
|
||||
from frappe import _, msgprint
|
||||
from frappe.utils import comma_and
|
||||
from frappe.utils import flt
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import get_datetime, get_datetime_str, now_datetime
|
||||
|
||||
@@ -18,46 +18,35 @@ class ShoppingCartSettings(Document):
|
||||
|
||||
def validate(self):
|
||||
if self.enabled:
|
||||
self.validate_exchange_rates_exist()
|
||||
self.validate_price_list_exchange_rate()
|
||||
|
||||
def validate_price_list_exchange_rate(self):
|
||||
"Check if exchange rate exists for Price List currency (to Company's currency)."
|
||||
from erpnext.setup.utils import get_exchange_rate
|
||||
|
||||
if not self.enabled or not self.company or not self.price_list:
|
||||
return # this function is also called from hooks, check values again
|
||||
|
||||
company_currency = frappe.get_cached_value("Company", self.company, "default_currency")
|
||||
price_list_currency = frappe.db.get_value("Price List", self.price_list, "currency")
|
||||
|
||||
def validate_exchange_rates_exist(self):
|
||||
"""check if exchange rates exist for all Price List currencies (to company's currency)"""
|
||||
company_currency = frappe.get_cached_value('Company', self.company, "default_currency")
|
||||
if not company_currency:
|
||||
msgprint(_("Please specify currency in Company") + ": " + self.company,
|
||||
raise_exception=ShoppingCartSetupError)
|
||||
msg = f"Please specify currency in Company {self.company}"
|
||||
frappe.throw(_(msg), title=_("Missing Currency"), exc=ShoppingCartSetupError)
|
||||
|
||||
price_list_currency_map = frappe.db.get_values("Price List",
|
||||
[self.price_list], "currency")
|
||||
if not price_list_currency:
|
||||
msg = f"Please specify currency in Price List {frappe.bold(self.price_list)}"
|
||||
frappe.throw(_(msg), title=_("Missing Currency"), exc=ShoppingCartSetupError)
|
||||
|
||||
price_list_currency_map = dict(price_list_currency_map)
|
||||
if price_list_currency != company_currency:
|
||||
from_currency, to_currency = price_list_currency, company_currency
|
||||
|
||||
# check if all price lists have a currency
|
||||
for price_list, currency in price_list_currency_map.items():
|
||||
if not currency:
|
||||
frappe.throw(_("Currency is required for Price List {0}").format(price_list))
|
||||
# Get exchange rate checks Currency Exchange Records too
|
||||
exchange_rate = get_exchange_rate(from_currency, to_currency, args="for_selling")
|
||||
|
||||
expected_to_exist = [currency + "-" + company_currency
|
||||
for currency in price_list_currency_map.values()
|
||||
if currency != company_currency]
|
||||
|
||||
# manqala 20/09/2016: set up selection parameters for query from tabCurrency Exchange
|
||||
from_currency = [currency for currency in price_list_currency_map.values() if currency != company_currency]
|
||||
to_currency = company_currency
|
||||
# manqala end
|
||||
|
||||
if expected_to_exist:
|
||||
# manqala 20/09/2016: modify query so that it uses date in the selection from Currency Exchange.
|
||||
# exchange rates defined with date less than the date on which this document is being saved will be selected
|
||||
exists = frappe.db.sql_list("""select CONCAT(from_currency,'-',to_currency) from `tabCurrency Exchange`
|
||||
where from_currency in (%s) and to_currency = "%s" and date <= curdate()""" % (", ".join(["%s"]*len(from_currency)), to_currency), tuple(from_currency))
|
||||
# manqala end
|
||||
|
||||
missing = list(set(expected_to_exist).difference(exists))
|
||||
|
||||
if missing:
|
||||
msgprint(_("Missing Currency Exchange Rates for {0}").format(comma_and(missing)),
|
||||
raise_exception=ShoppingCartSetupError)
|
||||
if not flt(exchange_rate):
|
||||
msg = f"Missing Currency Exchange Rates for {from_currency}-{to_currency}"
|
||||
frappe.throw(_(msg), title=_("Missing"), exc=ShoppingCartSetupError)
|
||||
|
||||
def validate_tax_rule(self):
|
||||
if not frappe.db.get_value("Tax Rule", {"use_for_shopping_cart" : 1}, "name"):
|
||||
@@ -71,7 +60,7 @@ class ShoppingCartSettings(Document):
|
||||
def get_shipping_rules(self, shipping_territory):
|
||||
return self.get_name_from_territory(shipping_territory, "shipping_rules", "shipping_rule")
|
||||
|
||||
def validate_cart_settings(doc, method):
|
||||
def validate_cart_settings(doc=None, method=None):
|
||||
frappe.get_doc("Shopping Cart Settings", "Shopping Cart Settings").run_method("validate")
|
||||
|
||||
def get_shopping_cart_settings():
|
||||
|
||||
@@ -16,17 +16,25 @@ class TestShoppingCartSettings(unittest.TestCase):
|
||||
return frappe.get_doc({"doctype": "Shopping Cart Settings",
|
||||
"company": "_Test Company"})
|
||||
|
||||
def test_exchange_rate_exists(self):
|
||||
frappe.db.sql("""delete from `tabCurrency Exchange`""")
|
||||
# NOTE: Exchangrate API has all enabled currencies that ERPNext supports.
|
||||
# We aren't checking just currency exchange record anymore
|
||||
# while validating price list currency exchange rate to that of company.
|
||||
# The API is being used to fetch the rate which again almost always
|
||||
# gives back a valid value (for valid currencies).
|
||||
# This makes the test obsolete.
|
||||
# Commenting because im not sure if there's a better test we can write
|
||||
|
||||
cart_settings = self.get_cart_settings()
|
||||
cart_settings.price_list = "_Test Price List Rest of the World"
|
||||
self.assertRaises(ShoppingCartSetupError, cart_settings.validate_exchange_rates_exist)
|
||||
# def test_exchange_rate_exists(self):
|
||||
# frappe.db.sql("""delete from `tabCurrency Exchange`""")
|
||||
|
||||
from erpnext.setup.doctype.currency_exchange.test_currency_exchange import test_records as \
|
||||
currency_exchange_records
|
||||
frappe.get_doc(currency_exchange_records[0]).insert()
|
||||
cart_settings.validate_exchange_rates_exist()
|
||||
# cart_settings = self.get_cart_settings()
|
||||
# cart_settings.price_list = "_Test Price List Rest of the World"
|
||||
# self.assertRaises(ShoppingCartSetupError, cart_settings.validate_price_list_exchange_rate)
|
||||
|
||||
# from erpnext.setup.doctype.currency_exchange.test_currency_exchange import test_records as \
|
||||
# currency_exchange_records
|
||||
# frappe.get_doc(currency_exchange_records[0]).insert()
|
||||
# cart_settings.validate_price_list_exchange_rate()
|
||||
|
||||
def test_tax_rule_validation(self):
|
||||
frappe.db.sql("update `tabTax Rule` set use_for_shopping_cart = 0")
|
||||
|
||||
@@ -430,12 +430,19 @@ class TestDeliveryNote(unittest.TestCase):
|
||||
})
|
||||
|
||||
def test_delivery_of_bundled_items_to_target_warehouse(self):
|
||||
from erpnext.selling.doctype.customer.test_customer import create_internal_customer
|
||||
|
||||
company = frappe.db.get_value('Warehouse', 'Stores - TCP1', 'company')
|
||||
customer_name = create_internal_customer(
|
||||
customer_name="_Test Internal Customer 2",
|
||||
represents_company="_Test Company with perpetual inventory",
|
||||
allowed_to_interact_with="_Test Company with perpetual inventory"
|
||||
)
|
||||
|
||||
set_valuation_method("_Test Item", "FIFO")
|
||||
set_valuation_method("_Test Item Home Desktop 100", "FIFO")
|
||||
|
||||
target_warehouse=get_warehouse(company=company, abbr="TCP1",
|
||||
target_warehouse = get_warehouse(company=company, abbr="TCP1",
|
||||
warehouse_name="_Test Customer Warehouse").name
|
||||
|
||||
for warehouse in ("Stores - TCP1", target_warehouse):
|
||||
@@ -444,10 +451,16 @@ class TestDeliveryNote(unittest.TestCase):
|
||||
create_stock_reconciliation(item_code="_Test Item Home Desktop 100", company = company,
|
||||
expense_account = "Stock Adjustment - TCP1", warehouse=warehouse, qty=500, rate=100)
|
||||
|
||||
dn = create_delivery_note(item_code="_Test Product Bundle Item",
|
||||
company='_Test Company with perpetual inventory', cost_center = 'Main - TCP1',
|
||||
expense_account = "Cost of Goods Sold - TCP1", do_not_submit=True, qty=5, rate=500,
|
||||
warehouse="Stores - TCP1", target_warehouse=target_warehouse)
|
||||
dn = create_delivery_note(
|
||||
item_code="_Test Product Bundle Item",
|
||||
company="_Test Company with perpetual inventory",
|
||||
customer=customer_name,
|
||||
cost_center = 'Main - TCP1',
|
||||
expense_account = "Cost of Goods Sold - TCP1",
|
||||
do_not_submit=True,
|
||||
qty=5, rate=500,
|
||||
warehouse="Stores - TCP1",
|
||||
target_warehouse=target_warehouse)
|
||||
|
||||
dn.submit()
|
||||
|
||||
@@ -487,6 +500,9 @@ class TestDeliveryNote(unittest.TestCase):
|
||||
for i, gle in enumerate(gl_entries):
|
||||
self.assertEqual([gle.debit, gle.credit], expected_values.get(gle.account))
|
||||
|
||||
# tear down
|
||||
frappe.db.rollback()
|
||||
|
||||
def test_closed_delivery_note(self):
|
||||
from erpnext.stock.doctype.delivery_note.delivery_note import update_delivery_note_status
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ class PriceList(Document):
|
||||
if not cint(self.buying) and not cint(self.selling):
|
||||
throw(_("Price List must be applicable for Buying or Selling"))
|
||||
|
||||
if not self.is_new():
|
||||
self.check_impact_on_shopping_cart()
|
||||
|
||||
def on_update(self):
|
||||
self.set_default_if_missing()
|
||||
self.update_item_price()
|
||||
@@ -32,6 +35,17 @@ class PriceList(Document):
|
||||
buying=%s, selling=%s, modified=NOW() where price_list=%s""",
|
||||
(self.currency, cint(self.buying), cint(self.selling), self.name))
|
||||
|
||||
def check_impact_on_shopping_cart(self):
|
||||
"Check if Price List currency change impacts Shopping Cart."
|
||||
from erpnext.shopping_cart.doctype.shopping_cart_settings.shopping_cart_settings import validate_cart_settings
|
||||
|
||||
doc_before_save = self.get_doc_before_save()
|
||||
currency_changed = self.currency != doc_before_save.currency
|
||||
affects_cart = self.name == frappe.get_cached_value("Shopping Cart Settings", None, "price_list")
|
||||
|
||||
if currency_changed and affects_cart:
|
||||
validate_cart_settings()
|
||||
|
||||
def on_trash(self):
|
||||
self.delete_price_list_details_key()
|
||||
|
||||
|
||||
@@ -272,7 +272,7 @@ class StockEntry(StockController):
|
||||
item_wise_qty = {}
|
||||
if self.purpose == "Manufacture" and self.work_order:
|
||||
for d in self.items:
|
||||
if d.is_finished_item:
|
||||
if d.is_finished_item or d.is_process_loss:
|
||||
item_wise_qty.setdefault(d.item_code, []).append(d.qty)
|
||||
|
||||
for item_code, qty_list in iteritems(item_wise_qty):
|
||||
@@ -333,7 +333,7 @@ class StockEntry(StockController):
|
||||
|
||||
if self.purpose == "Manufacture":
|
||||
if validate_for_manufacture:
|
||||
if d.is_finished_item or d.is_scrap_item:
|
||||
if d.is_finished_item or d.is_scrap_item or d.is_process_loss:
|
||||
d.s_warehouse = None
|
||||
if not d.t_warehouse:
|
||||
frappe.throw(_("Target warehouse is mandatory for row {0}").format(d.idx))
|
||||
@@ -465,7 +465,7 @@ class StockEntry(StockController):
|
||||
"""
|
||||
# Set rate for outgoing items
|
||||
outgoing_items_cost = self.set_rate_for_outgoing_items(reset_outgoing_rate, raise_error_if_no_rate)
|
||||
finished_item_qty = sum(d.transfer_qty for d in self.items if d.is_finished_item)
|
||||
finished_item_qty = sum(d.transfer_qty for d in self.items if d.is_finished_item or d.is_process_loss)
|
||||
|
||||
# Set basic rate for incoming items
|
||||
for d in self.get('items'):
|
||||
@@ -486,6 +486,8 @@ class StockEntry(StockController):
|
||||
raise_error_if_no_rate=raise_error_if_no_rate)
|
||||
|
||||
d.basic_rate = flt(d.basic_rate, d.precision("basic_rate"))
|
||||
if d.is_process_loss:
|
||||
d.basic_rate = flt(0.)
|
||||
d.basic_amount = flt(flt(d.transfer_qty) * flt(d.basic_rate), d.precision("basic_amount"))
|
||||
|
||||
def set_rate_for_outgoing_items(self, reset_outgoing_rate=True, raise_error_if_no_rate=True):
|
||||
@@ -1043,6 +1045,7 @@ class StockEntry(StockController):
|
||||
|
||||
self.set_scrap_items()
|
||||
self.set_actual_qty()
|
||||
self.update_items_for_process_loss()
|
||||
self.validate_customer_provided_item()
|
||||
self.calculate_rate_and_amount()
|
||||
|
||||
@@ -1400,6 +1403,7 @@ class StockEntry(StockController):
|
||||
get_default_cost_center(item_dict[d], company = self.company))
|
||||
se_child.is_finished_item = item_dict[d].get("is_finished_item", 0)
|
||||
se_child.is_scrap_item = item_dict[d].get("is_scrap_item", 0)
|
||||
se_child.is_process_loss = item_dict[d].get("is_process_loss", 0)
|
||||
|
||||
for field in ["idx", "po_detail", "original_item",
|
||||
"expense_account", "description", "item_name", "serial_no", "batch_no"]:
|
||||
@@ -1579,6 +1583,30 @@ class StockEntry(StockController):
|
||||
material_requests.append(material_request)
|
||||
frappe.db.set_value('Material Request', material_request, 'transfer_status', status)
|
||||
|
||||
def update_items_for_process_loss(self):
|
||||
process_loss_dict = {}
|
||||
for d in self.get("items"):
|
||||
if not d.is_process_loss:
|
||||
continue
|
||||
|
||||
scrap_warehouse = frappe.db.get_single_value("Manufacturing Settings", "default_scrap_warehouse")
|
||||
if scrap_warehouse is not None:
|
||||
d.t_warehouse = scrap_warehouse
|
||||
d.is_scrap_item = 0
|
||||
|
||||
if d.item_code not in process_loss_dict:
|
||||
process_loss_dict[d.item_code] = [flt(0), flt(0)]
|
||||
process_loss_dict[d.item_code][0] += flt(d.transfer_qty)
|
||||
process_loss_dict[d.item_code][1] += flt(d.qty)
|
||||
|
||||
for d in self.get("items"):
|
||||
if not d.is_finished_item or d.item_code not in process_loss_dict:
|
||||
continue
|
||||
# Assumption: 1 finished item has 1 row.
|
||||
d.transfer_qty -= process_loss_dict[d.item_code][0]
|
||||
d.qty -= process_loss_dict[d.item_code][1]
|
||||
|
||||
|
||||
def set_serial_no_batch_for_finished_good(self):
|
||||
args = {}
|
||||
if self.pro_doc.serial_no:
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
QUnit.module('Stock');
|
||||
|
||||
QUnit.test("test manufacture from bom", function(assert) {
|
||||
assert.expect(2);
|
||||
let done = assert.async();
|
||||
frappe.run_serially([
|
||||
() => {
|
||||
return frappe.tests.make("Stock Entry", [
|
||||
{ purpose: "Manufacture" },
|
||||
{ from_bom: 1 },
|
||||
{ bom_no: "BOM-_Test Item - Non Whole UOM-001" },
|
||||
{ fg_completed_qty: 2 }
|
||||
]);
|
||||
},
|
||||
() => cur_frm.save(),
|
||||
() => frappe.click_button("Update Rate and Availability"),
|
||||
() => {
|
||||
assert.ok(cur_frm.doc.items[1] === 0.75, " Finished Item Qty correct");
|
||||
assert.ok(cur_frm.doc.items[2] === 0.25, " Process Loss Item Qty correct");
|
||||
},
|
||||
() => frappe.tests.click_button('Submit'),
|
||||
() => frappe.tests.click_button('Yes'),
|
||||
() => frappe.timeout(0.3),
|
||||
() => done()
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"col_break2",
|
||||
"is_finished_item",
|
||||
"is_scrap_item",
|
||||
"is_process_loss",
|
||||
"quality_inspection",
|
||||
"subcontracted_item",
|
||||
"section_break_8",
|
||||
@@ -543,13 +544,19 @@
|
||||
"no_copy": 1,
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "is_process_loss",
|
||||
"fieldtype": "Check",
|
||||
"label": "Is Process Loss"
|
||||
}
|
||||
],
|
||||
"idx": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2021-06-21 16:03:18.834880",
|
||||
"modified": "2021-06-22 16:47:11.268975",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Stock Entry Detail",
|
||||
|
||||
@@ -390,7 +390,7 @@ class StockReconciliation(StockController):
|
||||
sl_entries = self.merge_similar_item_serial_nos(sl_entries)
|
||||
|
||||
sl_entries.reverse()
|
||||
allow_negative_stock = frappe.db.get_value("Stock Settings", None, "allow_negative_stock")
|
||||
allow_negative_stock = cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock"))
|
||||
self.make_sl_entries(sl_entries, allow_negative_stock=allow_negative_stock)
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from erpnext.stock.doctype.item.test_item import create_item
|
||||
from erpnext.stock.utils import get_incoming_rate, get_stock_value_on, get_valuation_method
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
|
||||
from erpnext.tests.utils import change_settings
|
||||
|
||||
|
||||
class TestStockReconciliation(unittest.TestCase):
|
||||
@@ -310,6 +311,7 @@ class TestStockReconciliation(unittest.TestCase):
|
||||
pr2.cancel()
|
||||
pr1.cancel()
|
||||
|
||||
@change_settings("Stock Settings", {"allow_negative_stock": 0})
|
||||
def test_backdated_stock_reco_future_negative_stock(self):
|
||||
"""
|
||||
Test if a backdated stock reco causes future negative stock and is blocked.
|
||||
@@ -327,8 +329,6 @@ class TestStockReconciliation(unittest.TestCase):
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
create_item(item_code)
|
||||
|
||||
negative_stock_setting = frappe.db.get_single_value("Stock Settings", "allow_negative_stock")
|
||||
frappe.db.set_value("Stock Settings", None, "allow_negative_stock", 0)
|
||||
|
||||
pr1 = make_purchase_receipt(item_code=item_code, warehouse=warehouse, qty=10, rate=100,
|
||||
posting_date=add_days(nowdate(), -2))
|
||||
@@ -348,11 +348,50 @@ class TestStockReconciliation(unittest.TestCase):
|
||||
self.assertRaises(NegativeStockError, sr3.submit)
|
||||
|
||||
# teardown
|
||||
frappe.db.set_value("Stock Settings", None, "allow_negative_stock", negative_stock_setting)
|
||||
sr3.cancel()
|
||||
dn2.cancel()
|
||||
pr1.cancel()
|
||||
|
||||
|
||||
@change_settings("Stock Settings", {"allow_negative_stock": 0})
|
||||
def test_backdated_stock_reco_cancellation_future_negative_stock(self):
|
||||
"""
|
||||
Test if a backdated stock reco cancellation that causes future negative stock is blocked.
|
||||
-------------------------------------------
|
||||
Var | Doc | Qty | Balance
|
||||
-------------------------------------------
|
||||
SR | Reco | 100 | 100 (posting date: today-1) (shouldn't be cancelled after DN)
|
||||
DN | DN | 100 | 0 (posting date: today)
|
||||
"""
|
||||
from erpnext.stock.stock_ledger import NegativeStockError
|
||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||
frappe.db.commit()
|
||||
|
||||
item_code = "Backdated-Reco-Cancellation-Item"
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
create_item(item_code)
|
||||
|
||||
|
||||
sr = create_stock_reconciliation(item_code=item_code, warehouse=warehouse, qty=100, rate=100,
|
||||
posting_date=add_days(nowdate(), -1))
|
||||
|
||||
dn = create_delivery_note(item_code=item_code, warehouse=warehouse, qty=100, rate=120,
|
||||
posting_date=nowdate())
|
||||
|
||||
dn_balance = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": dn.name, "is_cancelled": 0},
|
||||
"qty_after_transaction")
|
||||
self.assertEqual(dn_balance, 0)
|
||||
|
||||
# check if cancellation of stock reco is blocked
|
||||
self.assertRaises(NegativeStockError, sr.cancel)
|
||||
|
||||
repost_exists = bool(frappe.db.exists("Repost Item Valuation", {"voucher_no": sr.name}))
|
||||
self.assertFalse(repost_exists, msg="Negative stock validation not working on reco cancellation")
|
||||
|
||||
# teardown
|
||||
frappe.db.rollback()
|
||||
|
||||
|
||||
def test_valid_batch(self):
|
||||
create_batch_item_with_batch("Testing Batch Item 1", "001")
|
||||
create_batch_item_with_batch("Testing Batch Item 2", "002")
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
/* eslint-disable */
|
||||
|
||||
frappe.query_reports["Process Loss Report"] = {
|
||||
filters: [
|
||||
{
|
||||
label: __("Company"),
|
||||
fieldname: "company",
|
||||
fieldtype: "Link",
|
||||
options: "Company",
|
||||
mandatory: true,
|
||||
default: frappe.defaults.get_user_default("Company"),
|
||||
},
|
||||
{
|
||||
label: __("Item"),
|
||||
fieldname: "item",
|
||||
fieldtype: "Link",
|
||||
options: "Item",
|
||||
mandatory: false,
|
||||
},
|
||||
{
|
||||
label: __("Work Order"),
|
||||
fieldname: "work_order",
|
||||
fieldtype: "Link",
|
||||
options: "Work Order",
|
||||
mandatory: false,
|
||||
},
|
||||
{
|
||||
label: __("From Date"),
|
||||
fieldname: "from_date",
|
||||
fieldtype: "Date",
|
||||
mandatory: true,
|
||||
default: frappe.datetime.year_start(),
|
||||
},
|
||||
{
|
||||
label: __("To Date"),
|
||||
fieldname: "to_date",
|
||||
fieldtype: "Date",
|
||||
mandatory: true,
|
||||
default: frappe.datetime.get_today(),
|
||||
},
|
||||
]
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"add_total_row": 0,
|
||||
"columns": [],
|
||||
"creation": "2021-08-24 16:38:15.233395",
|
||||
"disable_prepared_report": 0,
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"filters": [],
|
||||
"idx": 0,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2021-08-24 16:38:15.233395",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Process Loss Report",
|
||||
"owner": "Administrator",
|
||||
"prepared_report": 0,
|
||||
"ref_doctype": "Work Order",
|
||||
"report_name": "Process Loss Report",
|
||||
"report_type": "Script Report",
|
||||
"roles": [
|
||||
{
|
||||
"role": "Manufacturing User"
|
||||
},
|
||||
{
|
||||
"role": "Stock User"
|
||||
}
|
||||
]
|
||||
}
|
||||
132
erpnext/stock/report/process_loss_report/process_loss_report.py
Normal file
132
erpnext/stock/report/process_loss_report/process_loss_report.py
Normal file
@@ -0,0 +1,132 @@
|
||||
# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
Filters = frappe._dict
|
||||
Row = frappe._dict
|
||||
Data = List[Row]
|
||||
Columns = List[Dict[str, str]]
|
||||
QueryArgs = Dict[str, str]
|
||||
|
||||
def execute(filters: Filters) -> Tuple[Columns, Data]:
|
||||
columns = get_columns()
|
||||
data = get_data(filters)
|
||||
return columns, data
|
||||
|
||||
def get_data(filters: Filters) -> Data:
|
||||
query_args = get_query_args(filters)
|
||||
data = run_query(query_args)
|
||||
update_data_with_total_pl_value(data)
|
||||
return data
|
||||
|
||||
def get_columns() -> Columns:
|
||||
return [
|
||||
{
|
||||
'label': 'Work Order',
|
||||
'fieldname': 'name',
|
||||
'fieldtype': 'Link',
|
||||
'options': 'Work Order',
|
||||
'width': '200'
|
||||
},
|
||||
{
|
||||
'label': 'Item',
|
||||
'fieldname': 'production_item',
|
||||
'fieldtype': 'Link',
|
||||
'options': 'Item',
|
||||
'width': '100'
|
||||
},
|
||||
{
|
||||
'label': 'Status',
|
||||
'fieldname': 'status',
|
||||
'fieldtype': 'Data',
|
||||
'width': '100'
|
||||
},
|
||||
{
|
||||
'label': 'Manufactured Qty',
|
||||
'fieldname': 'produced_qty',
|
||||
'fieldtype': 'Float',
|
||||
'width': '150'
|
||||
},
|
||||
{
|
||||
'label': 'Loss Qty',
|
||||
'fieldname': 'process_loss_qty',
|
||||
'fieldtype': 'Float',
|
||||
'width': '150'
|
||||
},
|
||||
{
|
||||
'label': 'Actual Manufactured Qty',
|
||||
'fieldname': 'actual_produced_qty',
|
||||
'fieldtype': 'Float',
|
||||
'width': '150'
|
||||
},
|
||||
{
|
||||
'label': 'Loss Value',
|
||||
'fieldname': 'total_pl_value',
|
||||
'fieldtype': 'Float',
|
||||
'width': '150'
|
||||
},
|
||||
{
|
||||
'label': 'FG Value',
|
||||
'fieldname': 'total_fg_value',
|
||||
'fieldtype': 'Float',
|
||||
'width': '150'
|
||||
},
|
||||
{
|
||||
'label': 'Raw Material Value',
|
||||
'fieldname': 'total_rm_value',
|
||||
'fieldtype': 'Float',
|
||||
'width': '150'
|
||||
}
|
||||
]
|
||||
|
||||
def get_query_args(filters: Filters) -> QueryArgs:
|
||||
query_args = {}
|
||||
query_args.update(filters)
|
||||
query_args.update(
|
||||
get_filter_conditions(filters)
|
||||
)
|
||||
return query_args
|
||||
|
||||
def run_query(query_args: QueryArgs) -> Data:
|
||||
return frappe.db.sql("""
|
||||
SELECT
|
||||
wo.name, wo.status, wo.production_item, wo.qty,
|
||||
wo.produced_qty, wo.process_loss_qty,
|
||||
(wo.produced_qty - wo.process_loss_qty) as actual_produced_qty,
|
||||
sum(se.total_incoming_value) as total_fg_value,
|
||||
sum(se.total_outgoing_value) as total_rm_value
|
||||
FROM
|
||||
`tabWork Order` wo INNER JOIN `tabStock Entry` se
|
||||
ON wo.name=se.work_order
|
||||
WHERE
|
||||
process_loss_qty > 0
|
||||
AND wo.company = %(company)s
|
||||
AND se.docstatus = 1
|
||||
AND se.posting_date BETWEEN %(from_date)s AND %(to_date)s
|
||||
{item_filter}
|
||||
{work_order_filter}
|
||||
GROUP BY
|
||||
se.work_order
|
||||
""".format(**query_args), query_args, as_dict=1, debug=1)
|
||||
|
||||
def update_data_with_total_pl_value(data: Data) -> None:
|
||||
for row in data:
|
||||
value_per_unit_fg = row['total_fg_value'] / row['actual_produced_qty']
|
||||
row['total_pl_value'] = row['process_loss_qty'] * value_per_unit_fg
|
||||
|
||||
def get_filter_conditions(filters: Filters) -> QueryArgs:
|
||||
filter_conditions = dict(item_filter="", work_order_filter="")
|
||||
if "item" in filters:
|
||||
production_item = filters.get("item")
|
||||
filter_conditions.update(
|
||||
{"item_filter": f"AND wo.production_item='{production_item}'"}
|
||||
)
|
||||
if "work_order" in filters:
|
||||
work_order_name = filters.get("work_order")
|
||||
filter_conditions.update(
|
||||
{"work_order_filter": f"AND wo.name='{work_order_name}'"}
|
||||
)
|
||||
return filter_conditions
|
||||
|
||||
@@ -324,6 +324,7 @@ class update_entries_after(object):
|
||||
where
|
||||
item_code = %(item_code)s
|
||||
and warehouse = %(warehouse)s
|
||||
and is_cancelled = 0
|
||||
and timestamp(posting_date, time_format(posting_time, %(time_format)s)) = timestamp(%(posting_date)s, time_format(%(posting_time)s, %(time_format)s))
|
||||
|
||||
order by
|
||||
@@ -946,7 +947,7 @@ def get_valuation_rate(item_code, warehouse, voucher_type, voucher_no,
|
||||
|
||||
return valuation_rate
|
||||
|
||||
def update_qty_in_future_sle(args, allow_negative_stock=None):
|
||||
def update_qty_in_future_sle(args, allow_negative_stock=False):
|
||||
"""Recalculate Qty after Transaction in future SLEs based on current SLE."""
|
||||
datetime_limit_condition = ""
|
||||
qty_shift = args.actual_qty
|
||||
@@ -1035,8 +1036,8 @@ def get_datetime_limit_condition(detail):
|
||||
)
|
||||
)"""
|
||||
|
||||
def validate_negative_qty_in_future_sle(args, allow_negative_stock=None):
|
||||
allow_negative_stock = allow_negative_stock \
|
||||
def validate_negative_qty_in_future_sle(args, allow_negative_stock=False):
|
||||
allow_negative_stock = cint(allow_negative_stock) \
|
||||
or cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock"))
|
||||
|
||||
if (args.actual_qty < 0 or args.voucher_type == "Stock Reconciliation") and not allow_negative_stock:
|
||||
|
||||
Reference in New Issue
Block a user