Compare commits

..

1 Commits

Author SHA1 Message Date
Sudharsanan Ashok
27081bceca fix: update stock variance account logic which defaults to default expense (#57656)
* fix(stock): update stock variance account logic which defaults to default expense account set in company

* test: add regression test for purchase invoice stock adjustment account fallback

---------

Co-authored-by: Afsal Syed <afsalsyed12@gmail.com>
(cherry picked from commit d09c04a623)

# Conflicts:
#	erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
2026-08-01 16:22:24 +05:30
29 changed files with 170 additions and 644 deletions

View File

@@ -4,7 +4,7 @@ import inspect
import frappe
from frappe.utils.user import is_website_user
__version__ = "15.119.0"
__version__ = "15.112.0"
def get_default_company(user=None):

View File

@@ -24,8 +24,7 @@
"account_number": "11530"
},
"account_number": "115",
"is_group": 1,
"account_type": "Bank"
"is_group": 1
},
"Trade Receivables": {
"Trade Debtors": {
@@ -530,13 +529,6 @@
"account_number": "630",
"is_group": 1
},
"Accrued Manufacturing Expenses": {
"Accrued Expenses - Manufacturing": {
"account_number": "63510"
},
"account_number": "635",
"is_group": 1
},
"account_number": "63",
"is_group": 1
},
@@ -822,4 +814,4 @@
"root_type": "Expense"
}
}
}
}

View File

@@ -1301,14 +1301,8 @@ class PaymentEntry(AccountsController):
self.add_deductions_gl_entries(gl_entries)
self.add_tax_gl_entries(gl_entries)
add_regional_gl_entries(gl_entries, self)
self.set_transaction_currency_and_rate_in_gl_map(gl_entries)
return gl_entries
def set_transaction_currency_and_rate_in_gl_map(self, gl_entries):
for gle in gl_entries:
gle.setdefault("transaction_currency", self.transaction_currency)
gle.setdefault("transaction_exchange_rate", self.transaction_exchange_rate)
def make_gl_entries(self, cancel=0, adv_adj=0):
gl_entries = self.build_gl_map()
gl_entries = process_gl_map(gl_entries)

View File

@@ -1046,17 +1046,14 @@ class TestPaymentEntry(FrappeTestCase):
gle.credit_in_account_currency,
gle.debit_in_transaction_currency,
gle.credit_in_transaction_currency,
gle.transaction_currency,
gle.transaction_exchange_rate,
)
.orderby(gle.account)
.where(gle.voucher_no == payment_entry.name)
.run()
)
# transaction currency/rate come from the paid-from USD account (company currency is INR)
expected_gl_entries = (
(paid_from, 0.0, 8440.0, 0.0, 100.0, 0.0, 100.0, "USD", 84.4),
("_Test Payable USD - _TC", 8440.0, 0.0, 100.0, 0.0, 100.0, 0.0, "USD", 84.4),
(paid_from, 0.0, 8440.0, 0.0, 100.0, 0.0, 100.0),
("_Test Payable USD - _TC", 8440.0, 0.0, 100.0, 0.0, 100.0, 0.0),
)
self.assertEqual(gl_entries, expected_gl_entries)

View File

