mirror of
https://github.com/frappe/erpnext.git
synced 2026-02-17 00:25:01 +00:00
Merge pull request #51124 from frappe/version-15-hotfix
chore: release v15
This commit is contained in:
@@ -38,7 +38,10 @@
|
||||
"column_break_3czf",
|
||||
"bank_party_name",
|
||||
"bank_party_account_number",
|
||||
"bank_party_iban"
|
||||
"bank_party_iban",
|
||||
"extended_bank_statement_section",
|
||||
"included_fee",
|
||||
"excluded_fee"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -233,12 +236,32 @@
|
||||
{
|
||||
"fieldname": "column_break_oufv",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "extended_bank_statement_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Extended Bank Statement"
|
||||
},
|
||||
{
|
||||
"fieldname": "included_fee",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Included Fee",
|
||||
"non_negative": 1,
|
||||
"options": "currency"
|
||||
},
|
||||
{
|
||||
"description": "On save, the Excluded Fee will be converted to an Included Fee.",
|
||||
"fieldname": "excluded_fee",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Excluded Fee",
|
||||
"non_negative": 1,
|
||||
"options": "currency"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-10-23 17:32:58.514807",
|
||||
"modified": "2025-12-07 20:49:18.600757",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Bank Transaction",
|
||||
|
||||
@@ -32,6 +32,8 @@ class BankTransaction(Document):
|
||||
date: DF.Date | None
|
||||
deposit: DF.Currency
|
||||
description: DF.SmallText | None
|
||||
excluded_fee: DF.Currency
|
||||
included_fee: DF.Currency
|
||||
naming_series: DF.Literal["ACC-BTN-.YYYY.-"]
|
||||
party: DF.DynamicLink | None
|
||||
party_type: DF.Link | None
|
||||
@@ -45,9 +47,11 @@ class BankTransaction(Document):
|
||||
# end: auto-generated types
|
||||
|
||||
def before_validate(self):
|
||||
self.handle_excluded_fee()
|
||||
self.update_allocated_amount()
|
||||
|
||||
def validate(self):
|
||||
self.validate_included_fee()
|
||||
self.validate_duplicate_references()
|
||||
self.validate_currency()
|
||||
|
||||
@@ -307,6 +311,40 @@ class BankTransaction(Document):
|
||||
|
||||
self.party_type, self.party = result
|
||||
|
||||
def validate_included_fee(self):
|
||||
"""
|
||||
The included_fee is only handled for withdrawals. An included_fee for a deposit, is not credited to the account and is
|
||||
therefore outside of the deposit value and can be larger than the deposit itself.
|
||||
"""
|
||||
|
||||
if self.included_fee and self.withdrawal:
|
||||
if self.included_fee > self.withdrawal:
|
||||
frappe.throw(_("Included fee is bigger than the withdrawal itself."))
|
||||
|
||||
def handle_excluded_fee(self):
|
||||
# Include the excluded fee on validate to handle all further processing the same
|
||||
excluded_fee = flt(self.excluded_fee)
|
||||
if excluded_fee <= 0:
|
||||
return
|
||||
|
||||
# Suppress a negative deposit (aka withdrawal), likely not intendend
|
||||
if flt(self.deposit) > 0 and (flt(self.deposit) - excluded_fee) < 0:
|
||||
frappe.throw(_("The Excluded Fee is bigger than the Deposit it is deducted from."))
|
||||
|
||||
# Enforce directionality
|
||||
if flt(self.deposit) > 0 and flt(self.withdrawal) > 0:
|
||||
frappe.throw(
|
||||
_("Only one of Deposit or Withdrawal should be non-zero when applying an Excluded Fee.")
|
||||
)
|
||||
|
||||
if flt(self.deposit) > 0:
|
||||
self.deposit = flt(self.deposit) - excluded_fee
|
||||
# A fee applied to deposit and withdrawal equal 0 become a withdrawal
|
||||
elif flt(self.withdrawal) >= 0:
|
||||
self.withdrawal = flt(self.withdrawal) + excluded_fee
|
||||
self.included_fee = flt(self.included_fee) + excluded_fee
|
||||
self.excluded_fee = 0
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_doctypes_for_bank_reconciliation():
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
|
||||
|
||||
class TestBankTransactionFees(FrappeTestCase):
|
||||
def test_included_fee_throws(self):
|
||||
"""A fee that's part of a withdrawal cannot be bigger than the
|
||||
withdrawal itself."""
|
||||
bt = frappe.new_doc("Bank Transaction")
|
||||
bt.withdrawal = 100
|
||||
bt.included_fee = 101
|
||||
|
||||
self.assertRaises(frappe.ValidationError, bt.validate_included_fee)
|
||||
|
||||
def test_included_fee_allows_equal(self):
|
||||
"""A fee that's part of a withdrawal may be equal to the withdrawal
|
||||
amount (only the fee was deducted from the account)."""
|
||||
bt = frappe.new_doc("Bank Transaction")
|
||||
bt.withdrawal = 100
|
||||
bt.included_fee = 100
|
||||
|
||||
bt.validate_included_fee()
|
||||
|
||||
def test_included_fee_allows_for_deposit(self):
|
||||
"""For deposits, a fee may be recorded separately without limiting the
|
||||
received amount."""
|
||||
bt = frappe.new_doc("Bank Transaction")
|
||||
bt.deposit = 10
|
||||
bt.included_fee = 999
|
||||
|
||||
bt.validate_included_fee()
|
||||
|
||||
def test_excluded_fee_noop_when_zero(self):
|
||||
"""When there is no excluded fee to apply, the amounts should remain
|
||||
unchanged."""
|
||||
bt = frappe.new_doc("Bank Transaction")
|
||||
bt.deposit = 100
|
||||
bt.withdrawal = 0
|
||||
bt.included_fee = 5
|
||||
bt.excluded_fee = 0
|
||||
|
||||
bt.handle_excluded_fee()
|
||||
|
||||
self.assertEqual(bt.deposit, 100)
|
||||
self.assertEqual(bt.withdrawal, 0)
|
||||
self.assertEqual(bt.included_fee, 5)
|
||||
self.assertEqual(bt.excluded_fee, 0)
|
||||
|
||||
def test_excluded_fee_throws_when_exceeds_deposit(self):
|
||||
"""A fee deducted from an incoming payment must not exceed the incoming
|
||||
amount (else it would be a withdrawal, a conversion we don't support)."""
|
||||
bt = frappe.new_doc("Bank Transaction")
|
||||
bt.deposit = 10
|
||||
bt.excluded_fee = 11
|
||||
|
||||
self.assertRaises(frappe.ValidationError, bt.handle_excluded_fee)
|
||||
|
||||
def test_excluded_fee_throws_when_both_deposit_and_withdrawal_are_set(self):
|
||||
"""A transaction must be either incoming or outgoing when applying a
|
||||
fee, not both."""
|
||||
bt = frappe.new_doc("Bank Transaction")
|
||||
bt.deposit = 10
|
||||
bt.withdrawal = 10
|
||||
bt.excluded_fee = 1
|
||||
|
||||
self.assertRaises(frappe.ValidationError, bt.handle_excluded_fee)
|
||||
|
||||
def test_excluded_fee_deducts_from_deposit(self):
|
||||
"""When a fee is deducted from an incoming payment, the net received
|
||||
amount decreases and the fee is tracked as included."""
|
||||
bt = frappe.new_doc("Bank Transaction")
|
||||
bt.deposit = 100
|
||||
bt.withdrawal = 0
|
||||
bt.included_fee = 2
|
||||
bt.excluded_fee = 5
|
||||
|
||||
bt.handle_excluded_fee()
|
||||
|
||||
self.assertEqual(bt.deposit, 95)
|
||||
self.assertEqual(bt.withdrawal, 0)
|
||||
self.assertEqual(bt.included_fee, 7)
|
||||
self.assertEqual(bt.excluded_fee, 0)
|
||||
|
||||
def test_excluded_fee_can_reduce_an_incoming_payment_to_zero(self):
|
||||
"""A separately-deducted fee may reduce an incoming payment to zero,
|
||||
while still tracking the fee."""
|
||||
bt = frappe.new_doc("Bank Transaction")
|
||||
bt.deposit = 5
|
||||
bt.withdrawal = 0
|
||||
bt.included_fee = 0
|
||||
bt.excluded_fee = 5
|
||||
|
||||
bt.handle_excluded_fee()
|
||||
|
||||
self.assertEqual(bt.deposit, 0)
|
||||
self.assertEqual(bt.withdrawal, 0)
|
||||
self.assertEqual(bt.included_fee, 5)
|
||||
self.assertEqual(bt.excluded_fee, 0)
|
||||
|
||||
def test_excluded_fee_increases_outgoing_payment(self):
|
||||
"""When a separately-deducted fee is provided for an outgoing payment,
|
||||
the total money leaving increases and the fee is tracked."""
|
||||
bt = frappe.new_doc("Bank Transaction")
|
||||
bt.deposit = 0
|
||||
bt.withdrawal = 100
|
||||
bt.included_fee = 2
|
||||
bt.excluded_fee = 5
|
||||
|
||||
bt.handle_excluded_fee()
|
||||
|
||||
self.assertEqual(bt.deposit, 0)
|
||||
self.assertEqual(bt.withdrawal, 105)
|
||||
self.assertEqual(bt.included_fee, 7)
|
||||
self.assertEqual(bt.excluded_fee, 0)
|
||||
|
||||
def test_excluded_fee_turns_zero_amount_into_withdrawal(self):
|
||||
"""If only an excluded fee is provided, it should be treated as an
|
||||
outgoing payment and the fee is then tracked as included."""
|
||||
bt = frappe.new_doc("Bank Transaction")
|
||||
bt.deposit = 0
|
||||
bt.withdrawal = 0
|
||||
bt.included_fee = 0
|
||||
bt.excluded_fee = 5
|
||||
|
||||
bt.handle_excluded_fee()
|
||||
|
||||
self.assertEqual(bt.deposit, 0)
|
||||
self.assertEqual(bt.withdrawal, 5)
|
||||
self.assertEqual(bt.included_fee, 5)
|
||||
self.assertEqual(bt.excluded_fee, 0)
|
||||
@@ -19,7 +19,7 @@ frappe.ui.form.on("Currency Exchange Settings", {
|
||||
to: "{to_currency}",
|
||||
};
|
||||
add_param(frm, r.message, params, result);
|
||||
} else if (frm.doc.service_provider == "frankfurter.dev") {
|
||||
} else if (["frankfurter.app", "frankfurter.dev"].includes(frm.doc.service_provider)) {
|
||||
let result = ["rates", "{to_currency}"];
|
||||
let params = {
|
||||
base: "{from_currency}",
|
||||
|
||||
@@ -60,7 +60,7 @@ class CurrencyExchangeSettings(Document):
|
||||
self.append("req_params", {"key": "date", "value": "{transaction_date}"})
|
||||
self.append("req_params", {"key": "from", "value": "{from_currency}"})
|
||||
self.append("req_params", {"key": "to", "value": "{to_currency}"})
|
||||
elif self.service_provider == "frankfurter.dev":
|
||||
elif self.service_provider in ("frankfurter.dev", "frankfurter.app"):
|
||||
self.set("result_key", [])
|
||||
self.set("req_params", [])
|
||||
|
||||
@@ -105,9 +105,11 @@ class CurrencyExchangeSettings(Document):
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_api_endpoint(service_provider: str | None = None, use_http: bool = False):
|
||||
if service_provider and service_provider in ["exchangerate.host", "frankfurter.dev"]:
|
||||
if service_provider and service_provider in ["exchangerate.host", "frankfurter.dev", "frankfurter.app"]:
|
||||
if service_provider == "exchangerate.host":
|
||||
api = "api.exchangerate.host/convert"
|
||||
elif service_provider == "frankfurter.app":
|
||||
api = "api.frankfurter.app/{transaction_date}"
|
||||
elif service_provider == "frankfurter.dev":
|
||||
api = "api.frankfurter.dev/v1/{transaction_date}"
|
||||
|
||||
|
||||
@@ -486,6 +486,9 @@ class ExchangeRateRevaluation(Document):
|
||||
journal_entry.posting_date = self.posting_date
|
||||
journal_entry.multi_currency = 1
|
||||
|
||||
# Prevent JE from overriding user-entered exchange rates (e.g., rate of 1)
|
||||
journal_entry.flags.ignore_exchange_rate = True
|
||||
|
||||
journal_entry_accounts = []
|
||||
for d in accounts:
|
||||
if not flt(d.get("balance_in_account_currency"), d.precision("balance_in_account_currency")):
|
||||
|
||||
@@ -1302,15 +1302,14 @@ frappe.ui.form.on("Payment Entry", {
|
||||
let row = (frm.doc.deductions || []).find((t) => t.is_exchange_gain_loss);
|
||||
|
||||
if (!row) {
|
||||
const response = await get_company_defaults(frm.doc.company);
|
||||
|
||||
const company_defaults = frappe.get_doc(":Company", frm.doc.company);
|
||||
const account =
|
||||
response.message?.[account_fieldname] ||
|
||||
company_defaults?.[account_fieldname] ||
|
||||
(await prompt_for_missing_account(frm, account_fieldname));
|
||||
|
||||
row = frm.add_child("deductions");
|
||||
row.account = account;
|
||||
row.cost_center = response.message?.cost_center;
|
||||
row.cost_center = company_defaults?.cost_center;
|
||||
row.is_exchange_gain_loss = 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -545,6 +545,9 @@ def make_payment_request(**args):
|
||||
if args.dt not in ALLOWED_DOCTYPES_FOR_PAYMENT_REQUEST:
|
||||
frappe.throw(_("Payment Requests cannot be created against: {0}").format(frappe.bold(args.dt)))
|
||||
|
||||
if args.dn and not isinstance(args.dn, str):
|
||||
frappe.throw(_("Invalid parameter. 'dn' should be of type str"))
|
||||
|
||||
ref_doc = args.ref_doc or frappe.get_doc(args.dt, args.dn)
|
||||
if not args.get("company"):
|
||||
args.company = ref_doc.company
|
||||
|
||||
@@ -575,17 +575,6 @@ cur_frm.fields_dict["items"].grid.get_field("cost_center").get_query = function
|
||||
};
|
||||
};
|
||||
|
||||
cur_frm.cscript.cost_center = function (doc, cdt, cdn) {
|
||||
var d = locals[cdt][cdn];
|
||||
if (d.cost_center) {
|
||||
var cl = doc.items || [];
|
||||
for (var i = 0; i < cl.length; i++) {
|
||||
if (!cl[i].cost_center) cl[i].cost_center = d.cost_center;
|
||||
}
|
||||
}
|
||||
refresh_field("items");
|
||||
};
|
||||
|
||||
cur_frm.fields_dict["items"].grid.get_field("project").get_query = function (doc, cdt, cdn) {
|
||||
return {
|
||||
filters: [["Project", "status", "not in", "Completed, Cancelled"]],
|
||||
|
||||
@@ -648,10 +648,6 @@ cur_frm.cscript.expense_account = function (doc, cdt, cdn) {
|
||||
erpnext.utils.copy_value_in_all_rows(doc, cdt, cdn, "items", "expense_account");
|
||||
};
|
||||
|
||||
cur_frm.cscript.cost_center = function (doc, cdt, cdn) {
|
||||
erpnext.utils.copy_value_in_all_rows(doc, cdt, cdn, "items", "cost_center");
|
||||
};
|
||||
|
||||
cur_frm.set_query("debit_to", function (doc) {
|
||||
return {
|
||||
filters: {
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"fieldname": "rate",
|
||||
"fieldtype": "Int",
|
||||
"fieldtype": "Currency",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
@@ -102,7 +102,7 @@
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"unique": 0
|
||||
},
|
||||
},
|
||||
{
|
||||
"allow_bulk_edit": 0,
|
||||
"allow_on_submit": 0,
|
||||
@@ -199,7 +199,7 @@
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"fieldname": "amount",
|
||||
"fieldtype": "Int",
|
||||
"fieldtype": "Currency",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
@@ -221,7 +221,7 @@
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"unique": 0
|
||||
},
|
||||
},
|
||||
{
|
||||
"allow_bulk_edit": 0,
|
||||
"allow_on_submit": 0,
|
||||
@@ -324,7 +324,7 @@
|
||||
"issingle": 0,
|
||||
"istable": 1,
|
||||
"max_attachments": 0,
|
||||
"modified": "2018-01-10 18:32:36.201124",
|
||||
"modified": "2025-12-10 08:06:40.611761",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Share Balance",
|
||||
@@ -339,4 +339,4 @@
|
||||
"sort_order": "DESC",
|
||||
"track_changes": 1,
|
||||
"track_seen": 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ class ShareBalance(Document):
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
amount: DF.Int
|
||||
amount: DF.Currency
|
||||
current_state: DF.Literal["", "Issued", "Purchased"]
|
||||
from_no: DF.Int
|
||||
is_company: DF.Check
|
||||
@@ -22,7 +22,7 @@ class ShareBalance(Document):
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
rate: DF.Int
|
||||
rate: DF.Currency
|
||||
share_type: DF.Link
|
||||
to_no: DF.Int
|
||||
# end: auto-generated types
|
||||
|
||||
@@ -199,19 +199,20 @@ def distribute_gl_based_on_cost_center_allocation(gl_map, precision=None, from_r
|
||||
for d in gl_map:
|
||||
cost_center = d.get("cost_center")
|
||||
|
||||
cost_center_allocation = get_cost_center_allocation_data(
|
||||
gl_map[0]["company"], gl_map[0]["posting_date"], cost_center
|
||||
)
|
||||
|
||||
if not cost_center_allocation:
|
||||
new_gl_map.append(d)
|
||||
continue
|
||||
|
||||
# Validate budget against main cost center
|
||||
if not from_repost:
|
||||
validate_expense_against_budget(
|
||||
d, expense_amount=flt(d.debit, precision) - flt(d.credit, precision)
|
||||
)
|
||||
|
||||
cost_center_allocation = get_cost_center_allocation_data(
|
||||
gl_map[0]["company"], gl_map[0]["posting_date"], cost_center
|
||||
)
|
||||
if not cost_center_allocation:
|
||||
new_gl_map.append(d)
|
||||
continue
|
||||
|
||||
if d.account == round_off_account:
|
||||
d.cost_center = cost_center_allocation[0][0]
|
||||
new_gl_map.append(d)
|
||||
@@ -414,7 +415,11 @@ def make_entry(args, adv_adj, update_outstanding, from_repost=False):
|
||||
gle.flags.notify_update = False
|
||||
gle.submit()
|
||||
|
||||
if not from_repost and gle.voucher_type != "Period Closing Voucher":
|
||||
if (
|
||||
not from_repost
|
||||
and gle.voucher_type != "Period Closing Voucher"
|
||||
and (gle.is_cancelled == 0 or gle.voucher_type == "Journal Entry")
|
||||
):
|
||||
validate_expense_against_budget(args)
|
||||
|
||||
|
||||
|
||||
@@ -482,7 +482,7 @@ def get_accountwise_gle(filters, accounting_dimensions, gl_entries, gle_map, tot
|
||||
|
||||
immutable_ledger = frappe.db.get_single_value("Accounts Settings", "enable_immutable_ledger")
|
||||
|
||||
def update_value_in_dict(data, key, gle):
|
||||
def update_value_in_dict(data, key, gle, show_net_values=False):
|
||||
data[key].debit += gle.debit
|
||||
data[key].credit += gle.credit
|
||||
|
||||
@@ -493,10 +493,14 @@ def get_accountwise_gle(filters, accounting_dimensions, gl_entries, gle_map, tot
|
||||
data[key].debit_in_transaction_currency += gle.debit_in_transaction_currency
|
||||
data[key].credit_in_transaction_currency += gle.credit_in_transaction_currency
|
||||
|
||||
if filters.get("show_net_values_in_party_account") and account_type_map.get(data[key].account) in (
|
||||
"Receivable",
|
||||
"Payable",
|
||||
):
|
||||
if (
|
||||
filters.get("show_net_values_in_party_account")
|
||||
and account_type_map.get(data[key].account)
|
||||
in (
|
||||
"Receivable",
|
||||
"Payable",
|
||||
)
|
||||
) or show_net_values:
|
||||
net_value = data[key].debit - data[key].credit
|
||||
net_value_in_account_currency = (
|
||||
data[key].debit_in_account_currency - data[key].credit_in_account_currency
|
||||
@@ -526,11 +530,11 @@ def get_accountwise_gle(filters, accounting_dimensions, gl_entries, gle_map, tot
|
||||
|
||||
if gle.posting_date < from_date or (cstr(gle.is_opening) == "Yes" and not show_opening_entries):
|
||||
if not group_by_voucher_consolidated:
|
||||
update_value_in_dict(gle_map[group_by_value].totals, "opening", gle)
|
||||
update_value_in_dict(gle_map[group_by_value].totals, "closing", gle)
|
||||
update_value_in_dict(gle_map[group_by_value].totals, "opening", gle, True)
|
||||
update_value_in_dict(gle_map[group_by_value].totals, "closing", gle, True)
|
||||
|
||||
update_value_in_dict(totals, "opening", gle)
|
||||
update_value_in_dict(totals, "closing", gle)
|
||||
update_value_in_dict(totals, "opening", gle, True)
|
||||
update_value_in_dict(totals, "closing", gle, True)
|
||||
|
||||
elif gle.posting_date <= to_date or (cstr(gle.is_opening) == "Yes" and show_opening_entries):
|
||||
if not group_by_voucher_consolidated:
|
||||
|
||||
@@ -399,7 +399,7 @@ def prepare_data(accounts, filters, parent_children_map, company_currency):
|
||||
}
|
||||
|
||||
for key in value_fields:
|
||||
row[key] = flt(d.get(key, 0.0), 3)
|
||||
row[key] = flt(d.get(key, 0.0))
|
||||
|
||||
if abs(row[key]) >= get_zero_cutoff(company_currency):
|
||||
# ignore zero values
|
||||
|
||||
@@ -378,6 +378,9 @@ class calculate_taxes_and_totals:
|
||||
self._calculate()
|
||||
|
||||
def calculate_taxes(self):
|
||||
# reset value from earlier calculations
|
||||
self.grand_total_diff = 0
|
||||
|
||||
doc = self.doc
|
||||
if not doc.get("taxes"):
|
||||
return
|
||||
@@ -587,7 +590,7 @@ class calculate_taxes_and_totals:
|
||||
self.grand_total_diff = 0
|
||||
|
||||
def calculate_totals(self):
|
||||
grand_total_diff = getattr(self, "grand_total_diff", 0)
|
||||
grand_total_diff = self.grand_total_diff
|
||||
|
||||
if self.doc.get("taxes"):
|
||||
self.doc.grand_total = flt(self.doc.get("taxes")[-1].total) + grand_total_diff
|
||||
@@ -850,12 +853,11 @@ class calculate_taxes_and_totals:
|
||||
)
|
||||
)
|
||||
|
||||
if self.doc.docstatus.is_draft():
|
||||
if self.doc.get("write_off_outstanding_amount_automatically"):
|
||||
self.doc.write_off_amount = 0
|
||||
if self.doc.get("write_off_outstanding_amount_automatically"):
|
||||
self.doc.write_off_amount = 0
|
||||
|
||||
self.calculate_outstanding_amount()
|
||||
self.calculate_write_off_amount()
|
||||
self.calculate_outstanding_amount()
|
||||
self.calculate_write_off_amount()
|
||||
|
||||
def is_internal_invoice(self):
|
||||
"""
|
||||
|
||||
@@ -412,29 +412,29 @@ scheduler_events = {
|
||||
"0/15 * * * *": [
|
||||
"erpnext.manufacturing.doctype.bom_update_log.bom_update_log.resume_bom_cost_update_jobs",
|
||||
],
|
||||
"0/30 * * * *": [
|
||||
"erpnext.utilities.doctype.video.video.update_youtube_data",
|
||||
],
|
||||
"0/30 * * * *": [],
|
||||
# Hourly but offset by 30 minutes
|
||||
"30 * * * *": [
|
||||
"erpnext.accounts.doctype.gl_entry.gl_entry.rename_gle_sle_docs",
|
||||
],
|
||||
# Daily but offset by 45 minutes
|
||||
"45 0 * * *": [
|
||||
"erpnext.stock.reorder_item.reorder_item",
|
||||
],
|
||||
"45 0 * * *": [],
|
||||
},
|
||||
"hourly": [
|
||||
"erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings.automatic_synchronization",
|
||||
"erpnext.projects.doctype.project.project.project_status_update_reminder",
|
||||
"erpnext.projects.doctype.project.project.hourly_reminder",
|
||||
"erpnext.projects.doctype.project.project.collect_project_status",
|
||||
],
|
||||
"hourly_long": [
|
||||
"hourly_long": [],
|
||||
"hourly_maintenance": [
|
||||
"erpnext.stock.doctype.repost_item_valuation.repost_item_valuation.repost_entries",
|
||||
"erpnext.utilities.bulk_transaction.retry",
|
||||
"erpnext.projects.doctype.project.project.collect_project_status",
|
||||
"erpnext.projects.doctype.project.project.project_status_update_reminder",
|
||||
"erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings.automatic_synchronization",
|
||||
"erpnext.utilities.doctype.video.video.update_youtube_data",
|
||||
],
|
||||
"daily": [
|
||||
"daily": [],
|
||||
"daily_long": [],
|
||||
"daily_maintenance": [
|
||||
"erpnext.support.doctype.issue.issue.auto_close_tickets",
|
||||
"erpnext.crm.doctype.opportunity.opportunity.auto_close_opportunity",
|
||||
"erpnext.controllers.accounts_controller.update_invoice_status",
|
||||
@@ -458,17 +458,16 @@ scheduler_events = {
|
||||
"erpnext.accounts.utils.auto_create_exchange_rate_revaluation_daily",
|
||||
"erpnext.accounts.utils.run_ledger_health_checks",
|
||||
"erpnext.assets.doctype.asset_maintenance_log.asset_maintenance_log.update_asset_maintenance_log_status",
|
||||
],
|
||||
"weekly": [
|
||||
"erpnext.accounts.utils.auto_create_exchange_rate_revaluation_weekly",
|
||||
],
|
||||
"daily_long": [
|
||||
"erpnext.stock.reorder_item.reorder_item",
|
||||
"erpnext.accounts.doctype.process_subscription.process_subscription.create_subscription_process",
|
||||
"erpnext.setup.doctype.email_digest.email_digest.send",
|
||||
"erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool.auto_update_latest_price_in_all_boms",
|
||||
"erpnext.crm.utils.open_leads_opportunities_based_on_todays_event",
|
||||
"erpnext.assets.doctype.asset.depreciation.post_depreciation_entries",
|
||||
],
|
||||
"weekly": [
|
||||
"erpnext.accounts.utils.auto_create_exchange_rate_revaluation_weekly",
|
||||
],
|
||||
"monthly_long": [
|
||||
"erpnext.accounts.deferred_revenue.process_deferred_accounting",
|
||||
"erpnext.accounts.utils.auto_create_exchange_rate_revaluation_monthly",
|
||||
|
||||
@@ -403,6 +403,10 @@ frappe.ui.form.on("Work Order", {
|
||||
erpnext.work_order
|
||||
.show_prompt_for_qty_input(frm, "Disassemble")
|
||||
.then((data) => {
|
||||
if (flt(data.qty) <= 0) {
|
||||
frappe.msgprint(__("Disassemble Qty cannot be less than or equal to <b>0</b>."));
|
||||
return;
|
||||
}
|
||||
return frappe.xcall("erpnext.manufacturing.doctype.work_order.work_order.make_stock_entry", {
|
||||
work_order_id: frm.doc.name,
|
||||
purpose: "Disassemble",
|
||||
|
||||
@@ -979,14 +979,14 @@ class WorkOrder(Document):
|
||||
|
||||
for d in self.get("operations"):
|
||||
precision = d.precision("completed_qty")
|
||||
qty = flt(d.completed_qty, precision) + flt(d.process_loss_qty, precision)
|
||||
qty = flt(flt(d.completed_qty, precision) + flt(d.process_loss_qty, precision), precision)
|
||||
if not qty:
|
||||
d.status = "Pending"
|
||||
elif flt(qty) < flt(self.qty):
|
||||
elif qty < flt(self.qty, precision):
|
||||
d.status = "Work in Progress"
|
||||
elif flt(qty) == flt(self.qty):
|
||||
elif qty == flt(self.qty, precision):
|
||||
d.status = "Completed"
|
||||
elif flt(qty) <= max_allowed_qty_for_wo:
|
||||
elif qty <= flt(max_allowed_qty_for_wo, precision):
|
||||
d.status = "Completed"
|
||||
else:
|
||||
frappe.throw(_("Completed Qty cannot be greater than 'Qty to Manufacture'"))
|
||||
@@ -1509,7 +1509,7 @@ def make_stock_entry(work_order_id, purpose, qty=None, target_warehouse=None):
|
||||
stock_entry.to_warehouse = target_warehouse or work_order.source_warehouse
|
||||
|
||||
stock_entry.set_stock_entry_type()
|
||||
stock_entry.get_items(qty, work_order.production_item)
|
||||
stock_entry.get_items()
|
||||
|
||||
if purpose != "Disassemble":
|
||||
stock_entry.set_serial_no_batch_for_finished_good()
|
||||
|
||||
@@ -426,4 +426,4 @@ erpnext.patches.v15_0.set_asset_status_if_not_already_set
|
||||
erpnext.patches.v15_0.toggle_legacy_controller_for_period_closing
|
||||
execute:frappe.db.set_single_value("Accounts Settings", "show_party_balance", 1)
|
||||
execute:frappe.db.set_single_value("Accounts Settings", "show_account_balance", 1)
|
||||
erpnext.patches.v16_0.update_currency_exchange_settings_for_frankfurter
|
||||
erpnext.patches.v16_0.update_currency_exchange_settings_for_frankfurter #2025-12-11
|
||||
|
||||
@@ -2,8 +2,13 @@ import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
settings_meta = frappe.get_meta("Currency Exchange Settings")
|
||||
settings = frappe.get_doc("Currency Exchange Settings")
|
||||
if settings.service_provider != "frankfurter.app":
|
||||
|
||||
if (
|
||||
"frankfurter.dev" not in settings_meta.get_options("service_provider").split("\n")
|
||||
or settings.service_provider != "frankfurter.app"
|
||||
):
|
||||
return
|
||||
|
||||
settings.service_provider = "frankfurter.dev"
|
||||
|
||||
@@ -75,13 +75,27 @@ def get_chart_data(data):
|
||||
delay = delay + 1
|
||||
else:
|
||||
on_track = on_track + 1
|
||||
|
||||
labels = []
|
||||
datasets = []
|
||||
colors = []
|
||||
|
||||
if on_track:
|
||||
labels.append(_("On Track"))
|
||||
datasets.append(on_track)
|
||||
colors.append("#84D5BA")
|
||||
if delay:
|
||||
labels.append(_("Delayed"))
|
||||
datasets.append(delay)
|
||||
colors.append("#CB4B5F")
|
||||
|
||||
charts = {
|
||||
"data": {
|
||||
"labels": [_("On Track"), _("Delayed")],
|
||||
"datasets": [{"name": _("Delayed"), "values": [on_track, delay]}],
|
||||
"labels": labels,
|
||||
"datasets": [{"name": _("Delayed"), "values": datasets}],
|
||||
},
|
||||
"type": "percentage",
|
||||
"colors": ["#84D5BA", "#CB4B5F"],
|
||||
"colors": colors,
|
||||
}
|
||||
return charts
|
||||
|
||||
|
||||
@@ -343,6 +343,9 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
|
||||
}
|
||||
|
||||
calculate_taxes() {
|
||||
// reset value from earlier calculations
|
||||
this.grand_total_diff = 0;
|
||||
|
||||
const doc = this.frm.doc;
|
||||
if (!doc.taxes?.length) return;
|
||||
|
||||
@@ -578,6 +581,8 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
|
||||
|
||||
if ( diff && Math.abs(diff) <= (5.0 / Math.pow(10, precision("tax_amount", last_tax))) ) {
|
||||
me.grand_total_diff = diff;
|
||||
} else {
|
||||
me.grand_total_diff = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -587,7 +592,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
|
||||
// Changing sequence can cause rounding_adjustmentng issue and on-screen discrepency
|
||||
const me = this;
|
||||
const tax_count = this.frm.doc.taxes?.length;
|
||||
const grand_total_diff = this.grand_total_diff || 0;
|
||||
const grand_total_diff = this.grand_total_diff;
|
||||
|
||||
this.frm.doc.grand_total = flt(tax_count
|
||||
? this.frm.doc["taxes"][tax_count - 1].total + grand_total_diff
|
||||
|
||||
@@ -1093,12 +1093,8 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
this.frm.refresh_field("payment_schedule");
|
||||
}
|
||||
|
||||
cost_center(doc) {
|
||||
this.frm.doc.items.forEach((item) => {
|
||||
item.cost_center = doc.cost_center;
|
||||
});
|
||||
|
||||
this.frm.refresh_field("items");
|
||||
cost_center(doc, cdt, cdn) {
|
||||
erpnext.utils.copy_value_in_all_rows(doc, cdt, cdn, "items", "cost_center");
|
||||
}
|
||||
|
||||
due_date(doc, cdt, cdn) {
|
||||
|
||||
@@ -1408,7 +1408,6 @@ def make_purchase_order_for_default_supplier(source_name, selected_items=None, t
|
||||
{
|
||||
"Sales Order": {
|
||||
"doctype": "Purchase Order",
|
||||
"field_map": {"dispatch_address_name": "dispatch_address"},
|
||||
"field_no_map": [
|
||||
"address_display",
|
||||
"contact_display",
|
||||
@@ -1417,6 +1416,7 @@ def make_purchase_order_for_default_supplier(source_name, selected_items=None, t
|
||||
"contact_person",
|
||||
"taxes_and_charges",
|
||||
"shipping_address",
|
||||
"dispatch_address",
|
||||
],
|
||||
"validation": {"docstatus": ["=", 1]},
|
||||
},
|
||||
@@ -1549,7 +1549,6 @@ def make_purchase_order(source_name, selected_items=None, target_doc=None):
|
||||
{
|
||||
"Sales Order": {
|
||||
"doctype": "Purchase Order",
|
||||
"field_map": {"dispatch_address_name": "dispatch_address"},
|
||||
"field_no_map": [
|
||||
"address_display",
|
||||
"contact_display",
|
||||
@@ -1558,6 +1557,7 @@ def make_purchase_order(source_name, selected_items=None, target_doc=None):
|
||||
"contact_person",
|
||||
"taxes_and_charges",
|
||||
"shipping_address",
|
||||
"dispatch_address",
|
||||
],
|
||||
"validation": {"docstatus": ["=", 1]},
|
||||
},
|
||||
|
||||
@@ -11,7 +11,13 @@ erpnext.setup.EmployeeController = class EmployeeController extends frappe.ui.fo
|
||||
};
|
||||
};
|
||||
this.frm.fields_dict.reports_to.get_query = function (doc, cdt, cdn) {
|
||||
return { query: "erpnext.controllers.queries.employee_query" };
|
||||
return {
|
||||
query: "erpnext.controllers.queries.employee_query",
|
||||
filters: [
|
||||
["status", "=", "Active"],
|
||||
["name", "!=", doc.name],
|
||||
],
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -347,8 +347,9 @@ class TransactionDeletionRecord(Document):
|
||||
self.db_set("error_log", None)
|
||||
|
||||
def get_doctypes_to_be_ignored_list(self):
|
||||
singles = frappe.get_all("DocType", filters={"issingle": 1}, pluck="name")
|
||||
doctypes_to_be_ignored_list = singles
|
||||
doctypes_to_be_ignored_list = frappe.get_all(
|
||||
"DocType", or_filters=[["issingle", "=", 1], ["is_virtual", "=", 1]], pluck="name"
|
||||
)
|
||||
for doctype in self.doctypes_to_be_ignored:
|
||||
doctypes_to_be_ignored_list.append(doctype.doctype_name)
|
||||
|
||||
|
||||
@@ -4,14 +4,13 @@
|
||||
|
||||
import click
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
|
||||
from frappe.desk.page.setup_wizard.setup_wizard import add_all_roles_to
|
||||
from frappe.utils import cint
|
||||
|
||||
import erpnext
|
||||
from erpnext.setup.default_energy_point_rules import get_default_energy_point_rules
|
||||
from erpnext.setup.doctype.incoterm.incoterm import create_incoterms
|
||||
from erpnext.setup.utils import identity as _
|
||||
|
||||
from .default_success_action import get_default_success_action
|
||||
|
||||
@@ -193,28 +192,27 @@ def add_company_to_session_defaults():
|
||||
|
||||
def add_standard_navbar_items():
|
||||
navbar_settings = frappe.get_single("Navbar Settings")
|
||||
|
||||
erpnext_navbar_items = [
|
||||
{
|
||||
"item_label": "Documentation",
|
||||
"item_label": _("Documentation"),
|
||||
"item_type": "Route",
|
||||
"route": "https://docs.erpnext.com/",
|
||||
"is_standard": 1,
|
||||
},
|
||||
{
|
||||
"item_label": "User Forum",
|
||||
"item_label": _("User Forum"),
|
||||
"item_type": "Route",
|
||||
"route": "https://discuss.frappe.io",
|
||||
"is_standard": 1,
|
||||
},
|
||||
{
|
||||
"item_label": "Frappe School",
|
||||
"item_label": _("Frappe School"),
|
||||
"item_type": "Route",
|
||||
"route": "https://frappe.io/school?utm_source=in_app",
|
||||
"is_standard": 1,
|
||||
},
|
||||
{
|
||||
"item_label": "Report an Issue",
|
||||
"item_label": _("Report an Issue"),
|
||||
"item_type": "Route",
|
||||
"route": "https://github.com/frappe/erpnext/issues",
|
||||
"is_standard": 1,
|
||||
|
||||
@@ -234,3 +234,15 @@ def welcome_email():
|
||||
site_name = get_default_company() or "ERPNext"
|
||||
title = _("Welcome to {0}").format(site_name)
|
||||
return title
|
||||
|
||||
|
||||
def identity(x, *args, **kwargs):
|
||||
"""Used for redefining the translation function to return the string as is.
|
||||
|
||||
We want to create english records but still mark the strings as translatable.
|
||||
E.g. when the respective DocTypes have 'Translate Link Fields' enabled or
|
||||
we're creating custom fields.
|
||||
|
||||
Use like this: `from erpnext.setup.utils import identity as _`
|
||||
"""
|
||||
return x
|
||||
|
||||
@@ -50,7 +50,7 @@ def boot_session(bootinfo):
|
||||
|
||||
bootinfo.docs += frappe.db.sql(
|
||||
"""select name, default_currency, cost_center, default_selling_terms, default_buying_terms,
|
||||
default_letter_head, default_bank_account, enable_perpetual_inventory, country from `tabCompany`""",
|
||||
default_letter_head, default_bank_account, enable_perpetual_inventory, country, exchange_gain_loss_account from `tabCompany`""",
|
||||
as_dict=1,
|
||||
update={"doctype": ":Company"},
|
||||
)
|
||||
|
||||
@@ -130,10 +130,6 @@ frappe.ui.form.on("Delivery Note Item", {
|
||||
var d = locals[dt][dn];
|
||||
frm.update_in_all_rows("items", "expense_account", d.expense_account);
|
||||
},
|
||||
cost_center: function (frm, dt, dn) {
|
||||
var d = locals[dt][dn];
|
||||
frm.update_in_all_rows("items", "cost_center", d.cost_center);
|
||||
},
|
||||
});
|
||||
|
||||
erpnext.stock.DeliveryNoteController = class DeliveryNoteController extends (
|
||||
|
||||
@@ -504,6 +504,61 @@ class TestInventoryDimension(FrappeTestCase):
|
||||
|
||||
self.assertEqual(site_name, "Site 1")
|
||||
|
||||
def test_validate_negative_stock_with_multiple_dimension(self):
|
||||
frappe.db.set_single_value("Stock Settings", "allow_negative_stock", 0)
|
||||
item_code = "Test Negative Multi Inventory Dimension Item"
|
||||
create_item(item_code)
|
||||
|
||||
inv_dimension_1 = create_inventory_dimension(
|
||||
apply_to_all_doctypes=1,
|
||||
dimension_name="Inv Site",
|
||||
reference_document="Inv Site",
|
||||
document_type="Inv Site",
|
||||
validate_negative_stock=1,
|
||||
)
|
||||
inv_dimension_1.db_set("validate_negative_stock", 1)
|
||||
|
||||
inv_dimension_2 = create_inventory_dimension(
|
||||
apply_to_all_doctypes=1,
|
||||
dimension_name="Rack",
|
||||
reference_document="Rack",
|
||||
document_type="Rack",
|
||||
validate_negative_stock=1,
|
||||
)
|
||||
inv_dimension_2.db_set("validate_negative_stock", 1)
|
||||
frappe.local.inventory_dimensions = {}
|
||||
frappe.local.document_wise_inventory_dimensions = {}
|
||||
|
||||
pr_doc = make_purchase_receipt(item_code=item_code, qty=30, do_not_submit=True)
|
||||
pr_doc.items[0].inv_site = "Site 1"
|
||||
pr_doc.items[0].rack = "Rack 1"
|
||||
pr_doc.save()
|
||||
pr_doc.submit()
|
||||
|
||||
pr_doc = make_purchase_receipt(item_code=item_code, qty=15, do_not_submit=True)
|
||||
pr_doc.items[0].inv_site = "Site 1"
|
||||
pr_doc.items[0].rack = "Rack 2"
|
||||
pr_doc.save()
|
||||
pr_doc.submit()
|
||||
|
||||
pr_doc = make_purchase_receipt(item_code=item_code, qty=30, do_not_submit=True)
|
||||
pr_doc.items[0].inv_site = "Site 2"
|
||||
pr_doc.items[0].rack = "Rack 1"
|
||||
pr_doc.save()
|
||||
pr_doc.submit()
|
||||
|
||||
pr_doc = make_purchase_receipt(item_code=item_code, qty=25, do_not_submit=True)
|
||||
pr_doc.items[0].inv_site = "Site 2"
|
||||
pr_doc.items[0].rack = "Rack 2"
|
||||
pr_doc.save()
|
||||
pr_doc.submit()
|
||||
|
||||
dn_doc = create_delivery_note(item_code=item_code, qty=35, do_not_submit=True)
|
||||
dn_doc.items[0].inv_site = "Site 2"
|
||||
dn_doc.items[0].rack = "Rack 1"
|
||||
dn_doc.save()
|
||||
self.assertRaises(InventoryDimensionNegativeStockError, dn_doc.submit)
|
||||
|
||||
|
||||
def get_voucher_sl_entries(voucher_no, fields):
|
||||
return frappe.get_all(
|
||||
@@ -593,7 +648,7 @@ def prepare_test_data():
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
for rack in ["Rack 1"]:
|
||||
for rack in ["Rack 1", "Rack 2"]:
|
||||
if not frappe.db.exists("Rack", rack):
|
||||
frappe.get_doc({"doctype": "Rack", "rack_name": rack}).insert(ignore_permissions=True)
|
||||
|
||||
|
||||
@@ -167,6 +167,7 @@
|
||||
"in_preview": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Item Group",
|
||||
"link_filters": "[[\"Item Group\",\"is_group\",\"=\",0]]",
|
||||
"oldfieldname": "item_group",
|
||||
"oldfieldtype": "Link",
|
||||
"options": "Item Group",
|
||||
@@ -894,7 +895,7 @@
|
||||
"image_field": "image",
|
||||
"links": [],
|
||||
"make_attachments_public": 1,
|
||||
"modified": "2025-12-04 09:11:56.029567",
|
||||
"modified": "2025-12-15 20:08:35.634046",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Item",
|
||||
|
||||
@@ -19,6 +19,7 @@ from erpnext.stock.doctype.material_request.material_request import (
|
||||
make_supplier_quotation,
|
||||
raise_work_orders,
|
||||
)
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry import make_stock_in_entry
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
|
||||
@@ -926,6 +927,49 @@ class TestMaterialRequest(FrappeTestCase):
|
||||
pl_for_pending = create_pick_list(mr.name)
|
||||
self.assertEqual(pl_for_pending.locations[0].qty, 5)
|
||||
|
||||
def test_mr_status_with_partial_and_excess_end_transit(self):
|
||||
material_request = make_material_request(
|
||||
material_request_type="Material Transfer",
|
||||
item_code="_Test Item Home Desktop 100",
|
||||
)
|
||||
|
||||
in_transit_wh = get_in_transit_warehouse(material_request.company)
|
||||
|
||||
# Make sure stock is available in source warehouse
|
||||
self._insert_stock_entry(20.0, 20.0)
|
||||
|
||||
# Stock Entry (Transfer to In-Transit)
|
||||
stock_entry_1 = make_in_transit_stock_entry(material_request.name, in_transit_wh)
|
||||
stock_entry_1.items[0].update(
|
||||
{
|
||||
"qty": 5,
|
||||
"s_warehouse": "_Test Warehouse 1 - _TC",
|
||||
}
|
||||
)
|
||||
stock_entry_1.save().submit()
|
||||
|
||||
stock_entry_2 = make_in_transit_stock_entry(material_request.name, in_transit_wh)
|
||||
stock_entry_2.items[0].update(
|
||||
{
|
||||
"qty": 5,
|
||||
"s_warehouse": "_Test Warehouse 1 - _TC",
|
||||
}
|
||||
)
|
||||
stock_entry_2.save().submit()
|
||||
|
||||
end_transit_1 = make_stock_in_entry(stock_entry_1.name)
|
||||
end_transit_1.save().submit()
|
||||
|
||||
# Material Request Transfer Status should still be In Transit
|
||||
material_request.load_from_db()
|
||||
self.assertEqual(material_request.transfer_status, "In Transit")
|
||||
|
||||
end_transit_2 = make_stock_in_entry(stock_entry_2.name)
|
||||
end_transit_2.items[0].update({"qty": 6}) # More than transferred
|
||||
end_transit_2.save()
|
||||
|
||||
self.assertRaises(frappe.ValidationError, end_transit_2.submit)
|
||||
|
||||
|
||||
def get_in_transit_warehouse(company):
|
||||
if not frappe.db.exists("Warehouse Type", "Transit"):
|
||||
|
||||
@@ -8,7 +8,7 @@ import frappe
|
||||
from frappe import _, throw
|
||||
from frappe.desk.notifications import clear_doctype_notifications
|
||||
from frappe.model.mapper import get_mapped_doc
|
||||
from frappe.query_builder.functions import CombineDatetime
|
||||
from frappe.query_builder.functions import Abs, CombineDatetime, Sum
|
||||
from frappe.utils import cint, flt, get_datetime, getdate, nowdate
|
||||
from pypika import functions as fn
|
||||
|
||||
@@ -1372,21 +1372,26 @@ def get_invoiced_qty_map(purchase_receipt):
|
||||
|
||||
|
||||
def get_returned_qty_map(purchase_receipt):
|
||||
"""returns a map: {so_detail: returned_qty}"""
|
||||
returned_qty_map = frappe._dict(
|
||||
frappe.db.sql(
|
||||
"""select pr_item.purchase_receipt_item, abs(pr_item.qty) as qty
|
||||
from `tabPurchase Receipt Item` pr_item, `tabPurchase Receipt` pr
|
||||
where pr.name = pr_item.parent
|
||||
and pr.docstatus = 1
|
||||
and pr.is_return = 1
|
||||
and pr.return_against = %s
|
||||
""",
|
||||
purchase_receipt,
|
||||
)
|
||||
)
|
||||
"""returns a map: {pr_detail: returned_qty}"""
|
||||
|
||||
return returned_qty_map
|
||||
pr = frappe.qb.DocType("Purchase Receipt")
|
||||
pr_item = frappe.qb.DocType("Purchase Receipt Item")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(pr)
|
||||
.inner_join(pr_item)
|
||||
.on(pr.name == pr_item.parent)
|
||||
.select(pr_item.purchase_receipt_item, Sum(Abs(pr_item.qty)).as_("qty"))
|
||||
.where(
|
||||
(pr.docstatus == 1)
|
||||
& (pr.is_return == 1)
|
||||
& (pr.return_against == purchase_receipt)
|
||||
& (pr_item.purchase_receipt_item.isnotnull())
|
||||
)
|
||||
.groupby(pr_item.purchase_receipt_item)
|
||||
).run(as_list=1)
|
||||
|
||||
return frappe._dict(query) if query else frappe._dict()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
@@ -2008,6 +2008,8 @@ def get_available_serial_nos(kwargs):
|
||||
filters["name"] = ("in", time_based_serial_nos)
|
||||
elif ignore_serial_nos:
|
||||
filters["name"] = ("not in", ignore_serial_nos)
|
||||
elif kwargs.get("serial_nos"):
|
||||
filters["name"] = ("in", kwargs.get("serial_nos"))
|
||||
|
||||
if kwargs.get("batches"):
|
||||
batches = get_non_expired_batches(kwargs.get("batches"))
|
||||
|
||||
@@ -8,6 +8,7 @@ from collections import defaultdict
|
||||
import frappe
|
||||
from frappe import _, bold
|
||||
from frappe.model.mapper import get_mapped_doc
|
||||
from frappe.query_builder import DocType
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import (
|
||||
cint,
|
||||
@@ -1905,7 +1906,7 @@ class StockEntry(StockController):
|
||||
},
|
||||
)
|
||||
|
||||
def get_items_for_disassembly(self, disassemble_qty, production_item):
|
||||
def get_items_for_disassembly(self):
|
||||
"""Get items for Disassembly Order"""
|
||||
|
||||
if not self.work_order:
|
||||
@@ -1918,7 +1919,7 @@ class StockEntry(StockController):
|
||||
items_dict = get_bom_items_as_dict(
|
||||
self.bom_no,
|
||||
self.company,
|
||||
disassemble_qty,
|
||||
self.fg_completed_qty,
|
||||
fetch_exploded=self.use_multi_level_bom,
|
||||
fetch_qty_in_stock_uom=False,
|
||||
)
|
||||
@@ -1935,8 +1936,8 @@ class StockEntry(StockController):
|
||||
child_row.qty = bom_items.get("qty", child_row.qty)
|
||||
child_row.amount = bom_items.get("amount", child_row.amount)
|
||||
|
||||
if row.item_code == production_item:
|
||||
child_row.qty = disassemble_qty
|
||||
if row.is_finished_item:
|
||||
child_row.qty = self.fg_completed_qty
|
||||
|
||||
child_row.s_warehouse = (self.from_warehouse or s_warehouse) if row.is_finished_item else ""
|
||||
child_row.t_warehouse = row.s_warehouse
|
||||
@@ -1972,12 +1973,12 @@ class StockEntry(StockController):
|
||||
)
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_items(self, qty=None, production_item=None):
|
||||
def get_items(self):
|
||||
self.set("items", [])
|
||||
self.validate_work_order()
|
||||
|
||||
if self.purpose == "Disassemble" and qty is not None:
|
||||
return self.get_items_for_disassembly(qty, production_item)
|
||||
if self.purpose == "Disassemble":
|
||||
return self.get_items_for_disassembly()
|
||||
|
||||
if not self.posting_date or not self.posting_time:
|
||||
frappe.throw(_("Posting date and posting time is mandatory"))
|
||||
@@ -2815,6 +2816,17 @@ class StockEntry(StockController):
|
||||
},
|
||||
)
|
||||
|
||||
if d.docstatus == 1:
|
||||
transfer_qty = frappe.get_value("Stock Entry Detail", d.ste_detail, "transfer_qty")
|
||||
|
||||
if transferred_qty and transferred_qty[0]:
|
||||
if transferred_qty[0].qty > transfer_qty:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Row {0}: Transferred quantity cannot be greater than the requested quantity."
|
||||
).format(d.idx)
|
||||
)
|
||||
|
||||
stock_entries[(d.against_stock_entry, d.ste_detail)] = (
|
||||
transferred_qty[0].qty if transferred_qty and transferred_qty[0] else 0.0
|
||||
) or 0.0
|
||||
@@ -2878,7 +2890,7 @@ class StockEntry(StockController):
|
||||
parent_se = frappe.get_value("Stock Entry", self.outgoing_stock_entry, "add_to_transit")
|
||||
|
||||
for item in self.items:
|
||||
material_request = item.material_request or None
|
||||
material_request = item.get("material_request")
|
||||
if self.purpose == "Material Transfer" and material_request not in material_requests:
|
||||
if self.outgoing_stock_entry and parent_se:
|
||||
material_request = frappe.get_value(
|
||||
@@ -2887,6 +2899,11 @@ class StockEntry(StockController):
|
||||
|
||||
if material_request and material_request not in material_requests:
|
||||
material_requests.append(material_request)
|
||||
if status == "Completed":
|
||||
qty = get_transferred_qty(material_request)
|
||||
if qty.get("transfer_qty") > qty.get("transferred_qty"):
|
||||
status = "In Transit"
|
||||
|
||||
frappe.db.set_value("Material Request", material_request, "transfer_status", status)
|
||||
|
||||
def set_serial_no_batch_for_finished_good(self):
|
||||
@@ -3545,3 +3562,18 @@ def get_batchwise_serial_nos(item_code, row):
|
||||
batchwise_serial_nos[batch_no] = sorted([serial_no.name for serial_no in serial_nos])
|
||||
|
||||
return batchwise_serial_nos
|
||||
|
||||
|
||||
def get_transferred_qty(material_request):
|
||||
sed = DocType("Stock Entry Detail")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(sed)
|
||||
.select(
|
||||
Sum(sed.transfer_qty).as_("transfer_qty"),
|
||||
Sum(sed.transferred_qty).as_("transferred_qty"),
|
||||
)
|
||||
.where((sed.material_request == material_request) & (sed.docstatus == 1))
|
||||
).run(as_dict=True)
|
||||
|
||||
return query[0]
|
||||
|
||||
@@ -113,17 +113,15 @@ class StockLedgerEntry(Document):
|
||||
return
|
||||
|
||||
flt_precision = cint(frappe.db.get_default("float_precision")) or 2
|
||||
for dimension, values in dimensions.items():
|
||||
dimension_value = values.get("value")
|
||||
available_qty = self.get_available_qty_after_prev_transaction(dimension, dimension_value)
|
||||
available_qty = self.get_available_qty_after_prev_transaction(dimensions)
|
||||
|
||||
diff = flt(available_qty + flt(self.actual_qty), flt_precision) # qty after current transaction
|
||||
if diff < 0 and abs(diff) > 0.0001:
|
||||
self.throw_validation_error(diff, dimension, dimension_value)
|
||||
diff = flt(available_qty + flt(self.actual_qty), flt_precision) # qty after current transaction
|
||||
if diff < 0 and abs(diff) > 0.0001:
|
||||
self.throw_validation_error(diff, dimensions)
|
||||
|
||||
def get_available_qty_after_prev_transaction(self, dimension, dimension_value):
|
||||
def get_available_qty_after_prev_transaction(self, dimensions):
|
||||
sle = frappe.qb.DocType("Stock Ledger Entry")
|
||||
available_qty = (
|
||||
available_qty_query = (
|
||||
frappe.qb.from_(sle)
|
||||
.select(Sum(sle.actual_qty))
|
||||
.where(
|
||||
@@ -132,21 +130,27 @@ class StockLedgerEntry(Document):
|
||||
& (sle.posting_datetime < self.posting_datetime)
|
||||
& (sle.company == self.company)
|
||||
& (sle.is_cancelled == 0)
|
||||
& (sle[dimension] == dimension_value)
|
||||
)
|
||||
).run()
|
||||
)
|
||||
|
||||
for dimension, values in dimensions.items():
|
||||
dimension_value = values.get("value")
|
||||
available_qty_query = available_qty_query.where(sle[dimension] == dimension_value)
|
||||
|
||||
available_qty = available_qty_query.run()
|
||||
|
||||
return available_qty[0][0] or 0
|
||||
|
||||
def throw_validation_error(self, diff, dimension, dimension_value):
|
||||
def throw_validation_error(self, diff, dimensions):
|
||||
msg = _(
|
||||
"{0} units of {1} are required in {2} with the inventory dimension: {3} ({4}) on {5} {6} for {7} to complete the transaction."
|
||||
"{0} units of {1} are required in {2} with the inventory dimension: {3} on {4} {5} for {6} to complete the transaction."
|
||||
).format(
|
||||
abs(diff),
|
||||
frappe.get_desk_link("Item", self.item_code),
|
||||
frappe.get_desk_link("Warehouse", self.warehouse),
|
||||
frappe.bold(dimension),
|
||||
frappe.bold(dimension_value),
|
||||
frappe.bold(
|
||||
", ".join([f"{dimension}: {values.get('value')}" for dimension, values in dimensions.items()])
|
||||
),
|
||||
self.posting_date,
|
||||
self.posting_time,
|
||||
frappe.get_desk_link(self.voucher_type, self.voucher_no),
|
||||
|
||||
@@ -20,6 +20,9 @@ def execute(filters=None):
|
||||
|
||||
|
||||
def get_chart_data(data, filters):
|
||||
def wrap_in_quotes(label):
|
||||
return f"'{label}'"
|
||||
|
||||
if not data:
|
||||
return []
|
||||
|
||||
@@ -36,6 +39,9 @@ def get_chart_data(data, filters):
|
||||
data = data[:10]
|
||||
|
||||
for row in data:
|
||||
if row[0] == wrap_in_quotes(_("Total")):
|
||||
continue
|
||||
|
||||
labels.append(row[0])
|
||||
datapoints.append(row[-1])
|
||||
|
||||
|
||||
@@ -273,6 +273,7 @@ class FIFOSlots:
|
||||
else:
|
||||
serial_nos = get_serial_nos_from_bundle(d.serial_and_batch_bundle) or []
|
||||
|
||||
serial_nos = self.uppercase_serial_nos(serial_nos)
|
||||
if d.actual_qty > 0:
|
||||
self.__compute_incoming_stock(d, fifo_queue, transferred_item_key, serial_nos)
|
||||
else:
|
||||
@@ -289,6 +290,10 @@ class FIFOSlots:
|
||||
|
||||
return self.item_details
|
||||
|
||||
def uppercase_serial_nos(self, serial_nos):
|
||||
"Convert serial nos to uppercase for uniformity."
|
||||
return [sn.upper() for sn in serial_nos]
|
||||
|
||||
def __init_key_stores(self, row: dict) -> tuple:
|
||||
"Initialise keys and FIFO Queue."
|
||||
|
||||
|
||||
@@ -543,6 +543,12 @@ class SubcontractingReceipt(SubcontractingController):
|
||||
|
||||
for row in self.items:
|
||||
precision = row.precision("qty")
|
||||
|
||||
# if allow alternative item, ignore the validation as per BOM required qty
|
||||
is_allow_alternative_item = frappe.db.get_value("BOM", row.bom, "allow_alternative_item")
|
||||
if is_allow_alternative_item:
|
||||
continue
|
||||
|
||||
for bom_item in self._get_materials_from_bom(
|
||||
row.item_code, row.bom, row.get("include_exploded_items")
|
||||
):
|
||||
|
||||
@@ -199,7 +199,7 @@ def get_issue_list(doctype, txt, filters, limit_start, limit_page_length=20, ord
|
||||
customer = contact_doc.get_link_for("Customer")
|
||||
|
||||
ignore_permissions = False
|
||||
if is_website_user():
|
||||
if is_website_user() and user != "Guest":
|
||||
if not filters:
|
||||
filters = {}
|
||||
|
||||
|
||||
@@ -2,14 +2,6 @@
|
||||
// License: GNU General Public License v3. See license.txt
|
||||
|
||||
frappe.ui.form.on("Rename Tool", {
|
||||
onload: function (frm) {
|
||||
return frappe.call({
|
||||
method: "erpnext.utilities.doctype.rename_tool.rename_tool.get_doctypes",
|
||||
callback: function (r) {
|
||||
frm.set_df_property("select_doctype", "options", r.message);
|
||||
},
|
||||
});
|
||||
},
|
||||
refresh: function (frm) {
|
||||
frm.disable_save();
|
||||
|
||||
|
||||
@@ -8,27 +8,13 @@
|
||||
"doctype": "DocType",
|
||||
"fields": [
|
||||
{
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"description": "Type of document to rename.",
|
||||
"fieldname": "select_doctype",
|
||||
"fieldtype": "Select",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"in_filter": 0,
|
||||
"in_list_view": 0,
|
||||
"label": "Select DocType",
|
||||
"no_copy": 0,
|
||||
"permlevel": 0,
|
||||
"print_hide": 0,
|
||||
"read_only": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 0,
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"unique": 0
|
||||
},
|
||||
"description": "Type of document to rename.",
|
||||
"fieldname": "select_doctype",
|
||||
"fieldtype": "Link",
|
||||
"label": "Select DocType",
|
||||
"link_filters": "[[\"DocType\",\"allow_rename\",\"=\",1],[\"DocType\",\"module\",\"!=\",\"Core\"]]",
|
||||
"options": "DocType"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
@@ -72,22 +58,18 @@
|
||||
"set_only_once": 0,
|
||||
"unique": 0
|
||||
}
|
||||
],
|
||||
"hide_heading": 0,
|
||||
"hide_toolbar": 1,
|
||||
"icon": "fa fa-magic",
|
||||
"idx": 1,
|
||||
"in_create": 0,
|
||||
|
||||
"is_submittable": 0,
|
||||
"issingle": 1,
|
||||
"istable": 0,
|
||||
"max_attachments": 1,
|
||||
"modified": "2015-10-19 03:04:49.097140",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Utilities",
|
||||
"name": "Rename Tool",
|
||||
"owner": "Administrator",
|
||||
],
|
||||
"hide_toolbar": 1,
|
||||
"icon": "fa fa-magic",
|
||||
"idx": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"max_attachments": 1,
|
||||
"modified": "2025-12-09 14:18:33.838623",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Utilities",
|
||||
"name": "Rename Tool",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"amend": 0,
|
||||
@@ -112,4 +94,4 @@
|
||||
],
|
||||
"read_only": 0,
|
||||
"read_only_onload": 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import frappe
|
||||
from frappe.model.document import Document
|
||||
from frappe.model.rename_doc import bulk_rename
|
||||
from frappe.utils.deprecations import deprecated
|
||||
|
||||
|
||||
class RenameTool(Document):
|
||||
@@ -19,13 +20,14 @@ class RenameTool(Document):
|
||||
from frappe.types import DF
|
||||
|
||||
file_to_rename: DF.Attach | None
|
||||
select_doctype: DF.Literal
|
||||
select_doctype: DF.Link | None
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@deprecated
|
||||
def get_doctypes():
|
||||
return frappe.db.sql_list(
|
||||
"""select name from tabDocType
|
||||
|
||||
Reference in New Issue
Block a user