mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-04 18:23:05 +00:00
Compare commits
31 Commits
mergify/bp
...
version-15
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
431dc2e5f1 | ||
|
|
3aed38423e | ||
|
|
33446f4f4a | ||
|
|
7ef039f6ed | ||
|
|
f47346aa90 | ||
|
|
9d417da3d8 | ||
|
|
928f984198 | ||
|
|
78a43833a0 | ||
|
|
faa7c466b1 | ||
|
|
ee4e296ce6 | ||
|
|
0b08129b40 | ||
|
|
c488de8f12 | ||
|
|
005b626482 | ||
|
|
2993747636 | ||
|
|
a9a3c20f3f | ||
|
|
aa20df88f5 | ||
|
|
5ec87ae06c | ||
|
|
b4e8aef2f8 | ||
|
|
5152281618 | ||
|
|
48beb2ee23 | ||
|
|
198468aa5a | ||
|
|
070a7cfb91 | ||
|
|
8676add875 | ||
|
|
0f6c6d4df7 | ||
|
|
70da05edb7 | ||
|
|
724eb1aac4 | ||
|
|
42a2674341 | ||
|
|
a6dff3fc47 | ||
|
|
00df8652e3 | ||
|
|
5a5e20e167 | ||
|
|
4373e295de |
@@ -473,19 +473,24 @@ def get_child_docs(doc: list) -> list:
|
||||
|
||||
|
||||
def validate_docs_for_deferred_accounting(sales_docs, purchase_docs):
|
||||
docs_with_deferred_revenue = frappe.db.get_all(
|
||||
"Sales Invoice Item",
|
||||
filters={"parent": ["in", sales_docs], "docstatus": 1, "enable_deferred_revenue": True},
|
||||
fields=["parent"],
|
||||
as_list=1,
|
||||
)
|
||||
docs_with_deferred_revenue = ()
|
||||
docs_with_deferred_expense = ()
|
||||
|
||||
docs_with_deferred_expense = frappe.db.get_all(
|
||||
"Purchase Invoice Item",
|
||||
filters={"parent": ["in", purchase_docs], "docstatus": 1, "enable_deferred_expense": 1},
|
||||
fields=["parent"],
|
||||
as_list=1,
|
||||
)
|
||||
if sales_docs:
|
||||
docs_with_deferred_revenue = frappe.db.get_all(
|
||||
"Sales Invoice Item",
|
||||
filters={"parent": ["in", sales_docs], "docstatus": 1, "enable_deferred_revenue": True},
|
||||
fields=["parent"],
|
||||
as_list=1,
|
||||
)
|
||||
|
||||
if purchase_docs:
|
||||
docs_with_deferred_expense = frappe.db.get_all(
|
||||
"Purchase Invoice Item",
|
||||
filters={"parent": ["in", purchase_docs], "docstatus": 1, "enable_deferred_expense": 1},
|
||||
fields=["parent"],
|
||||
as_list=1,
|
||||
)
|
||||
|
||||
if docs_with_deferred_revenue or docs_with_deferred_expense:
|
||||
frappe.throw(
|
||||
|
||||
@@ -161,7 +161,14 @@ class ShippingRule(Document):
|
||||
)
|
||||
shipping_charge["add_deduct_tax"] = "Add"
|
||||
|
||||
existing_shipping_charge = doc.get("taxes", filters=shipping_charge)
|
||||
shipping_charge_filters = shipping_charge.copy()
|
||||
if not self.cost_center:
|
||||
shipping_charge_filters["cost_center"] = (
|
||||
"in",
|
||||
(None, "", erpnext.get_default_cost_center(doc.company)),
|
||||
)
|
||||
|
||||
existing_shipping_charge = doc.get("taxes", filters=shipping_charge_filters)
|
||||
if existing_shipping_charge:
|
||||
# take the last record found
|
||||
existing_shipping_charge[-1].tax_amount = shipping_amount
|
||||
|
||||
@@ -849,9 +849,11 @@ def validate_account_party_type(self):
|
||||
|
||||
|
||||
def get_dashboard_info(party_type, party, loyalty_program=None):
|
||||
current_fiscal_year = get_fiscal_year(nowdate(), as_dict=True)
|
||||
|
||||
doctype = "Sales Invoice" if party_type == "Customer" else "Purchase Invoice"
|
||||
if not frappe.has_permission(doctype, "read"):
|
||||
return None
|
||||
|
||||
current_fiscal_year = get_fiscal_year(nowdate(), as_dict=True)
|
||||
|
||||
companies = frappe.get_list(
|
||||
doctype, filters={"docstatus": 1, party_type.lower(): party}, distinct=1, fields=["company"]
|
||||
|
||||
@@ -358,7 +358,7 @@ class BuyingController(SubcontractingController):
|
||||
)
|
||||
valuation_amount_adjustment -= item.item_tax_amount
|
||||
|
||||
self.round_floats_in(item)
|
||||
self.round_floats_in(item, do_not_round_fields=["conversion_factor"])
|
||||
if flt(item.conversion_factor) == 0.0:
|
||||
item.conversion_factor = (
|
||||
get_conversion_factor(item.item_code, item.uom).get("conversion_factor") or 1.0
|
||||
|
||||
@@ -158,10 +158,28 @@ def validate_returned_items(doc):
|
||||
):
|
||||
frappe.throw(_("Warehouse is mandatory"))
|
||||
|
||||
items_returned = True
|
||||
if doc.doctype in (
|
||||
"Purchase Invoice",
|
||||
"Purchase Receipt",
|
||||
"Subcontracting Receipt",
|
||||
"Sales Invoice",
|
||||
"Delivery Note",
|
||||
"POS Invoice",
|
||||
):
|
||||
if flt(d.qty) < 0 or flt(d.get("received_qty")) < 0:
|
||||
items_returned = True
|
||||
else:
|
||||
items_returned = True
|
||||
|
||||
elif d.item_name:
|
||||
items_returned = True
|
||||
if doc.doctype in ("Purchase Invoice", "Purchase Receipt", "Subcontracting Receipt"):
|
||||
# No item_code here means no linked Item, so there's no accepted/rejected
|
||||
# split to speak of - received_qty isn't a meaningful independent signal.
|
||||
# Only a negative qty (i.e. a real negative billing amount) counts.
|
||||
if flt(d.qty) < 0:
|
||||
items_returned = True
|
||||
else:
|
||||
items_returned = True
|
||||
|
||||
if not items_returned:
|
||||
frappe.throw(_("Atleast one item should be entered with negative quantity in return document"))
|
||||
|
||||
@@ -185,7 +185,12 @@ class calculate_taxes_and_totals:
|
||||
return
|
||||
|
||||
if not self.discount_amount_applied:
|
||||
do_not_round_fields = ["valuation_rate", "incoming_rate", "sales_incoming_rate"]
|
||||
do_not_round_fields = [
|
||||
"valuation_rate",
|
||||
"incoming_rate",
|
||||
"sales_incoming_rate",
|
||||
"conversion_factor",
|
||||
]
|
||||
|
||||
for item in self.doc.items:
|
||||
self.doc.round_floats_in(item, do_not_round_fields=do_not_round_fields)
|
||||
|
||||
89
erpnext/controllers/tests/test_sales_and_purchase_return.py
Normal file
89
erpnext/controllers/tests/test_sales_and_purchase_return.py
Normal file
@@ -0,0 +1,89 @@
|
||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
|
||||
|
||||
class TestSalesAndPurchaseReturn(FrappeTestCase):
|
||||
@staticmethod
|
||||
def _cancel_and_delete(doctype, name):
|
||||
if not frappe.db.exists(doctype, name):
|
||||
return
|
||||
doc = frappe.get_doc(doctype, name)
|
||||
if doc.docstatus == 1:
|
||||
doc.cancel()
|
||||
frappe.delete_doc(doctype, name, force=1)
|
||||
|
||||
def test_purchase_invoice_zero_qty_return_is_rejected(self):
|
||||
# A return with every item at qty 0 moves no stock and no value, so it must be
|
||||
# rejected the same way a return with no items at all would be.
|
||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
||||
|
||||
pi = make_purchase_invoice(qty=10)
|
||||
self.addCleanup(self._cancel_and_delete, "Purchase Invoice", pi.name)
|
||||
|
||||
return_pi = make_purchase_invoice(
|
||||
is_return=1,
|
||||
return_against=pi.name,
|
||||
qty=0,
|
||||
do_not_save=True,
|
||||
)
|
||||
|
||||
self.assertRaises(frappe.ValidationError, return_pi.save)
|
||||
|
||||
def test_purchase_invoice_item_name_only_zero_qty_return_is_rejected(self):
|
||||
# Item Code is not mandatory on Purchase Invoice Item - a row can have only an
|
||||
# item_name (e.g. a free-text/non-stock line). Such rows fall through to the
|
||||
# item_name-only branch, which must also reject an all-zero-qty return instead
|
||||
# of unconditionally treating the row as returned.
|
||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
||||
|
||||
pi = make_purchase_invoice(item_name="_Test Item", qty=10, do_not_submit=True)
|
||||
pi.items[0].item_code = ""
|
||||
pi.save()
|
||||
pi.submit()
|
||||
self.addCleanup(self._cancel_and_delete, "Purchase Invoice", pi.name)
|
||||
|
||||
return_pi = make_purchase_invoice(
|
||||
item_name="_Test Item",
|
||||
is_return=1,
|
||||
return_against=pi.name,
|
||||
qty=0,
|
||||
do_not_save=True,
|
||||
)
|
||||
return_pi.items[0].item_code = ""
|
||||
|
||||
self.assertRaises(frappe.ValidationError, return_pi.save)
|
||||
|
||||
def test_delivery_note_zero_qty_return_is_rejected(self):
|
||||
# A return with every item at qty 0 moves no stock and no value, so it must be
|
||||
# rejected the same way a return with no items at all would be.
|
||||
from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
|
||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
se = make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=20, basic_rate=100)
|
||||
self.addCleanup(self._cancel_and_delete, "Stock Entry", se.name)
|
||||
|
||||
dn = create_delivery_note(qty=5)
|
||||
self.addCleanup(self._cancel_and_delete, "Delivery Note", dn.name)
|
||||
|
||||
return_dn = make_sales_return(dn.name)
|
||||
return_dn.items[0].qty = 0
|
||||
|
||||
self.assertRaises(frappe.ValidationError, return_dn.insert)
|
||||
|
||||
def test_sales_invoice_zero_qty_return_is_rejected(self):
|
||||
# Same rule for a standalone (non stock-affecting) Sales Invoice return: qty 0 on
|
||||
# every row must be rejected, not silently accepted as a no-op credit note.
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.controllers.sales_and_purchase_return import make_return_doc
|
||||
|
||||
si = create_sales_invoice(qty=10)
|
||||
self.addCleanup(self._cancel_and_delete, "Sales Invoice", si.name)
|
||||
|
||||
return_si = make_return_doc(si.doctype, si.name)
|
||||
return_si.items[0].qty = 0
|
||||
|
||||
self.assertRaises(frappe.ValidationError, return_si.save)
|
||||
@@ -132,6 +132,7 @@ class Opportunity(TransactionBase, CRMNote):
|
||||
self.validate_uom_is_integer("uom", "qty")
|
||||
self.validate_cust_name()
|
||||
self.map_fields()
|
||||
self.validate_qty()
|
||||
self.set_exchange_rate()
|
||||
|
||||
if not self.title:
|
||||
@@ -142,6 +143,15 @@ class Opportunity(TransactionBase, CRMNote):
|
||||
def on_update(self):
|
||||
self.update_prospect()
|
||||
|
||||
def validate_qty(self):
|
||||
for item in self.items:
|
||||
if flt(item.qty) <= 0:
|
||||
frappe.throw(
|
||||
_("Row #{0}: Quantity must be greater than 0 for Item {1}").format(
|
||||
item.idx, item.item_code
|
||||
)
|
||||
)
|
||||
|
||||
def map_fields(self):
|
||||
for field in self.meta.get_valid_columns():
|
||||
if not self.get(field) and frappe.db.field_exists(self.opportunity_from, field):
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
<div class="row" style="border-bottom:1px solid var(--border-color); padding:4px 5px; margin-top: 3px;margin-bottom: 3px;">
|
||||
<div class="col-sm-1">
|
||||
{% if(row.image) { %}
|
||||
<img style="width:50px;height:50px;" src="{{row.image}}">
|
||||
<img style="width:50px;height:50px;" src="{{frappe.utils.escape_html(row.image)}}">
|
||||
{% } else { %}
|
||||
<div style="width:50px;height:50px;background-color:var(--control-bg);text-align:center;padding-top:15px">{{frappe.get_abbr(row.item_code, 2)}}</div>
|
||||
<div style="width:50px;height:50px;background-color:var(--control-bg);text-align:center;padding-top:15px">{{frappe.get_abbr(frappe.utils.escape_html(row.item_code), 2)}}</div>
|
||||
{% } %}
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
@@ -13,7 +13,7 @@
|
||||
{% } else { %}
|
||||
{{row.item_link}}
|
||||
<p>
|
||||
{{row.item_name}}
|
||||
{{frappe.utils.escape_html(row.item_name)}}
|
||||
</p>
|
||||
{% } %}
|
||||
|
||||
@@ -52,10 +52,10 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-sm-1">
|
||||
<button style="margin-left: 7px;" class="btn btn-default btn-xs btn-add" data-item-code="{{ escape(row.item_code) }}">{{ __("Add") }}</button>
|
||||
<button style="margin-left: 7px;" class="btn btn-default btn-xs btn-add" data-item-code="{{ frappe.utils.escape_html(row.item_code) }}">{{ __("Add") }}</button>
|
||||
</div>
|
||||
<div class="col-sm-1">
|
||||
<button style="margin-left: 7px;" class="btn btn-default btn-xs btn-move" data-item-code="{{ escape(row.item_code) }}">{{ __("Move") }}</button>
|
||||
<button style="margin-left: 7px;" class="btn btn-default btn-xs btn-move" data-item-code="{{ frappe.utils.escape_html(row.item_code) }}">{{ __("Move") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
{% }); %}
|
||||
|
||||
@@ -418,6 +418,6 @@ def get_workstations(**kwargs):
|
||||
d.background_color = color_map.get(d.status, "var(--red-600)")
|
||||
d.workstation_link = get_url_to_form("Workstation", d.name)
|
||||
if d.status != "Production":
|
||||
d.status_image = d.off_status_image
|
||||
d.status_image = frappe.utils.escape_html(d.off_status_image)
|
||||
|
||||
return data
|
||||
|
||||
@@ -126,11 +126,26 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
|
||||
}
|
||||
}
|
||||
|
||||
get_item_fields_to_round() {
|
||||
const [item] = this.frm.doc.items || [];
|
||||
if (!item) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const do_not_round_fields = ["conversion_factor"];
|
||||
return frappe.meta
|
||||
.get_fieldnames(item.doctype, item.parent, {
|
||||
fieldtype: ["in", ["Currency", "Float"]],
|
||||
})
|
||||
.filter((fieldname) => !do_not_round_fields.includes(fieldname));
|
||||
}
|
||||
|
||||
calculate_item_values() {
|
||||
var me = this;
|
||||
if (!this.discount_amount_applied) {
|
||||
const fields_to_round = this.get_item_fields_to_round();
|
||||
for (const item of this.frm.doc.items || []) {
|
||||
frappe.model.round_floats_in(item);
|
||||
frappe.model.round_floats_in(item, fields_to_round);
|
||||
item.net_rate = item.rate;
|
||||
item.qty = item.qty === undefined ? (me.frm.doc.is_return ? -1 : 1) : item.qty;
|
||||
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
<div class="app-listing item-list image-view-container item-selector">
|
||||
{% for (var i=0; i < data.length; i++) { var item = data[i]; %}
|
||||
{% const item_name = frappe.utils.escape_html(item.name); %}
|
||||
{% const item_title = frappe.utils.escape_html(item.item_name || item.name); %}
|
||||
{% if (i % 4 === 0) { %}<div class="image-view-row">{% } %}
|
||||
<div class="image-view-item" data-name="{{ item.name }}">
|
||||
<div class="image-view-item" data-name="{{ item_name }}">
|
||||
<div class="image-view-header doclist-row">
|
||||
<div class="list-value">
|
||||
<a class="grey list-id" data-name="{{item.name}}"
|
||||
title="{{ item.item_name || item.name}}">
|
||||
{{item.item_name || item.name}}</a>
|
||||
<a class="grey list-id" data-name="{{ item_name }}"
|
||||
title="{{ item_title }}">
|
||||
{{ item_title }}</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="image-view-body">
|
||||
<a data-item-code="{{ item.name }}"
|
||||
title="{{ item.item_name || item.name }}"
|
||||
<a data-item-code="{{ item_name }}"
|
||||
title="{{ item_title }}"
|
||||
>
|
||||
<div class="image-field"
|
||||
style="
|
||||
@@ -22,11 +24,11 @@
|
||||
>
|
||||
{% if (!item.image) { %}
|
||||
<span class="placeholder-text">
|
||||
{%= frappe.get_abbr(item.item_name || item.name) %}
|
||||
{%= frappe.get_abbr(item_title) %}
|
||||
</span>
|
||||
{% } %}
|
||||
{% if (item.image) { %}
|
||||
<img src="{{ item.image }}" alt="{{item.item_name || item.name}}">
|
||||
<img src="{{ frappe.utils.escape_html(item.image) }}" alt="{{ item_title }}">
|
||||
{% } %}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% $.each(workstations, (idx, row) => { %}
|
||||
{% const row_workstation_name = frappe.utils.escape_html(row.name); %}
|
||||
<div class="workstation-wrapper">
|
||||
<div class="workstation-status text-right">
|
||||
{% if(row.status == "Production") { %}
|
||||
@@ -17,14 +18,14 @@
|
||||
{% if(row.status_image) { %}
|
||||
<img class="workstation-image-cls" src="{{row.status_image}}">
|
||||
{% } else { %}
|
||||
<div class="workstation-image-cls workstation-abbr">{{frappe.get_abbr(row.name, 2)}}</div>
|
||||
<div class="workstation-image-cls workstation-abbr">{{frappe.get_abbr(row_workstation_name, 2)}}</div>
|
||||
{% } %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="workstation-card" style="display: grid;">
|
||||
<span class="ellipsis" title="{{row.name}}">
|
||||
{{row.workstation_name}}
|
||||
<span class="ellipsis" title="{{row_workstation_name}}">
|
||||
{{row_workstation_name}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1984,6 +1984,41 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase):
|
||||
sales_order.save()
|
||||
self.assertEqual(sales_order.taxes[0].tax_amount, 0)
|
||||
|
||||
def test_sales_order_with_shipping_rule_without_cost_center(self):
|
||||
from erpnext import get_default_cost_center
|
||||
|
||||
shipping_rule = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Shipping Rule",
|
||||
"label": "Shipping Rule Without Cost Center - Sales Order Test",
|
||||
"shipping_rule_type": "Selling",
|
||||
"company": "_Test Company",
|
||||
"account": "_Test Account Shipping Charges - _TC",
|
||||
"calculate_based_on": "Fixed",
|
||||
"shipping_amount": 50,
|
||||
}
|
||||
).insert()
|
||||
sales_order = make_sales_order(do_not_save=True)
|
||||
sales_order.shipping_rule = shipping_rule.name
|
||||
company_cost_center = get_default_cost_center(sales_order.company)
|
||||
|
||||
shipping_rule.apply(sales_order)
|
||||
self.assertEqual(len(sales_order.taxes), 1)
|
||||
self.assertIsNone(sales_order.taxes[0].cost_center)
|
||||
|
||||
for cost_center in (None, "", company_cost_center):
|
||||
sales_order.taxes[0].cost_center = cost_center
|
||||
shipping_rule.apply(sales_order)
|
||||
self.assertEqual(len(sales_order.taxes), 1)
|
||||
self.assertEqual(sales_order.taxes[0].cost_center, cost_center)
|
||||
|
||||
sales_order.taxes[0].cost_center = ""
|
||||
sales_order.save()
|
||||
sales_order.reload()
|
||||
shipping_rule.apply(sales_order)
|
||||
self.assertEqual(len(sales_order.taxes), 1)
|
||||
self.assertEqual(sales_order.taxes[0].cost_center, "")
|
||||
|
||||
@change_settings(
|
||||
"Accounts Settings",
|
||||
{"add_taxes_from_item_tax_template": 0, "add_taxes_from_taxes_and_charges_template": 1},
|
||||
|
||||
@@ -53,7 +53,7 @@ class Employee(NestedSet):
|
||||
user = frappe.get_doc("User", existing_user_id)
|
||||
validate_employee_role(user, ignore_emp_check=True)
|
||||
user.save(ignore_permissions=True)
|
||||
remove_user_permission("Employee", self.name, existing_user_id)
|
||||
remove_user_permission("Employee", self.name, existing_user_id, ignore_permissions=True)
|
||||
|
||||
def after_rename(self, old, new, merge):
|
||||
self.db_set("employee", new)
|
||||
@@ -91,11 +91,11 @@ class Employee(NestedSet):
|
||||
)
|
||||
|
||||
if employee_user_permission_exists and not self.create_user_permission:
|
||||
remove_user_permission("Employee", self.name, self.user_id)
|
||||
remove_user_permission("Company", self.company, self.user_id)
|
||||
remove_user_permission("Employee", self.name, self.user_id, ignore_permissions=True)
|
||||
remove_user_permission("Company", self.company, self.user_id, ignore_permissions=True)
|
||||
elif not employee_user_permission_exists and self.create_user_permission:
|
||||
add_user_permission("Employee", self.name, self.user_id)
|
||||
add_user_permission("Company", self.company, self.user_id)
|
||||
add_user_permission("Employee", self.name, self.user_id, ignore_permissions=True)
|
||||
add_user_permission("Company", self.company, self.user_id, ignore_permissions=True)
|
||||
|
||||
def update_user(self):
|
||||
# add employee role if missing
|
||||
|
||||
@@ -837,7 +837,17 @@ class Item(Document):
|
||||
frappe.throw(_("Item {0} is not a template item.").format(frappe.bold(self.variant_of)))
|
||||
|
||||
if based_on == "Item Attribute":
|
||||
previous_doc = self.get_doc_before_save()
|
||||
saved_attributes = (
|
||||
{(row.attribute, row.attribute_value) for row in previous_doc.attributes}
|
||||
if previous_doc
|
||||
else set()
|
||||
)
|
||||
|
||||
for d in self.attributes:
|
||||
if (d.attribute, d.attribute_value) in saved_attributes:
|
||||
continue
|
||||
|
||||
if not frappe.db.exists(
|
||||
"Item Variant Attribute", {"attribute": d.attribute, "parent": self.variant_of}
|
||||
):
|
||||
|
||||
@@ -360,6 +360,45 @@ class TestItem(FrappeTestCase):
|
||||
self.assertRaises(InvalidItemAttributeValueError, attribute.save)
|
||||
frappe.db.rollback()
|
||||
|
||||
def test_disabled_attribute_blocks_only_attribute_changes(self):
|
||||
frappe.delete_doc_if_exists("Item", "_Test Disabled Attribute Template-L", force=1)
|
||||
frappe.delete_doc_if_exists("Item", "_Test Disabled Attribute Template", force=1)
|
||||
frappe.delete_doc_if_exists("Item Attribute", "_Test Disabled Size", force=1)
|
||||
|
||||
attribute = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Item Attribute",
|
||||
"attribute_name": "_Test Disabled Size",
|
||||
"item_attribute_values": [
|
||||
{"attribute_value": "Large", "abbr": "L"},
|
||||
{"attribute_value": "Small", "abbr": "S"},
|
||||
],
|
||||
}
|
||||
).insert()
|
||||
|
||||
template = make_item(
|
||||
"_Test Disabled Attribute Template",
|
||||
{
|
||||
"has_variants": 1,
|
||||
"variant_based_on": "Item Attribute",
|
||||
"attributes": [{"attribute": attribute.name}],
|
||||
},
|
||||
)
|
||||
|
||||
variant = create_variant(template.name, {attribute.name: "Large"})
|
||||
variant.save()
|
||||
|
||||
attribute.disabled = 1
|
||||
attribute.save()
|
||||
|
||||
variant.reload()
|
||||
variant.description = "Edited after the attribute was disabled"
|
||||
variant.save()
|
||||
|
||||
variant.reload()
|
||||
variant.attributes[0].attribute_value = "Small"
|
||||
self.assertRaises(frappe.ValidationError, variant.save)
|
||||
|
||||
def test_rename_attribute_value_updates_variants(self):
|
||||
frappe.delete_doc_if_exists("Item", "_Test Variant Item-L", force=1)
|
||||
|
||||
|
||||
@@ -795,6 +795,28 @@ class TestMaterialRequest(FrappeTestCase):
|
||||
mr = frappe.get_doc("Material Request", mr.name)
|
||||
self.assertEqual(mr.per_ordered, 100)
|
||||
|
||||
def test_fractional_conversion_factor_for_purchase(self):
|
||||
item = create_item("_Test Fractional Conversion Item", stock_uom="Kg", is_purchase_item=1)
|
||||
conversion_factor = 0.453592292
|
||||
|
||||
mr = make_material_request(
|
||||
item_code=item.name,
|
||||
qty=1000,
|
||||
uom="Pound",
|
||||
conversion_factor=conversion_factor,
|
||||
)
|
||||
mr.reload()
|
||||
|
||||
self.assertEqual(mr.items[0].conversion_factor, conversion_factor)
|
||||
|
||||
po = make_purchase_order(mr.name)
|
||||
po.supplier = "_Test Supplier"
|
||||
po.insert()
|
||||
po.reload()
|
||||
|
||||
self.assertEqual(po.items[0].conversion_factor, conversion_factor)
|
||||
self.assertEqual(po.items[0].stock_qty, mr.items[0].stock_qty)
|
||||
|
||||
def test_customer_provided_parts_mr(self):
|
||||
create_item("CUST-0987", is_customer_provided_item=1, customer="_Test Customer", is_purchase_item=0)
|
||||
existing_requested_qty = self._get_requested_qty("_Test Customer", "_Test Warehouse - _TC")
|
||||
|
||||
@@ -5,7 +5,7 @@ import json
|
||||
|
||||
import frappe
|
||||
from frappe.tests.utils import FrappeTestCase, change_settings
|
||||
from frappe.utils import flt, nowtime, today
|
||||
from frappe.utils import add_days, add_to_date, flt, nowtime, today
|
||||
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
|
||||
@@ -1512,3 +1512,186 @@ class TestSerialandBatchBundleLogic(FrappeTestCase):
|
||||
|
||||
self.assertNotIn(bundles[1], bundle_wise_serial_nos)
|
||||
self.assertEqual(bundle_wise_serial_nos[bundles[0]], [serial_no])
|
||||
|
||||
@change_settings("Stock Settings", {"auto_create_serial_and_batch_bundle_for_outward": 1})
|
||||
def test_batchwise_valuation_for_same_posting_datetime_entries(self):
|
||||
# an inward at a different rate and multiple outward rows with the same
|
||||
# item and warehouse share the same posting datetime, the tie-breaking
|
||||
# must include the same-timestamp entries which are already part of the
|
||||
# ledger and must not let the outward rows count each other
|
||||
item_code = make_item(
|
||||
"Test Batchwise Same Posting Datetime Item 1",
|
||||
properties={
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"create_new_batch": 1,
|
||||
"batch_number_series": "TBSPD-ITEM1-.#####",
|
||||
"valuation_method": "FIFO",
|
||||
},
|
||||
).name
|
||||
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
|
||||
receipt = make_stock_entry(
|
||||
item_code=item_code,
|
||||
qty=10,
|
||||
rate=100,
|
||||
target=warehouse,
|
||||
posting_date=add_days(today(), -5),
|
||||
posting_time="12:00:00",
|
||||
)
|
||||
|
||||
batch_no = get_batch_from_bundle(receipt.items[0].serial_and_batch_bundle)
|
||||
self.assertTrue(frappe.db.get_value("Batch", batch_no, "use_batchwise_valuation"))
|
||||
|
||||
# same posting datetime as the outward rows below, at a different rate
|
||||
make_stock_entry(
|
||||
item_code=item_code,
|
||||
qty=20,
|
||||
rate=250,
|
||||
target=warehouse,
|
||||
batch_no=batch_no,
|
||||
use_serial_batch_fields=1,
|
||||
posting_date=add_days(today(), -3),
|
||||
posting_time="12:00:00",
|
||||
)
|
||||
|
||||
issue = make_stock_entry(
|
||||
item_code=item_code,
|
||||
qty=2,
|
||||
source=warehouse,
|
||||
posting_date=add_days(today(), -3),
|
||||
posting_time="12:00:00",
|
||||
do_not_save=True,
|
||||
)
|
||||
|
||||
for qty in [3, 4]:
|
||||
issue.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": item_code,
|
||||
"s_warehouse": warehouse,
|
||||
"qty": qty,
|
||||
"conversion_factor": 1,
|
||||
},
|
||||
)
|
||||
|
||||
issue.save()
|
||||
issue.submit()
|
||||
|
||||
# (10 * 100 + 20 * 250) / 30 = 200
|
||||
self.assert_batchwise_outgoing_rate(item_code, outgoing_rate=200.0, balance_value=4200.0)
|
||||
|
||||
# backdated receipt reposts the same posting datetime cluster
|
||||
make_stock_entry(
|
||||
item_code=item_code,
|
||||
qty=10,
|
||||
rate=100,
|
||||
target=warehouse,
|
||||
batch_no=batch_no,
|
||||
use_serial_batch_fields=1,
|
||||
posting_date=add_days(today(), -4),
|
||||
posting_time="12:00:00",
|
||||
)
|
||||
|
||||
# (20 * 100 + 20 * 250) / 40 = 175
|
||||
self.assert_batchwise_outgoing_rate(item_code, outgoing_rate=175.0, balance_value=5425.0)
|
||||
|
||||
@change_settings("Stock Settings", {"auto_create_serial_and_batch_bundle_for_outward": 1})
|
||||
def test_batchwise_valuation_when_bundle_created_before_the_sle(self):
|
||||
# a bundle can be created (drafted) much before / after its SLE, the
|
||||
# tie-breaking for the same posting datetime entries must follow the
|
||||
# SLE creation and not the bundle creation
|
||||
item_code = make_item(
|
||||
"Test Batchwise Same Posting Datetime Item 2",
|
||||
properties={
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"create_new_batch": 1,
|
||||
"batch_number_series": "TBSPD-ITEM2-.#####",
|
||||
"valuation_method": "FIFO",
|
||||
},
|
||||
).name
|
||||
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
|
||||
receipt = make_stock_entry(
|
||||
item_code=item_code,
|
||||
qty=10,
|
||||
rate=100,
|
||||
target=warehouse,
|
||||
posting_date=add_days(today(), -5),
|
||||
posting_time="12:00:00",
|
||||
)
|
||||
|
||||
batch_no = get_batch_from_bundle(receipt.items[0].serial_and_batch_bundle)
|
||||
|
||||
# inward at a different rate, same posting datetime as the outward below
|
||||
inward = make_stock_entry(
|
||||
item_code=item_code,
|
||||
qty=10,
|
||||
rate=200,
|
||||
target=warehouse,
|
||||
batch_no=batch_no,
|
||||
use_serial_batch_fields=1,
|
||||
posting_date=add_days(today(), -3),
|
||||
posting_time="12:00:00",
|
||||
)
|
||||
|
||||
outward = make_stock_entry(
|
||||
item_code=item_code,
|
||||
qty=10,
|
||||
source=warehouse,
|
||||
posting_date=add_days(today(), -3),
|
||||
posting_time="12:00:00",
|
||||
)
|
||||
|
||||
# simulate the inward's bundle drafted after the outward's SLE, the
|
||||
# bundle creation timeline no longer matches the SLE creation timeline
|
||||
outward_sle_creation = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_no": outward.name, "is_cancelled": 0},
|
||||
"creation",
|
||||
)
|
||||
|
||||
frappe.db.set_value(
|
||||
"Serial and Batch Bundle",
|
||||
inward.items[0].serial_and_batch_bundle,
|
||||
"creation",
|
||||
add_to_date(outward_sle_creation, minutes=30),
|
||||
update_modified=False,
|
||||
)
|
||||
|
||||
repost = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Repost Item Valuation",
|
||||
"based_on": "Item and Warehouse",
|
||||
"item_code": item_code,
|
||||
"warehouse": warehouse,
|
||||
"posting_date": add_days(today(), -6),
|
||||
"posting_time": "00:00:00",
|
||||
"allow_negative_stock": 1,
|
||||
}
|
||||
)
|
||||
|
||||
repost.submit()
|
||||
|
||||
# (10 * 100 + 10 * 200) / 20 = 150, the inward precedes the outward as
|
||||
# per the SLE creation even though its bundle was created afterwards
|
||||
self.assert_batchwise_outgoing_rate(item_code, outgoing_rate=150.0, balance_value=1500.0)
|
||||
|
||||
def assert_batchwise_outgoing_rate(self, item_code, outgoing_rate, balance_value):
|
||||
sl_entries = frappe.get_all(
|
||||
"Stock Ledger Entry",
|
||||
filters={"item_code": item_code, "is_cancelled": 0},
|
||||
fields=["actual_qty", "stock_value_difference", "stock_value"],
|
||||
order_by="posting_datetime, creation",
|
||||
)
|
||||
|
||||
for sle in sl_entries:
|
||||
if sle.actual_qty > 0:
|
||||
continue
|
||||
|
||||
self.assertEqual(flt(sle.stock_value_difference, 2), flt(sle.actual_qty * outgoing_rate, 2))
|
||||
|
||||
self.assertEqual(flt(sl_entries[-1].stock_value, 2), flt(balance_value, 2))
|
||||
|
||||
@@ -829,14 +829,43 @@ class BatchNoValuation(DeprecatedBatchNoValuation):
|
||||
parent = frappe.qb.DocType("Serial and Batch Bundle")
|
||||
child = frappe.qb.DocType("Serial and Batch Entry")
|
||||
|
||||
sle_creation = self.sle.creation if self.sle.get("name") else None
|
||||
if not self.sle.get("name") and self.sle.get("serial_and_batch_bundle"):
|
||||
sle_creation = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"serial_and_batch_bundle": self.sle.serial_and_batch_bundle, "is_cancelled": 0},
|
||||
"creation",
|
||||
)
|
||||
|
||||
timestamp_condition = ""
|
||||
if self.sle.posting_datetime:
|
||||
timestamp_condition = parent.posting_datetime < self.sle.posting_datetime
|
||||
|
||||
if self.sle.creation:
|
||||
timestamp_condition |= (parent.posting_datetime == self.sle.posting_datetime) & (
|
||||
parent.creation < self.sle.creation
|
||||
sle_table = frappe.qb.DocType("Stock Ledger Entry")
|
||||
if sle_creation:
|
||||
# bundle creation and SLE creation are different timelines (a
|
||||
# bundle can be created much before its SLE), so break the tie
|
||||
# using the creation of the bundle's own SLE
|
||||
tie_condition = ExistsCriterion(
|
||||
frappe.qb.from_(sle_table)
|
||||
.select(sle_table.name)
|
||||
.where(
|
||||
(sle_table.serial_and_batch_bundle == parent.name)
|
||||
& (sle_table.is_cancelled == 0)
|
||||
& (sle_table.creation < sle_creation)
|
||||
)
|
||||
)
|
||||
else:
|
||||
# the current entry is not yet in the ledger and will get the
|
||||
# latest creation, so the same-timestamp entries which are
|
||||
# already in the ledger precede it
|
||||
tie_condition = ExistsCriterion(
|
||||
frappe.qb.from_(sle_table)
|
||||
.select(sle_table.name)
|
||||
.where((sle_table.serial_and_batch_bundle == parent.name) & (sle_table.is_cancelled == 0))
|
||||
)
|
||||
|
||||
timestamp_condition |= (parent.posting_datetime == self.sle.posting_datetime) & tie_condition
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(parent)
|
||||
|
||||
Reference in New Issue
Block a user