@@ -2436,86 +2436,6 @@ class TestPaymentReconciliation(FrappeTestCase):
self.assertEqual(flt(pr.allocation[0].get("difference_amount")), -5000.0)
pr.reconcile()
def test_foreign_currency_reverse_payment_entry_gain_for_supplier(self):
transaction_date = nowdate()
self.supplier = "_Test Supplier USD"
amount = 100
department = frappe.db.get_value("Department", {"company": self.company, "is_group": 0}, "name")
# Pay USD 100 at an exchange rate of 90.
pe = self.create_payment_entry(amount=amount, posting_date=transaction_date)
pe.payment_type = "Pay"
pe.party_type = "Supplier"
pe.party = self.supplier
pe.paid_from = self.cash
pe.paid_from_account_currency = "INR"
pe.target_exchange_rate = 90
pe.paid_amount = 90 * amount
pe.received_amount = amount
pe.paid_to = self.creditors_usd
pe.paid_to_account_currency = "USD"
pe.department = department
pe = pe.save().submit()
# Receive USD 100 from the supplier at an exchange rate of 100.
reverse_pe = self.create_payment_entry(amount=amount, posting_date=transaction_date)
reverse_pe.payment_type = "Receive"
reverse_pe.party_type = "Supplier"
reverse_pe.party = self.supplier
reverse_pe.paid_from = self.creditors_usd
reverse_pe.paid_from_account_currency = "USD"
reverse_pe.source_exchange_rate = 100
reverse_pe.paid_amount = amount
reverse_pe.received_amount = 100 * amount
reverse_pe.paid_to = self.cash
reverse_pe.paid_to_account_currency = "INR"
reverse_pe.department = department
reverse_pe = reverse_pe.save().submit()
pr = self.create_payment_reconciliation(party_is_customer=False)
pr.party = self.supplier
pr.receivable_payable_account = self.creditors_usd
pr.get_unreconciled_entries()
invoices = [invoice.as_dict() for invoice in pr.invoices]
payments = [payment.as_dict() for payment in pr.payments]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
for row in pr.allocation:
row.department = department
self.assertEqual(flt(pr.allocation[0].difference_amount), 1000)
pr.reconcile()
gain_loss_journal = frappe.db.get_value(
"Journal Entry Account",
{
"reference_type": reverse_pe.doctype,
"reference_name": reverse_pe.name,
"party": self.supplier,
"docstatus": 1,
},
"parent",
)
party_row = frappe.db.get_value(
"Journal Entry Account",
{"parent": gain_loss_journal, "party": self.supplier},
["debit", "credit"],
as_dict=True,
)
self.assertEqual(flt(party_row.debit), 1000)
self.assertEqual(flt(party_row.credit), 0)
party_gl_entries = frappe.get_all(
"GL Entry",
filters={
"voucher_no": ["in", [pe.name, reverse_pe.name, gain_loss_journal]],
"account": self.creditors_usd,
"party": self.supplier,
"is_cancelled": 0,
},
fields=["debit", "credit"],
)
self.assertEqual(flt(sum(row.debit - row.credit for row in party_gl_entries)), 0)
def test_foreign_currency_reverse_journal_entry_against_journal_entry_for_customer(self):
transaction_date = nowdate()
customer = self.customer3

View File

@@ -1365,7 +1365,20 @@ class PurchaseInvoice(BuyingController):
)
if flt(stock_amount, net_amt_precision) != flt(warehouse_debit_amount, net_amt_precision):
cost_of_goods_sold_account = self.get_company_default("default_expense_account")
stock_asset_rbnb = (
self.get_company_default("asset_received_but_not_billed", ignore_validation=True)
if item.is_fixed_asset
else self.get_company_default("stock_received_but_not_billed", ignore_validation=True)
)
fallback_account = (
(item.expense_account or stock_asset_rbnb)
if self.is_return
else (stock_asset_rbnb or item.expense_account)
)
cost_of_goods_sold_account = (
self.get_company_default("default_expense_account", ignore_validation=True)
or fallback_account
)
stock_adjustment_amt = stock_amount - warehouse_debit_amount
gl_entries.append(
@@ -1390,7 +1403,20 @@ class PurchaseInvoice(BuyingController):
and warehouse_debit_amount
!= flt(voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision)
):
cost_of_goods_sold_account = self.get_company_default("default_expense_account")
stock_asset_rbnb = (
self.get_company_default("asset_received_but_not_billed", ignore_validation=True)
if item.is_fixed_asset
else self.get_company_default("stock_received_but_not_billed", ignore_validation=True)
)
fallback_account = (
(item.expense_account or stock_asset_rbnb)
if self.is_return
else (stock_asset_rbnb or item.expense_account)
)
cost_of_goods_sold_account = (
self.get_company_default("default_expense_account", ignore_validation=True)
or fallback_account
)
stock_amount = flt(voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision)
stock_adjustment_amt = warehouse_debit_amount - stock_amount

View File

@@ -1510,6 +1510,96 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
)
frappe.db.set_value("Company", "_Test Company", "exchange_gain_loss_account", original_account)
def test_stock_adjustment_account_fallbacks_when_default_expense_account_unset(self):
from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import PurchaseInvoice
class StockAdjustmentInvoice:
company = "_Test Company"
conversion_rate = 1
update_stock = 1
is_internal_supplier = 0
return_against = None
project = None
def __init__(self, is_return, defaults):
self.is_return = is_return
self.defaults = defaults
def get(self, fieldname):
return None
def get_company_default(self, fieldname, ignore_validation=False):
return self.defaults.get(fieldname)
def get_gl_dict(self, args, *unused_args, **unused_kwargs):
return frappe._dict(args)
def make_invoice(is_return, defaults):
return StockAdjustmentInvoice(is_return, defaults)
def make_item(is_fixed_asset=0, expense_account="Item Expense - _TC"):
return frappe._dict(
{
"name": "row-1",
"warehouse": "Stores - _TC",
"valuation_rate": 10,
"qty": 10,
"conversion_factor": 1,
"base_net_amount": 100,
"item_tax_amount": 0,
"landed_cost_voucher_amount": 0,
"sales_incoming_rate": 0,
"is_fixed_asset": is_fixed_asset,
"expense_account": expense_account,
"cost_center": "Main - _TC",
"project": None,
"precision": lambda fieldname: 2,
}
)
defaults = {
"default_expense_account": None,
"stock_received_but_not_billed": "Stock Received But Not Billed - _TC",
"asset_received_but_not_billed": "Asset Received But Not Billed - _TC",
}
test_cases = (
(
"company default expense",
0,
make_item(),
{**defaults, "default_expense_account": "Default Expense - _TC"},
"Default Expense - _TC",
),
("stock rbnb", 0, make_item(), defaults, "Stock Received But Not Billed - _TC"),
(
"asset rbnb",
0,
make_item(is_fixed_asset=1),
defaults,
"Asset Received But Not Billed - _TC",
),
("return item expense", 1, make_item(), defaults, "Item Expense - _TC"),
(
"return without item expense",
1,
make_item(expense_account=None),
defaults,
"Stock Received But Not Billed - _TC",
),
)
for label, is_return, item, company_defaults, expected_account in test_cases:
with self.subTest(label=label):
invoice = make_invoice(is_return, company_defaults)
gl_entries = []
PurchaseInvoice.make_stock_adjustment_entry(
invoice, gl_entries, item, {(item.name, item.warehouse): 90}, "INR"
)
self.assertEqual(gl_entries[0].account, expected_account)
self.assertEqual(gl_entries[0].debit, 10)
self.assertEqual(gl_entries[0].debit_in_transaction_currency, 10)
@change_settings("Accounts Settings", {"unlink_payment_on_cancellation_of_invoice": 1})
def test_purchase_invoice_advance_taxes(self):
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry

View File

@@ -473,24 +473,19 @@ def get_child_docs(doc: list) -> list:
def validate_docs_for_deferred_accounting(sales_docs, purchase_docs):
docs_with_deferred_revenue = ()
docs_with_deferred_expense = ()
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 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,
)
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(

View File

@@ -161,14 +161,7 @@ class ShippingRule(Document):
)
shipping_charge["add_deduct_tax"] = "Add"
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)
existing_shipping_charge = doc.get("taxes", filters=shipping_charge)
if existing_shipping_charge:
# take the last record found
existing_shipping_charge[-1].tax_amount = shipping_amount

View File

@@ -849,12 +849,10 @@ def validate_account_party_type(self):
def get_dashboard_info(party_type, party, loyalty_program=None):
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)
doctype = "Sales Invoice" if party_type == "Customer" else "Purchase Invoice"
companies = frappe.get_list(
doctype, filters={"docstatus": 1, party_type.lower(): party}, distinct=1, fields=["company"]
)

View File

@@ -1869,7 +1869,7 @@ class AccountsController(TransactionBase):
def is_payable_account(self, reference_doctype, account):
if reference_doctype == "Purchase Invoice" or (
reference_doctype in ("Journal Entry", "Payment Entry")
reference_doctype == "Journal Entry"
and frappe.get_cached_value("Account", account, "account_type") == "Payable"
):
return True

View File

@@ -358,7 +358,7 @@ class BuyingController(SubcontractingController):
)
valuation_amount_adjustment -= item.item_tax_amount
self.round_floats_in(item, do_not_round_fields=["conversion_factor"])
self.round_floats_in(item)
if flt(item.conversion_factor) == 0.0:
item.conversion_factor = (
get_conversion_factor(item.item_code, item.uom).get("conversion_factor") or 1.0

View File

@@ -158,28 +158,10 @@ def validate_returned_items(doc):
):
frappe.throw(_("Warehouse is mandatory"))
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
items_returned = True
elif d.item_name:
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
items_returned = True
if not items_returned:
frappe.throw(_("Atleast one item should be entered with negative quantity in return document"))

View File

@@ -185,12 +185,7 @@ class calculate_taxes_and_totals:
return
if not self.discount_amount_applied:
do_not_round_fields = [
"valuation_rate",
"incoming_rate",
"sales_incoming_rate",
"conversion_factor",
]
do_not_round_fields = ["valuation_rate", "incoming_rate", "sales_incoming_rate"]
for item in self.doc.items:
self.doc.round_floats_in(item, do_not_round_fields=do_not_round_fields)

View File

@@ -1,89 +0,0 @@
# 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)

View File

@@ -132,7 +132,6 @@ 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:
@@ -143,15 +142,6 @@ 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):

View File

@@ -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="{{frappe.utils.escape_html(row.image)}}">
<img style="width:50px;height:50px;" src="{{row.image}}">
{% } else { %}
<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 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>
<div class="col-sm-3">
@@ -13,7 +13,7 @@
{% } else { %}
{{row.item_link}}
<p>
{{frappe.utils.escape_html(row.item_name)}}
{{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="{{ frappe.utils.escape_html(row.item_code) }}">{{ __("Add") }}</button>
<button style="margin-left: 7px;" class="btn btn-default btn-xs btn-add" data-item-code="{{ escape(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="{{ frappe.utils.escape_html(row.item_code) }}">{{ __("Move") }}</button>
<button style="margin-left: 7px;" class="btn btn-default btn-xs btn-move" data-item-code="{{ escape(row.item_code) }}">{{ __("Move") }}</button>
</div>
</div>
{% }); %}

View File

@@ -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 = frappe.utils.escape_html(d.off_status_image)
d.status_image = d.off_status_image
return data

View File

@@ -126,26 +126,11 @@ 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, fields_to_round);
frappe.model.round_floats_in(item);
item.net_rate = item.rate;
item.qty = item.qty === undefined ? (me.frm.doc.is_return ? -1 : 1) : item.qty;

View File

@@ -1,19 +1,17 @@
<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_title }}">
{{ item_title }}</a>
<a class="grey list-id" data-name="{{item.name}}"
title="{{ item.item_name || item.name}}">
{{item.item_name || item.name}}</a>
</div>
</div>
<div class="image-view-body">
<a data-item-code="{{ item_name }}"
title="{{ item_title }}"
<a data-item-code="{{ item.name }}"
title="{{ item.item_name || item.name }}"
>
<div class="image-field"
style="
@@ -24,11 +22,11 @@
>
{% if (!item.image) { %}
<span class="placeholder-text">
{%= frappe.get_abbr(item_title) %}
{%= frappe.get_abbr(item.item_name || item.name) %}
</span>
{% } %}
{% if (item.image) { %}
<img src="{{ frappe.utils.escape_html(item.image) }}" alt="{{ item_title }}">
<img src="{{ item.image }}" alt="{{item.item_name || item.name}}">
{% } %}
</div>
</a>

View File

@@ -1,5 +1,4 @@
{% $.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") { %}
@@ -18,14 +17,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_workstation_name, 2)}}</div>
<div class="workstation-image-cls workstation-abbr">{{frappe.get_abbr(row.name, 2)}}</div>
{% } %}
</a>
</div>
</div>
<div class="workstation-card" style="display: grid;">
<span class="ellipsis" title="{{row_workstation_name}}">
{{row_workstation_name}}
<span class="ellipsis" title="{{row.name}}">
{{row.workstation_name}}
</span>
</div>
</div>

View File

@@ -1984,41 +1984,6 @@ 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},

View File

@@ -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, ignore_permissions=True)
remove_user_permission("Employee", self.name, existing_user_id)
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, ignore_permissions=True)
remove_user_permission("Company", self.company, self.user_id, ignore_permissions=True)
remove_user_permission("Employee", self.name, self.user_id)
remove_user_permission("Company", self.company, self.user_id)
elif not employee_user_permission_exists and self.create_user_permission:
add_user_permission("Employee", self.name, self.user_id, ignore_permissions=True)
add_user_permission("Company", self.company, self.user_id, ignore_permissions=True)
add_user_permission("Employee", self.name, self.user_id)
add_user_permission("Company", self.company, self.user_id)
def update_user(self):
# add employee role if missing

View File

@@ -837,17 +837,7 @@ 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}
):

View File

@@ -360,45 +360,6 @@ 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)

View File

@@ -795,28 +795,6 @@ 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")

View File

@@ -5,7 +5,7 @@ import json
import frappe
from frappe.tests.utils import FrappeTestCase, change_settings
from frappe.utils import add_days, add_to_date, flt, nowtime, today
from frappe.utils import 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,186 +1512,3 @@ 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))

View File

@@ -7,8 +7,6 @@ from frappe.utils import today
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
from erpnext.stock.doctype.warehouse.warehouse import get_warehouses_based_on_account
from erpnext.stock.report.stock_and_account_value_comparison.stock_and_account_value_comparison import (
create_reposting_entries,
execute,
@@ -57,22 +55,3 @@ class TestStockAndAccountValueComparison(FrappeTestCase):
filters={"based_on": "Item and Warehouse", "item_code": item},
)
self.assertFalse(item_wh_rivs, "Purchase vouchers must not be reposted Item-and-Warehouse based")
def test_child_account_override_excluded_from_group_account(self):
# A group warehouse carries an inventory account; a child (e.g. Goods-in-Transit) can override
# it with its own account. get_warehouses_based_on_account must return only warehouses whose
# effective account matches, excluding the overriding child.
group = create_warehouse("_Test SAVC Group WH", {"is_group": 1}, company=PI_COMPANY)
group_account = frappe.get_value("Warehouse", group, "account")
inheriting = create_warehouse(
"_Test SAVC Inherit WH", {"parent_warehouse": group, "account": group_account}, company=PI_COMPANY
)
overriding = create_warehouse(
"_Test SAVC Transit WH", {"parent_warehouse": group}, company=PI_COMPANY
)
warehouses = get_warehouses_based_on_account(group_account, PI_COMPANY)
self.assertIn(inheriting, warehouses)
self.assertNotIn(overriding, warehouses)

View File

@@ -829,43 +829,14 @@ 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
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)
)
if self.sle.creation:
timestamp_condition |= (parent.posting_datetime == self.sle.posting_datetime) & (
parent.creation < self.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)