Merge branch 'develop' into st31369

This commit is contained in:
Mihir Kandoi
2025-03-05 12:13:13 +05:30
committed by GitHub
78 changed files with 10476 additions and 9275 deletions

View File

@@ -11,7 +11,6 @@ pull_request_rules:
- author!=deepeshgarg007 - author!=deepeshgarg007
- author!=frappe-pr-bot - author!=frappe-pr-bot
- author!=mergify[bot] - author!=mergify[bot]
- or: - or:
- base=version-13 - base=version-13
- base=version-12 - base=version-12
@@ -24,7 +23,6 @@ pull_request_rules:
message: | message: |
@{{author}}, thanks for the contribution, but we do not accept pull requests on a stable branch. Please raise PR on an appropriate hotfix branch. @{{author}}, thanks for the contribution, but we do not accept pull requests on a stable branch. Please raise PR on an appropriate hotfix branch.
https://github.com/frappe/erpnext/wiki/Pull-Request-Checklist#which-branch https://github.com/frappe/erpnext/wiki/Pull-Request-Checklist#which-branch
- name: backport to develop - name: backport to develop
conditions: conditions:
- label="backport develop" - label="backport develop"
@@ -34,7 +32,6 @@ pull_request_rules:
- develop - develop
assignees: assignees:
- "{{ author }}" - "{{ author }}"
- name: backport to version-14-hotfix - name: backport to version-14-hotfix
conditions: conditions:
- label="backport version-14-hotfix" - label="backport version-14-hotfix"
@@ -44,7 +41,6 @@ pull_request_rules:
- version-14-hotfix - version-14-hotfix
assignees: assignees:
- "{{ author }}" - "{{ author }}"
- name: backport to version-15-hotfix - name: backport to version-15-hotfix
conditions: conditions:
- label="backport version-15-hotfix" - label="backport version-15-hotfix"
@@ -54,18 +50,6 @@ pull_request_rules:
- version-15-hotfix - version-15-hotfix
assignees: assignees:
- "{{ author }}" - "{{ author }}"
- name: backport to version-13-hotfix
conditions:
- label="backport version-13-hotfix"
actions:
backport:
branches:
- version-13-hotfix
assignees:
- "{{ author }}"
- name: Automatic merge on CI success and review - name: Automatic merge on CI success and review
conditions: conditions:
- status-success=linters - status-success=linters

View File

@@ -1,5 +1,5 @@
<div align="center"> <div align="center">
<a href="https://erpnext.com"> <a href="https://frappe.io/erpnext">
<img src="./erpnext/public/images/v16/erpnext.svg" alt="ERPNext Logo" height="80px" width="80xp"/> <img src="./erpnext/public/images/v16/erpnext.svg" alt="ERPNext Logo" height="80px" width="80xp"/>
</a> </a>
<h2>ERPNext</h2> <h2>ERPNext</h2>
@@ -19,9 +19,9 @@
<div align="center"> <div align="center">
<a href="https://erpnext-demo.frappe.cloud/app/home">Live Demo</a> <a href="https://erpnext-demo.frappe.cloud/app/home">Live Demo</a>
- -
<a href="https://erpnext.com">Website</a> <a href="https://frappe.io/erpnext">Website</a>
- -
<a href="https://docs.erpnext.com">Documentation</a> <a href="https://docs.frappe.io/erpnext/">Documentation</a>
</div> </div>
## ERPNext ## ERPNext

View File

@@ -4,7 +4,7 @@
import frappe import frappe
from frappe import _, throw from frappe import _, throw
from frappe.utils import cint, cstr from frappe.utils import add_to_date, cint, cstr, pretty_date
from frappe.utils.nestedset import NestedSet, get_ancestors_of, get_descendants_of from frappe.utils.nestedset import NestedSet, get_ancestors_of, get_descendants_of
import erpnext import erpnext
@@ -479,6 +479,7 @@ def get_account_autoname(account_number, account_name, company):
@frappe.whitelist() @frappe.whitelist()
def update_account_number(name, account_name, account_number=None, from_descendant=False): def update_account_number(name, account_name, account_number=None, from_descendant=False):
_ensure_idle_system()
account = frappe.get_cached_doc("Account", name) account = frappe.get_cached_doc("Account", name)
if not account: if not account:
return return
@@ -540,6 +541,7 @@ def update_account_number(name, account_name, account_number=None, from_descenda
@frappe.whitelist() @frappe.whitelist()
def merge_account(old, new): def merge_account(old, new):
_ensure_idle_system()
# Validate properties before merging # Validate properties before merging
new_account = frappe.get_cached_doc("Account", new) new_account = frappe.get_cached_doc("Account", new)
old_account = frappe.get_cached_doc("Account", old) old_account = frappe.get_cached_doc("Account", old)
@@ -593,3 +595,27 @@ def sync_update_account_number_in_child(
for d in frappe.db.get_values("Account", filters=filters, fieldname=["company", "name"], as_dict=True): for d in frappe.db.get_values("Account", filters=filters, fieldname=["company", "name"], as_dict=True):
update_account_number(d["name"], account_name, account_number, from_descendant=True) update_account_number(d["name"], account_name, account_number, from_descendant=True)
def _ensure_idle_system():
# Don't allow renaming if accounting entries are actively being updated, there are two main reasons:
# 1. Correctness: It's next to impossible to ensure that renamed account is not being used *right now*.
# 2. Performance: Renaming requires locking out many tables entirely and severely degrades performance.
if frappe.flags.in_test:
return
try:
# We also lock inserts to GL entry table with for_update here.
last_gl_update = frappe.db.get_value("GL Entry", {}, "modified", for_update=True, wait=False)
except frappe.QueryTimeoutError:
# wait=False fails immediately if there's an active transaction.
last_gl_update = add_to_date(None, seconds=-1)
if last_gl_update > add_to_date(None, minutes=-5):
frappe.throw(
_(
"Last GL Entry update was done {}. This operation is not allowed while system is actively being used. Please wait for 5 minutes before retrying."
).format(pretty_date(last_gl_update)),
title=_("System In Use"),
)

View File

@@ -258,6 +258,10 @@ frappe.ui.form.on("Payment Entry", {
frappe.flags.allocate_payment_amount = true; frappe.flags.allocate_payment_amount = true;
}, },
validate: async function (frm) {
await frm.events.set_exchange_gain_loss_deduction(frm);
},
validate_company: (frm) => { validate_company: (frm) => {
if (!frm.doc.company) { if (!frm.doc.company) {
frappe.throw({ message: __("Please select a Company first."), title: __("Mandatory") }); frappe.throw({ message: __("Please select a Company first."), title: __("Mandatory") });
@@ -1837,8 +1841,6 @@ function prompt_for_missing_account(frm, account) {
(values) => resolve(values?.[account]), (values) => resolve(values?.[account]),
__("Please Specify Account") __("Please Specify Account")
); );
dialog.on_hide = () => resolve("");
}); });
} }

View File

@@ -1942,7 +1942,7 @@ class PaymentEntry(AccountsController):
allocated_positive_outstanding = paid_amount + allocated_negative_outstanding allocated_positive_outstanding = paid_amount + allocated_negative_outstanding
elif self.party_type in ("Supplier", "Employee"): elif self.party_type in ("Supplier", "Customer"):
if paid_amount > total_negative_outstanding: if paid_amount > total_negative_outstanding:
if total_negative_outstanding == 0: if total_negative_outstanding == 0:
frappe.msgprint( frappe.msgprint(
@@ -3437,13 +3437,14 @@ def add_income_discount_loss(pe, doc, total_discount_percent) -> float:
"""Add loss on income discount in base currency.""" """Add loss on income discount in base currency."""
precision = doc.precision("total") precision = doc.precision("total")
base_loss_on_income = doc.get("base_total") * (total_discount_percent / 100) base_loss_on_income = doc.get("base_total") * (total_discount_percent / 100)
positive_negative = -1 if pe.payment_type == "Pay" else 1
pe.append( pe.append(
"deductions", "deductions",
{ {
"account": frappe.get_cached_value("Company", pe.company, "default_discount_account"), "account": frappe.get_cached_value("Company", pe.company, "default_discount_account"),
"cost_center": pe.cost_center or frappe.get_cached_value("Company", pe.company, "cost_center"), "cost_center": pe.cost_center or frappe.get_cached_value("Company", pe.company, "cost_center"),
"amount": flt(base_loss_on_income, precision), "amount": flt(base_loss_on_income, precision) * positive_negative,
}, },
) )
@@ -3455,6 +3456,7 @@ def add_tax_discount_loss(pe, doc, total_discount_percentage) -> float:
tax_discount_loss = {} tax_discount_loss = {}
base_total_tax_loss = 0 base_total_tax_loss = 0
precision = doc.precision("tax_amount_after_discount_amount", "taxes") precision = doc.precision("tax_amount_after_discount_amount", "taxes")
positive_negative = -1 if pe.payment_type == "Pay" else 1
# The same account head could be used more than once # The same account head could be used more than once
for tax in doc.get("taxes", []): for tax in doc.get("taxes", []):
@@ -3477,7 +3479,7 @@ def add_tax_discount_loss(pe, doc, total_discount_percentage) -> float:
"account": account, "account": account,
"cost_center": pe.cost_center "cost_center": pe.cost_center
or frappe.get_cached_value("Company", pe.company, "cost_center"), or frappe.get_cached_value("Company", pe.company, "cost_center"),
"amount": flt(loss, precision), "amount": flt(loss, precision) * positive_negative,
}, },
) )

View File

@@ -291,6 +291,48 @@ class TestPaymentEntry(IntegrationTestCase):
self.assertEqual(si.payment_schedule[0].paid_amount, 200.0) self.assertEqual(si.payment_schedule[0].paid_amount, 200.0)
self.assertEqual(si.payment_schedule[1].paid_amount, 36.0) self.assertEqual(si.payment_schedule[1].paid_amount, 36.0)
def test_payment_entry_against_payment_terms_with_discount_on_pi(self):
pi = make_purchase_invoice(do_not_save=1)
create_payment_terms_template_with_discount()
pi.payment_terms_template = "Test Discount Template"
frappe.db.set_value("Company", pi.company, "default_discount_account", "Write Off - _TC")
pi.append(
"taxes",
{
"charge_type": "On Net Total",
"account_head": "_Test Account Service Tax - _TC",
"cost_center": "_Test Cost Center - _TC",
"description": "Service Tax",
"rate": 18,
},
)
pi.save()
pi.submit()
frappe.db.set_single_value("Accounts Settings", "book_tax_discount_loss", 1)
pe_with_tax_loss = get_payment_entry("Purchase Invoice", pi.name, bank_account="_Test Cash - _TC")
self.assertEqual(pe_with_tax_loss.references[0].payment_term, "30 Credit Days with 10% Discount")
self.assertEqual(pe_with_tax_loss.payment_type, "Pay")
self.assertEqual(pe_with_tax_loss.references[0].allocated_amount, 295.0)
self.assertEqual(pe_with_tax_loss.paid_amount, 265.5)
self.assertEqual(pe_with_tax_loss.difference_amount, 0)
self.assertEqual(pe_with_tax_loss.deductions[0].amount, -25.0) # Loss on Income
self.assertEqual(pe_with_tax_loss.deductions[1].amount, -4.5) # Loss on Tax
self.assertEqual(pe_with_tax_loss.deductions[1].account, "_Test Account Service Tax - _TC")
frappe.db.set_single_value("Accounts Settings", "book_tax_discount_loss", 0)
pe = get_payment_entry("Purchase Invoice", pi.name, bank_account="_Test Cash - _TC")
self.assertEqual(pe.references[0].payment_term, "30 Credit Days with 10% Discount")
self.assertEqual(pe.payment_type, "Pay")
self.assertEqual(pe.references[0].allocated_amount, 295.0)
self.assertEqual(pe.paid_amount, 265.5)
self.assertEqual(pe.deductions[0].amount, -29.5)
self.assertEqual(pe.difference_amount, 0)
def test_payment_entry_against_payment_terms_with_discount(self): def test_payment_entry_against_payment_terms_with_discount(self):
si = create_sales_invoice(do_not_save=1, qty=1, rate=200) si = create_sales_invoice(do_not_save=1, qty=1, rate=200)
create_payment_terms_template_with_discount() create_payment_terms_template_with_discount()

View File

@@ -1625,6 +1625,5 @@
"states": [], "states": [],
"timeline_field": "customer", "timeline_field": "customer",
"title_field": "customer_name", "title_field": "customer_name",
"track_changes": 1, "track_changes": 1
"track_seen": 1
} }

View File

@@ -895,11 +895,19 @@ frappe.ui.form.on("Sales Invoice", {
project: function (frm) { project: function (frm) {
if (frm.doc.project) { if (frm.doc.project) {
frappe.call({
method: "is_auto_fetch_timesheet_enabled",
doc: frm.doc,
callback: function (r) {
if (cint(r.message)) {
frm.events.add_timesheet_data(frm, { frm.events.add_timesheet_data(frm, {
project: frm.doc.project, project: frm.doc.project,
}); });
} }
}, },
});
}
},
async add_timesheet_data(frm, kwargs) { async add_timesheet_data(frm, kwargs) {
if (kwargs === "Sales Invoice") { if (kwargs === "Sales Invoice") {

View File

@@ -1096,16 +1096,17 @@ class SalesInvoice(SellingController):
timesheet.billing_amount = ts_doc.total_billable_amount timesheet.billing_amount = ts_doc.total_billable_amount
def update_timesheet_billing_for_project(self): def update_timesheet_billing_for_project(self):
if self.timesheets: if not self.timesheets and self.project and self.is_auto_fetch_timesheet_enabled():
self.add_timesheet_data()
else:
self.calculate_billing_amount_for_timesheet() self.calculate_billing_amount_for_timesheet()
@frappe.whitelist(methods=["PUT"]) @frappe.whitelist()
def add_timesheet_data(self): def is_auto_fetch_timesheet_enabled(self):
if not self.timesheets and self.project: return frappe.db.get_single_value("Projects Settings", "fetch_timesheet_in_sales_invoice")
self._add_timesheet_data()
self.save()
def _add_timesheet_data(self): @frappe.whitelist()
def add_timesheet_data(self):
self.set("timesheets", []) self.set("timesheets", [])
if self.project: if self.project:
for data in get_projectwise_timesheet_data(self.project): for data in get_projectwise_timesheet_data(self.project):

View File

@@ -4305,6 +4305,31 @@ class TestSalesInvoice(IntegrationTestCase):
doc = frappe.get_doc("Project", project.name) doc = frappe.get_doc("Project", project.name)
self.assertEqual(doc.total_billed_amount, si.grand_total) self.assertEqual(doc.total_billed_amount, si.grand_total)
def test_total_billed_amount_with_different_projects(self):
# This test case is for checking the scenario where project is set at document level and for **some** child items only, not all
from copy import copy
si = create_sales_invoice(do_not_submit=True)
project = frappe.new_doc("Project")
project.company = "_Test Company"
project.project_name = "Test Total Billed Amount"
project.save()
si.project = project.name
si.items.append(copy(si.items[0]))
si.items.append(copy(si.items[0]))
si.items[0].project = project.name
si.items[1].project = project.name
# Not setting project on last item
si.items[1].insert()
si.items[2].insert()
si.submit()
project.reload()
self.assertIsNone(si.items[2].project)
self.assertEqual(project.total_billed_amount, 300)
def test_pos_returns_with_party_account_currency(self): def test_pos_returns_with_party_account_currency(self):
from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return

View File

@@ -13,17 +13,15 @@
"fields": [ "fields": [
{ {
"fieldname": "voucher_type", "fieldname": "voucher_type",
"fieldtype": "Link", "fieldtype": "Data",
"in_list_view": 1, "in_list_view": 1,
"label": "Voucher Type", "label": "Voucher Type"
"options": "DocType"
}, },
{ {
"fieldname": "voucher_name", "fieldname": "voucher_name",
"fieldtype": "Dynamic Link", "fieldtype": "Data",
"in_list_view": 1, "in_list_view": 1,
"label": "Voucher Name", "label": "Voucher Name"
"options": "voucher_type"
}, },
{ {
"fieldname": "taxable_amount", "fieldname": "taxable_amount",
@@ -36,7 +34,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2024-03-27 13:10:52.307012", "modified": "2025-02-05 16:39:14.863698",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Tax Withheld Vouchers", "name": "Tax Withheld Vouchers",

View File

@@ -18,8 +18,8 @@ class TaxWithheldVouchers(Document):
parentfield: DF.Data parentfield: DF.Data
parenttype: DF.Data parenttype: DF.Data
taxable_amount: DF.Currency taxable_amount: DF.Currency
voucher_name: DF.DynamicLink | None voucher_name: DF.Data | None
voucher_type: DF.Link | None voucher_type: DF.Data | None
# end: auto-generated types # end: auto-generated types
pass pass

View File

@@ -436,6 +436,7 @@ def get_invoice_vouchers(parties, tax_details, company, party_type="Supplier"):
tax_details.get("tax_withholding_category"), tax_details.get("tax_withholding_category"),
company, company,
), ),
as_dict=1,
) )
for d in journal_entries_details: for d in journal_entries_details:

View File

@@ -38,6 +38,23 @@ class TestAccountsPayable(AccountsTestMixin, IntegrationTestCase):
self.assertEqual(data[1][0].get("outstanding"), 300) self.assertEqual(data[1][0].get("outstanding"), 300)
self.assertEqual(data[1][0].get("currency"), "USD") self.assertEqual(data[1][0].get("currency"), "USD")
def test_account_payable_for_debit_note(self):
pi = self.create_purchase_invoice(do_not_submit=True)
pi.is_return = 1
pi.items[0].qty = -1
pi = pi.save().submit()
filters = {
"company": self.company,
"party_type": "Supplier",
"party": [self.supplier],
"report_date": today(),
"range": "30, 60, 90, 120",
}
data = execute(filters)
self.assertEqual(data[1][0].get("invoiced"), 300)
def create_purchase_invoice(self, do_not_submit=False): def create_purchase_invoice(self, do_not_submit=False):
frappe.set_user("Administrator") frappe.set_user("Administrator")
pi = make_purchase_invoice( pi = make_purchase_invoice(

View File

@@ -267,6 +267,18 @@ class ReceivablePayableReport:
row.invoiced_in_account_currency += amount_in_account_currency row.invoiced_in_account_currency += amount_in_account_currency
else: else:
if self.is_invoice(ple): if self.is_invoice(ple):
# when invoice has is_return marked
if self.invoice_details.get(row.voucher_no, {}).get("is_return"):
# for Credit Note
if row.voucher_type == "Sales Invoice":
row.credit_note -= amount
row.credit_note_in_account_currency -= amount_in_account_currency
# for Debit Note
else:
row.invoiced -= amount
row.invoiced_in_account_currency -= amount_in_account_currency
return
if row.voucher_no == ple.voucher_no == ple.against_voucher_no: if row.voucher_no == ple.voucher_no == ple.against_voucher_no:
row.paid -= amount row.paid -= amount
row.paid_in_account_currency -= amount_in_account_currency row.paid_in_account_currency -= amount_in_account_currency
@@ -421,7 +433,7 @@ class ReceivablePayableReport:
# nosemgrep # nosemgrep
si_list = frappe.db.sql( si_list = frappe.db.sql(
""" """
select name, due_date, po_no select name, due_date, po_no, is_return
from `tabSales Invoice` from `tabSales Invoice`
where posting_date <= %s where posting_date <= %s
and company = %s and company = %s
@@ -453,7 +465,7 @@ class ReceivablePayableReport:
# nosemgrep # nosemgrep
for pi in frappe.db.sql( for pi in frappe.db.sql(
""" """
select name, due_date, bill_no, bill_date select name, due_date, bill_no, bill_date, is_return
from `tabPurchase Invoice` from `tabPurchase Invoice`
where where
posting_date <= %s posting_date <= %s

View File

@@ -204,7 +204,7 @@ class TestAccountsReceivable(AccountsTestMixin, IntegrationTestCase):
expected_data_after_credit_note = [ expected_data_after_credit_note = [
[100.0, 100.0, 40.0, 0.0, 60.0, si.name], [100.0, 100.0, 40.0, 0.0, 60.0, si.name],
[0, 0, 100.0, 0.0, -100.0, cr_note.name], [0, 0, 0, 100.0, -100.0, cr_note.name],
] ]
self.assertEqual(len(report[1]), 2) self.assertEqual(len(report[1]), 2)
si_row = next( si_row = next(
@@ -478,13 +478,19 @@ class TestAccountsReceivable(AccountsTestMixin, IntegrationTestCase):
report = execute(filters)[1] report = execute(filters)[1]
self.assertEqual(len(report), 2) self.assertEqual(len(report), 2)
expected_data = {sr.name: [10.0, -10.0, 0.0, -10], si.name: [100.0, 100.0, 10.0, 90.0]} expected_data = {sr.name: [0.0, 10.0, -10.0, 0.0, -10], si.name: [100.0, 0.0, 100.0, 10.0, 90.0]}
rows = report[:2] rows = report[:2]
for row in rows: for row in rows:
self.assertEqual( self.assertEqual(
expected_data[row.voucher_no], expected_data[row.voucher_no],
[row.invoiced or row.paid, row.outstanding, row.remaining_balance, row.future_amount], [
row.invoiced or row.paid,
row.credit_note,
row.outstanding,
row.remaining_balance,
row.future_amount,
],
) )
pe.cancel() pe.cancel()

View File

@@ -27,6 +27,7 @@ def get_report_filters(report_filters):
["Purchase Invoice", "docstatus", "=", 1], ["Purchase Invoice", "docstatus", "=", 1],
["Purchase Invoice", "per_received", "<", 100], ["Purchase Invoice", "per_received", "<", 100],
["Purchase Invoice", "update_stock", "=", 0], ["Purchase Invoice", "update_stock", "=", 0],
["Purchase Invoice", "is_opening", "!=", "Yes"],
] ]
if report_filters.get("purchase_invoice"): if report_filters.get("purchase_invoice"):

View File

@@ -263,6 +263,7 @@ def get_actual_details(name, filters):
and ba.account=gl.account and ba.account=gl.account
and b.{budget_against} = gl.{budget_against} and b.{budget_against} = gl.{budget_against}
and gl.fiscal_year between %s and %s and gl.fiscal_year between %s and %s
and gl.is_cancelled = 0
and b.{budget_against} = %s and b.{budget_against} = %s
and exists( and exists(
select select

View File

@@ -652,6 +652,9 @@ frappe.ui.form.on("Asset", {
frm.set_value("purchase_amount", data.gross_purchase_amount); frm.set_value("purchase_amount", data.gross_purchase_amount);
frm.set_value("asset_quantity", data.asset_quantity); frm.set_value("asset_quantity", data.asset_quantity);
frm.set_value("cost_center", data.cost_center); frm.set_value("cost_center", data.cost_center);
if (data.asset_location) {
frm.set_value("location", data.asset_location);
}
if (doctype === "Purchase Receipt") { if (doctype === "Purchase Receipt") {
frm.set_value("purchase_receipt_item", data.purchase_receipt_item); frm.set_value("purchase_receipt_item", data.purchase_receipt_item);

View File

@@ -19,6 +19,10 @@ frappe.query_reports["Purchase Order Analysis"] = {
width: "80", width: "80",
reqd: 1, reqd: 1,
default: frappe.datetime.add_months(frappe.datetime.get_today(), -1), default: frappe.datetime.add_months(frappe.datetime.get_today(), -1),
on_change: (report) => {
report.set_filter_value("name", []);
report.refresh();
},
}, },
{ {
fieldname: "to_date", fieldname: "to_date",
@@ -27,6 +31,10 @@ frappe.query_reports["Purchase Order Analysis"] = {
width: "80", width: "80",
reqd: 1, reqd: 1,
default: frappe.datetime.get_today(), default: frappe.datetime.get_today(),
on_change: (report) => {
report.set_filter_value("name", []);
report.refresh();
},
}, },
{ {
fieldname: "project", fieldname: "project",
@@ -38,13 +46,17 @@ frappe.query_reports["Purchase Order Analysis"] = {
{ {
fieldname: "name", fieldname: "name",
label: __("Purchase Order"), label: __("Purchase Order"),
fieldtype: "Link", fieldtype: "MultiSelectList",
width: "80", width: "80",
options: "Purchase Order", options: "Purchase Order",
get_query: () => { get_data: function (txt) {
return { let filters = { docstatus: 1 };
filters: { docstatus: 1 },
}; const from_date = frappe.query_report.get_filter_value("from_date");
const to_date = frappe.query_report.get_filter_value("to_date");
if (from_date && to_date) filters["transaction_date"] = ["between", [from_date, to_date]];
return frappe.db.get_link_options("Purchase Order", txt, filters);
}, },
}, },
{ {
@@ -52,9 +64,16 @@ frappe.query_reports["Purchase Order Analysis"] = {
label: __("Status"), label: __("Status"),
fieldtype: "MultiSelectList", fieldtype: "MultiSelectList",
width: "80", width: "80",
options: ["To Pay", "To Bill", "To Receive", "To Receive and Bill", "Completed"], options: ["To Pay", "To Bill", "To Receive", "To Receive and Bill", "Completed", "Closed"],
get_data: function (txt) { get_data: function (txt) {
let status = ["To Pay", "To Bill", "To Receive", "To Receive and Bill", "Completed"]; let status = [
"To Pay",
"To Bill",
"To Receive",
"To Receive and Bill",
"Completed",
"Closed",
];
let options = []; let options = [];
for (let option of status) { for (let option of status) {
options.push({ options.push({

View File

@@ -70,14 +70,16 @@ def get_data(filters):
po.company, po.company,
po_item.name, po_item.name,
) )
.where((po_item.parent == po.name) & (po.status.notin(("Stopped", "Closed"))) & (po.docstatus == 1)) .where((po_item.parent == po.name) & (po.status.notin(("Stopped", "On Hold"))) & (po.docstatus == 1))
.groupby(po_item.name) .groupby(po_item.name)
.orderby(po.transaction_date) .orderby(po.transaction_date)
) )
for field in ("company", "name"): if filters.get("company"):
if filters.get(field): query = query.where(po.company == filters.get("company"))
query = query.where(po[field] == filters.get(field))
if filters.get("name"):
query = query.where(po.name.isin(filters.get("name")))
if filters.get("from_date") and filters.get("to_date"): if filters.get("from_date") and filters.get("to_date"):
query = query.where(po.transaction_date.between(filters.get("from_date"), filters.get("to_date"))) query = query.where(po.transaction_date.between(filters.get("from_date"), filters.get("to_date")))

View File

@@ -197,6 +197,14 @@ class AccountsController(TransactionBase):
self.set_incoming_rate() self.set_incoming_rate()
self.init_internal_values() self.init_internal_values()
# Need to set taxes based on taxes_and_charges template
# before calculating taxes and totals
if self.meta.get_field("taxes_and_charges"):
self.validate_enabled_taxes_and_charges()
self.validate_tax_account_company()
self.set_taxes_and_charges()
if self.meta.get_field("currency"): if self.meta.get_field("currency"):
self.calculate_taxes_and_totals() self.calculate_taxes_and_totals()
@@ -207,10 +215,6 @@ class AccountsController(TransactionBase):
self.validate_all_documents_schedule() self.validate_all_documents_schedule()
if self.meta.get_field("taxes_and_charges"):
self.validate_enabled_taxes_and_charges()
self.validate_tax_account_company()
self.validate_party() self.validate_party()
self.validate_currency() self.validate_currency()
self.validate_party_account_currency() self.validate_party_account_currency()
@@ -255,8 +259,6 @@ class AccountsController(TransactionBase):
self.validate_deferred_income_expense_account() self.validate_deferred_income_expense_account()
self.set_inter_company_account() self.set_inter_company_account()
self.set_taxes_and_charges()
if self.doctype == "Purchase Invoice": if self.doctype == "Purchase Invoice":
self.calculate_paid_amount() self.calculate_paid_amount()
# apply tax withholding only if checked and applicable # apply tax withholding only if checked and applicable
@@ -823,11 +825,15 @@ class AccountsController(TransactionBase):
and item.get("use_serial_batch_fields") and item.get("use_serial_batch_fields")
) )
): ):
if fieldname == "batch_no" and not item.batch_no and not item.is_free_item:
item.set("rate", ret.get("rate"))
item.set("price_list_rate", ret.get("price_list_rate"))
item.set(fieldname, value) item.set(fieldname, value)
if fieldname == "batch_no" and item.batch_no and not item.is_free_item:
if ret.get("rate"):
item.set("rate", ret.get("rate"))
if not item.get("price_list_rate") and ret.get("price_list_rate"):
item.set("price_list_rate", ret.get("price_list_rate"))
elif fieldname in ["cost_center", "conversion_factor"] and not item.get( elif fieldname in ["cost_center", "conversion_factor"] and not item.get(
fieldname fieldname
): ):

View File

@@ -935,6 +935,35 @@ class TestAccountsController(IntegrationTestCase):
self.assertEqual(exc_je_for_si, []) self.assertEqual(exc_je_for_si, [])
self.assertEqual(exc_je_for_pe, []) self.assertEqual(exc_je_for_pe, [])
@IntegrationTestCase.change_settings("Accounts Settings", {"add_taxes_from_item_tax_template": 1})
def test_18_fetch_taxes_based_on_taxes_and_charges_template(self):
# Create a Sales Taxes and Charges Template
if not frappe.db.exists("Sales Taxes and Charges Template", "_Test Tax - _TC"):
doc = frappe.new_doc("Sales Taxes and Charges Template")
doc.company = self.company
doc.title = "_Test Tax"
doc.append(
"taxes",
{
"charge_type": "On Net Total",
"account_head": "Sales Expenses - _TC",
"description": "Test taxes",
"rate": 9,
},
)
doc.insert()
# Create a Sales Invoice
sinv = frappe.new_doc("Sales Invoice")
sinv.customer = self.customer
sinv.company = self.company
sinv.currency = "INR"
sinv.taxes_and_charges = "_Test Tax - _TC"
sinv.append("items", {"item_code": "_Test Item", "qty": 1, "rate": 50})
sinv.insert()
self.assertEqual(sinv.total_taxes_and_charges, 4.5)
def test_20_journal_against_sales_invoice(self): def test_20_journal_against_sales_invoice(self):
# Invoice in Foreign Currency # Invoice in Foreign Currency
si = self.create_sales_invoice(qty=1, conversion_rate=80, rate=1) si = self.create_sales_invoice(qty=1, conversion_rate=80, rate=1)

View File

@@ -4,7 +4,7 @@ app_publisher = "Frappe Technologies Pvt. Ltd."
app_description = """ERP made simple""" app_description = """ERP made simple"""
app_icon = "fa fa-th" app_icon = "fa fa-th"
app_color = "#e74c3c" app_color = "#e74c3c"
app_email = "info@erpnext.com" app_email = "hello@frappe.io"
app_license = "GNU General Public License (v3)" app_license = "GNU General Public License (v3)"
source_link = "https://github.com/frappe/erpnext" source_link = "https://github.com/frappe/erpnext"
app_logo_url = "/assets/erpnext/images/erpnext-logo.svg" app_logo_url = "/assets/erpnext/images/erpnext-logo.svg"
@@ -479,7 +479,7 @@ email_brand_image = "assets/erpnext/images/erpnext-logo.jpg"
default_mail_footer = """ default_mail_footer = """
<span> <span>
Sent via Sent via
<a class="text-muted" href="https://erpnext.com?source=via_email_footer" target="_blank"> <a class="text-muted" href="https://frappe.io/erpnext?source=via_email_footer" target="_blank">
ERPNext ERPNext
</a> </a>
</span> </span>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -403,3 +403,4 @@ erpnext.patches.v14_0.disable_add_row_in_gross_profit
erpnext.patches.v14_0.update_posting_datetime erpnext.patches.v14_0.update_posting_datetime
erpnext.patches.v15_0.rename_field_from_rate_difference_to_amount_difference erpnext.patches.v15_0.rename_field_from_rate_difference_to_amount_difference
erpnext.patches.v15_0.recalculate_amount_difference_field erpnext.patches.v15_0.recalculate_amount_difference_field
erpnext.stock.doctype.stock_ledger_entry.patches.ensure_sle_indexes

View File

@@ -82,6 +82,9 @@ def get_asset_depreciation_schedules_map():
.orderby(ds.idx) .orderby(ds.idx)
).run(as_dict=True) ).run(as_dict=True)
if len(records) > 20000:
frappe.db.auto_commit_on_many_writes = True
asset_depreciation_schedules_map = frappe._dict() asset_depreciation_schedules_map = frappe._dict()
for d in records: for d in records:
asset_depreciation_schedules_map.setdefault((d.asset_name, cstr(d.finance_book)), []).append(d) asset_depreciation_schedules_map.setdefault((d.asset_name, cstr(d.finance_book)), []).append(d)

View File

@@ -322,17 +322,31 @@ class Project(Document):
self.total_sales_amount = total_sales_amount and total_sales_amount[0][0] or 0 self.total_sales_amount = total_sales_amount and total_sales_amount[0][0] or 0
def update_billed_amount(self): def update_billed_amount(self):
# nosemgrep self.total_billed_amount = self.get_billed_amount_from_parent() + self.get_billed_amount_from_child()
def get_billed_amount_from_parent(self):
total_billed_amount = frappe.db.sql( total_billed_amount = frappe.db.sql(
"""select sum(base_net_amount) """select sum(base_net_amount)
from `tabSales Invoice Item` si_item, `tabSales Invoice` si from `tabSales Invoice` si join `tabSales Invoice Item` si_item on si_item.parent = si.name
where si_item.parent = si.name where si_item.project is null
and if(si_item.project, si_item.project, si.project) = %s and si.project is not null
and si.project = %s
and si.docstatus = 1""", and si.docstatus = 1""",
self.name, self.name,
) )
self.total_billed_amount = total_billed_amount and total_billed_amount[0][0] or 0 return total_billed_amount and total_billed_amount[0][0] or 0
def get_billed_amount_from_child(self):
total_billed_amount = frappe.db.sql(
"""select sum(base_net_amount)
from `tabSales Invoice Item`
where project = %s
and docstatus = 1""",
self.name,
)
return total_billed_amount and total_billed_amount[0][0] or 0
def after_rename(self, old_name, new_name, merge=False): def after_rename(self, old_name, new_name, merge=False):
if old_name == self.copied_from: if old_name == self.copied_from:

View File

@@ -8,7 +8,8 @@
"timesheet_sb", "timesheet_sb",
"ignore_workstation_time_overlap", "ignore_workstation_time_overlap",
"ignore_user_time_overlap", "ignore_user_time_overlap",
"ignore_employee_time_overlap" "ignore_employee_time_overlap",
"fetch_timesheet_in_sales_invoice"
], ],
"fields": [ "fields": [
{ {
@@ -33,11 +34,18 @@
"fieldname": "ignore_employee_time_overlap", "fieldname": "ignore_employee_time_overlap",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Ignore Employee Time Overlap" "label": "Ignore Employee Time Overlap"
},
{
"default": "0",
"description": "Enabling the check box will fetch timesheet on select of a Project in Sales Invoice",
"fieldname": "fetch_timesheet_in_sales_invoice",
"fieldtype": "Check",
"label": "Fetch Timesheet in Sales Invoice"
} }
], ],
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2024-03-27 13:10:21.984404", "modified": "2025-02-13 23:01:27.321902",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Projects", "module": "Projects",
"name": "Projects Settings", "name": "Projects Settings",

View File

@@ -14,6 +14,7 @@ class ProjectsSettings(Document):
if TYPE_CHECKING: if TYPE_CHECKING:
from frappe.types import DF from frappe.types import DF
fetch_timesheet_in_sales_invoice: DF.Check
ignore_employee_time_overlap: DF.Check ignore_employee_time_overlap: DF.Check
ignore_user_time_overlap: DF.Check ignore_user_time_overlap: DF.Check
ignore_workstation_time_overlap: DF.Check ignore_workstation_time_overlap: DF.Check

View File

@@ -53,6 +53,7 @@ class TestTimesheet(IntegrationTestCase):
self.assertEqual(item.qty, 2.00) self.assertEqual(item.qty, 2.00)
self.assertEqual(item.rate, 50.00) self.assertEqual(item.rate, 50.00)
@IntegrationTestCase.change_settings("Projects Settings", {"fetch_timesheet_in_sales_invoice": 1})
def test_timesheet_billing_based_on_project(self): def test_timesheet_billing_based_on_project(self):
emp = make_employee("test_employee_6@salary.com") emp = make_employee("test_employee_6@salary.com")
project = frappe.get_value("Project", {"project_name": "_Test Project"}) project = frappe.get_value("Project", {"project_name": "_Test Project"})
@@ -62,7 +63,7 @@ class TestTimesheet(IntegrationTestCase):
) )
sales_invoice = create_sales_invoice(do_not_save=True) sales_invoice = create_sales_invoice(do_not_save=True)
sales_invoice.project = project sales_invoice.project = project
sales_invoice._add_timesheet_data() sales_invoice.add_timesheet_data()
sales_invoice.submit() sales_invoice.submit()
ts = frappe.get_doc("Timesheet", timesheet.name) ts = frappe.get_doc("Timesheet", timesheet.name)

View File

@@ -1373,6 +1373,8 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
() => this.calculate_stock_uom_rate(doc, cdt, cdn), () => this.calculate_stock_uom_rate(doc, cdt, cdn),
() => this.apply_pricing_rule(item, true) () => this.apply_pricing_rule(item, true)
]); ]);
} else {
this.conversion_factor(doc, cdt, cdn, true)
} }
} }

View File

@@ -14,6 +14,10 @@ erpnext.landed_cost_taxes_and_charges = {
"Income Account", "Income Account",
"Expenses Included In Valuation", "Expenses Included In Valuation",
"Expenses Included In Asset Valuation", "Expenses Included In Asset Valuation",
"Expense Account",
"Direct Expense",
"Indirect Expense",
"Stock Received But Not Billed",
], ],
], ],
company: frm.doc.company, company: frm.doc.company,

View File

@@ -540,6 +540,8 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
has_batch_no: this.item.has_batch_no, has_batch_no: this.item.has_batch_no,
qty: qty, qty: qty,
based_on: based_on, based_on: based_on,
posting_date: this.frm.doc.posting_date,
posting_time: this.frm.doc.posting_time,
}, },
callback: (r) => { callback: (r) => {
if (r.message) { if (r.message) {

View File

@@ -1,4 +1,4 @@
{{ address_line1 }}<br> {{ address_line1 }}<br>
{% if address_line2 %}{{ address_line2 }}<br>{% endif -%} {% if address_line2 %}{{ address_line2 }}<br>{% endif -%}
{{ city }}, {% if state %}{{ state }}{% endif -%}{% if pincode %} {{ pincode }}<br>{% endif -%} {{ city }}, {% if state %}{{ state }}{% endif -%}{% if pincode %} {{ pincode }}{% endif -%}<br>
{% if country != "United States" %}{{ country }}{% endif -%} {% if country != "United States" %}{{ country }}{% endif -%}

View File

@@ -2185,6 +2185,45 @@ class TestSalesOrder(AccountsTestMixin, IntegrationTestCase):
frappe.db.set_single_value("Stock Settings", "update_existing_price_list_rate", 0) frappe.db.set_single_value("Stock Settings", "update_existing_price_list_rate", 0)
frappe.db.set_single_value("Stock Settings", "auto_insert_price_list_rate_if_missing", 0) frappe.db.set_single_value("Stock Settings", "auto_insert_price_list_rate_if_missing", 0)
def test_delivery_note_rate_on_change_of_warehouse(self):
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
item = make_item(
"_Test Batch Item for Delivery Note Rate",
{
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "BH-SDDTBIFRM-.#####",
},
)
frappe.db.set_single_value("Stock Settings", "auto_insert_price_list_rate_if_missing", 1)
so = make_sales_order(
item_code=item.name, rate=27648.00, price_list_rate=27648.00, qty=1, do_not_submit=True
)
so.items[0].rate = 90
so.save()
self.assertTrue(so.items[0].discount_amount == 27558.0)
so.submit()
warehouse = create_warehouse("NW Warehouse FOR Rate", company=so.company)
make_stock_entry(
item_code=item.name,
qty=2,
target=warehouse,
basic_rate=100,
company=so.company,
use_serial_batch_fields=1,
)
dn = make_delivery_note(so.name)
dn.items[0].warehouse = warehouse
dn.save()
self.assertEqual(dn.items[0].rate, 90)
def test_credit_limit_on_so_reopning(self): def test_credit_limit_on_so_reopning(self):
# set credit limit # set credit limit
company = "_Test Company" company = "_Test Company"

View File

@@ -952,7 +952,6 @@
"options": "Cost Center", "options": "Cost Center",
"print_hide": 1, "print_hide": 1,
"print_width": "120px", "print_width": "120px",
"reqd": 1,
"width": "120px" "width": "120px"
}, },
{ {
@@ -971,7 +970,7 @@
"idx": 1, "idx": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2025-02-06 13:29:24.619850", "modified": "2025-02-28 09:45:43.934947",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Selling", "module": "Selling",
"name": "Sales Order Item", "name": "Sales Order Item",

View File

@@ -32,7 +32,7 @@ class SalesOrderItem(Document):
brand: DF.Link | None brand: DF.Link | None
company_total_stock: DF.Float company_total_stock: DF.Float
conversion_factor: DF.Float conversion_factor: DF.Float
cost_center: DF.Link cost_center: DF.Link | None
customer_item_code: DF.Data | None customer_item_code: DF.Data | None
delivered_by_supplier: DF.Check delivered_by_supplier: DF.Check
delivered_qty: DF.Float delivered_qty: DF.Float

View File

@@ -320,13 +320,13 @@ def get_past_order_list(search_term, status, limit=20):
invoice_list = [] invoice_list = []
if search_term and status: if search_term and status:
invoices_by_customer = frappe.db.get_all( invoices_by_customer = frappe.db.get_list(
"POS Invoice", "POS Invoice",
filters={"customer": ["like", f"%{search_term}%"], "status": status}, filters={"customer": ["like", f"%{search_term}%"], "status": status},
fields=fields, fields=fields,
page_length=limit, page_length=limit,
) )
invoices_by_name = frappe.db.get_all( invoices_by_name = frappe.db.get_list(
"POS Invoice", "POS Invoice",
filters={"name": ["like", f"%{search_term}%"], "status": status}, filters={"name": ["like", f"%{search_term}%"], "status": status},
fields=fields, fields=fields,
@@ -335,7 +335,7 @@ def get_past_order_list(search_term, status, limit=20):
invoice_list = invoices_by_customer + invoices_by_name invoice_list = invoices_by_customer + invoices_by_name
elif status: elif status:
invoice_list = frappe.db.get_all( invoice_list = frappe.db.get_list(
"POS Invoice", filters={"status": status}, fields=fields, page_length=limit "POS Invoice", filters={"status": status}, fields=fields, page_length=limit
) )

View File

@@ -40,7 +40,7 @@ erpnext.PointOfSale.Controller = class {
in_list_view: 1, in_list_view: 1,
label: __("Opening Amount"), label: __("Opening Amount"),
options: "company:company_currency", options: "company:company_currency",
change: function () { onchange: function () {
dialog.fields_dict.balance_details.df.data.some((d) => { dialog.fields_dict.balance_details.df.data.some((d) => {
if (d.idx == this.doc.idx) { if (d.idx == this.doc.idx) {
d.opening_amount = this.value; d.opening_amount = this.value;
@@ -609,6 +609,14 @@ erpnext.PointOfSale.Controller = class {
if (this.is_current_item_being_edited(item_row) || from_selector) { if (this.is_current_item_being_edited(item_row) || from_selector) {
await frappe.model.set_value(item_row.doctype, item_row.name, field, value); await frappe.model.set_value(item_row.doctype, item_row.name, field, value);
if (item.serial_no && from_selector) {
await frappe.model.set_value(
item_row.doctype,
item_row.name,
"serial_no",
item_row.serial_no + `\n${item.serial_no}`
);
}
this.update_cart_html(item_row); this.update_cart_html(item_row);
} }
} else { } else {

View File

@@ -187,6 +187,7 @@ erpnext.PointOfSale.ItemDetails = class {
this[`${fieldname}_control`].set_value(item[fieldname]); this[`${fieldname}_control`].set_value(item[fieldname]);
}); });
this.resize_serial_control(item);
this.make_auto_serial_selection_btn(item); this.make_auto_serial_selection_btn(item);
this.bind_custom_control_change_event(); this.bind_custom_control_change_event();
@@ -203,11 +204,17 @@ erpnext.PointOfSale.ItemDetails = class {
"actual_qty", "actual_qty",
"price_list_rate", "price_list_rate",
]; ];
if (item.has_serial_no) fields.push("serial_no"); if (item.has_serial_no || item.serial_no) fields.push("serial_no");
if (item.has_batch_no) fields.push("batch_no"); if (item.has_batch_no || item.batch_no) fields.push("batch_no");
return fields; return fields;
} }
resize_serial_control(item) {
if (item.has_serial_no || item.serial_no) {
this.$form_container.find(".serial_no-control").find("textarea").css("height", "6rem");
}
}
make_auto_serial_selection_btn(item) { make_auto_serial_selection_btn(item) {
if (item.has_serial_no || item.has_batch_no) { if (item.has_serial_no || item.has_batch_no) {
if (item.has_serial_no && item.has_batch_no) { if (item.has_serial_no && item.has_batch_no) {
@@ -225,7 +232,6 @@ erpnext.PointOfSale.ItemDetails = class {
`<div class="btn btn-sm btn-secondary auto-fetch-btn" style="margin-top: 6px">${label}</div>` `<div class="btn btn-sm btn-secondary auto-fetch-btn" style="margin-top: 6px">${label}</div>`
); );
} }
this.$form_container.find(".serial_no-control").find("textarea").css("height", "6rem");
} }
} }

View File

@@ -340,19 +340,11 @@ erpnext.PointOfSale.Payment = class {
// pass // pass
} }
async render_payment_section() { render_payment_section() {
this.render_payment_mode_dom(); this.render_payment_mode_dom();
this.make_invoice_fields_control(); this.make_invoice_fields_control();
this.update_totals_section(); this.update_totals_section();
let r = await frappe.db.get_value( this.unset_grand_total_to_default_mop();
"POS Profile",
this.frm.doc.pos_profile,
"disable_grand_total_to_default_mop"
);
if (!r.message.disable_grand_total_to_default_mop) {
this.focus_on_default_mop();
}
} }
after_render() { after_render() {
@@ -637,6 +629,19 @@ erpnext.PointOfSale.Payment = class {
.toLowerCase(); .toLowerCase();
} }
async unset_grand_total_to_default_mop() {
const doc = this.events.get_frm().doc;
let r = await frappe.db.get_value(
"POS Profile",
doc.pos_profile,
"disable_grand_total_to_default_mop"
);
if (!r.message.disable_grand_total_to_default_mop) {
this.focus_on_default_mop();
}
}
validate_reqd_invoice_fields() { validate_reqd_invoice_fields() {
const doc = this.events.get_frm().doc; const doc = this.events.get_frm().doc;
let validation_flag = true; let validation_flag = true;

View File

@@ -19,6 +19,10 @@ frappe.query_reports["Sales Order Analysis"] = {
width: "80", width: "80",
reqd: 1, reqd: 1,
default: frappe.datetime.add_months(frappe.datetime.get_today(), -1), default: frappe.datetime.add_months(frappe.datetime.get_today(), -1),
on_change: (report) => {
report.set_filter_value("sales_order", []);
report.refresh();
},
}, },
{ {
fieldname: "to_date", fieldname: "to_date",
@@ -27,6 +31,10 @@ frappe.query_reports["Sales Order Analysis"] = {
width: "80", width: "80",
reqd: 1, reqd: 1,
default: frappe.datetime.get_today(), default: frappe.datetime.get_today(),
on_change: (report) => {
report.set_filter_value("sales_order", []);
report.refresh();
},
}, },
{ {
fieldname: "sales_order", fieldname: "sales_order",
@@ -35,12 +43,13 @@ frappe.query_reports["Sales Order Analysis"] = {
width: "80", width: "80",
options: "Sales Order", options: "Sales Order",
get_data: function (txt) { get_data: function (txt) {
return frappe.db.get_link_options("Sales Order", txt); let filters = { docstatus: 1 };
},
get_query: () => { const from_date = frappe.query_report.get_filter_value("from_date");
return { const to_date = frappe.query_report.get_filter_value("to_date");
filters: { docstatus: 1 }, if (from_date && to_date) filters["transaction_date"] = ["between", [from_date, to_date]];
};
return frappe.db.get_link_options("Sales Order", txt, filters);
}, },
}, },
{ {
@@ -53,10 +62,17 @@ frappe.query_reports["Sales Order Analysis"] = {
fieldname: "status", fieldname: "status",
label: __("Status"), label: __("Status"),
fieldtype: "MultiSelectList", fieldtype: "MultiSelectList",
options: ["To Pay", "To Bill", "To Deliver", "To Deliver and Bill", "Completed"], options: ["To Pay", "To Bill", "To Deliver", "To Deliver and Bill", "Completed", "Closed"],
width: "80", width: "80",
get_data: function (txt) { get_data: function (txt) {
let status = ["To Pay", "To Bill", "To Deliver", "To Deliver and Bill", "Completed"]; let status = [
"To Pay",
"To Bill",
"To Deliver",
"To Deliver and Bill",
"Completed",
"Closed",
];
let options = []; let options = [];
for (let option of status) { for (let option of status) {
options.push({ options.push({

View File

@@ -86,7 +86,7 @@ def get_data(conditions, filters):
ON sii.so_detail = soi.name and sii.docstatus = 1 ON sii.so_detail = soi.name and sii.docstatus = 1
WHERE WHERE
soi.parent = so.name soi.parent = so.name
and so.status not in ('Stopped', 'Closed', 'On Hold') and so.status not in ('Stopped', 'On Hold')
and so.docstatus = 1 and so.docstatus = 1
{conditions} {conditions}
GROUP BY soi.name GROUP BY soi.name

View File

@@ -14,7 +14,7 @@ from erpnext.setup.doctype.incoterm.incoterm import create_incoterms
from .default_success_action import get_default_success_action from .default_success_action import get_default_success_action
default_mail_footer = """<div style="padding: 7px; text-align: right; color: #888"><small>Sent via default_mail_footer = """<div style="padding: 7px; text-align: right; color: #888"><small>Sent via
<a style="color: #888" href="http://erpnext.org">ERPNext</a></div>""" <a style="color: #888" href="http://frappe.io/erpnext">ERPNext</a></div>"""
def after_install(): def after_install():

View File

@@ -365,6 +365,8 @@ class DeprecatedBatchNoValuation:
if self.sle.voucher_detail_no: if self.sle.voucher_detail_no:
query = query.where(sabb.voucher_detail_no != self.sle.voucher_detail_no) query = query.where(sabb.voucher_detail_no != self.sle.voucher_detail_no)
query = query.where(sabb.voucher_type != "Pick List")
data = query.run(as_dict=True) data = query.run(as_dict=True)
if not data: if not data:
return {} return {}

View File

@@ -247,7 +247,7 @@ def update_qty(bin_name, args):
& (sle.warehouse == args.get("warehouse")) & (sle.warehouse == args.get("warehouse"))
& (sle.is_cancelled == 0) & (sle.is_cancelled == 0)
) )
.orderby(CombineDatetime(sle.posting_date, sle.posting_time), order=Order.desc) .orderby(sle.posting_datetime, order=Order.desc)
.orderby(sle.creation, order=Order.desc) .orderby(sle.creation, order=Order.desc)
.limit(1) .limit(1)
.run() .run()

View File

@@ -236,7 +236,12 @@ class InventoryDimension(Document):
custom_fields[dt] = dimension_field custom_fields[dt] = dimension_field
filter_custom_fields = {} filter_custom_fields = {}
ignore_doctypes = ["Serial and Batch Bundle", "Serial and Batch Entry", "Pick List Item"] ignore_doctypes = [
"Serial and Batch Bundle",
"Serial and Batch Entry",
"Pick List Item",
"Maintenance Visit Purpose",
]
if custom_fields: if custom_fields:
for doctype, fields in custom_fields.items(): for doctype, fields in custom_fields.items():

View File

@@ -1081,15 +1081,19 @@ def get_billed_amount_against_po(po_items):
if not po_items: if not po_items:
return {} return {}
purchase_invoice = frappe.qb.DocType("Purchase Invoice")
purchase_invoice_item = frappe.qb.DocType("Purchase Invoice Item") purchase_invoice_item = frappe.qb.DocType("Purchase Invoice Item")
query = ( query = (
frappe.qb.from_(purchase_invoice_item) frappe.qb.from_(purchase_invoice_item)
.inner_join(purchase_invoice)
.on(purchase_invoice_item.parent == purchase_invoice.name)
.select(fn.Sum(purchase_invoice_item.amount).as_("billed_amt"), purchase_invoice_item.po_detail) .select(fn.Sum(purchase_invoice_item.amount).as_("billed_amt"), purchase_invoice_item.po_detail)
.where( .where(
(purchase_invoice_item.po_detail.isin(po_items)) (purchase_invoice_item.po_detail.isin(po_items))
& (purchase_invoice_item.docstatus == 1) & (purchase_invoice.docstatus == 1)
& (purchase_invoice_item.pr_detail.isnull()) & (purchase_invoice_item.pr_detail.isnull())
& (purchase_invoice.update_stock == 0)
) )
.groupby(purchase_invoice_item.po_detail) .groupby(purchase_invoice_item.po_detail)
).run(as_dict=1) ).run(as_dict=1)

View File

@@ -4096,6 +4096,36 @@ class TestPurchaseReceipt(IntegrationTestCase):
batch_return.save() batch_return.save()
batch_return.submit() batch_return.submit()
def test_pr_status_based_on_invoices_with_update_stock(self):
from erpnext.buying.doctype.purchase_order.purchase_order import (
make_purchase_invoice as _make_purchase_invoice,
)
from erpnext.buying.doctype.purchase_order.purchase_order import (
make_purchase_receipt as _make_purchase_receipt,
)
from erpnext.buying.doctype.purchase_order.test_purchase_order import (
create_pr_against_po,
create_purchase_order,
)
item_code = "Test Item for PR Status Based on Invoices"
create_item(item_code)
po = create_purchase_order(item_code=item_code, qty=10)
pi = _make_purchase_invoice(po.name)
pi.update_stock = 1
pi.items[0].qty = 5
pi.submit()
po.reload()
self.assertEqual(po.per_billed, 50)
pr = _make_purchase_receipt(po.name)
self.assertEqual(pr.items[0].qty, 5)
pr.submit()
pr.reload()
self.assertEqual(pr.status, "To Bill")
def prepare_data_for_internal_transfer(): def prepare_data_for_internal_transfer():
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier

View File

@@ -34,6 +34,7 @@
"shipment_parcel", "shipment_parcel",
"parcel_template", "parcel_template",
"add_template", "add_template",
"total_weight",
"column_break_28", "column_break_28",
"shipment_delivery_note", "shipment_delivery_note",
"shipment_details_section", "shipment_details_section",
@@ -429,11 +430,17 @@
"label": "Pickup Contact Person", "label": "Pickup Contact Person",
"mandatory_depends_on": "eval:doc.pickup_from_type === 'Company'", "mandatory_depends_on": "eval:doc.pickup_from_type === 'Company'",
"options": "User" "options": "User"
},
{
"fieldname": "total_weight",
"fieldtype": "Float",
"label": "Total Weight (kg)",
"read_only": 1
} }
], ],
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2024-03-27 13:10:41.030764", "modified": "2025-02-20 16:55:20.076418",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Shipment", "name": "Shipment",

View File

@@ -65,6 +65,7 @@ class Shipment(Document):
shipment_parcel: DF.Table[ShipmentParcel] shipment_parcel: DF.Table[ShipmentParcel]
shipment_type: DF.Literal["Goods", "Documents"] shipment_type: DF.Literal["Goods", "Documents"]
status: DF.Literal["Draft", "Submitted", "Booked", "Cancelled", "Completed"] status: DF.Literal["Draft", "Submitted", "Booked", "Cancelled", "Completed"]
total_weight: DF.Float
tracking_status: DF.Literal["", "In Progress", "Delivered", "Returned", "Lost"] tracking_status: DF.Literal["", "In Progress", "Delivered", "Returned", "Lost"]
tracking_status_info: DF.Data | None tracking_status_info: DF.Data | None
tracking_url: DF.SmallText | None tracking_url: DF.SmallText | None
@@ -75,6 +76,7 @@ class Shipment(Document):
self.validate_weight() self.validate_weight()
self.validate_pickup_time() self.validate_pickup_time()
self.set_value_of_goods() self.set_value_of_goods()
self.set_total_weight()
if self.docstatus == 0: if self.docstatus == 0:
self.status = "Draft" self.status = "Draft"
@@ -93,6 +95,12 @@ class Shipment(Document):
if flt(parcel.weight) <= 0: if flt(parcel.weight) <= 0:
frappe.throw(_("Parcel weight cannot be 0")) frappe.throw(_("Parcel weight cannot be 0"))
def set_total_weight(self):
self.total_weight = self.get_total_weight()
def get_total_weight(self):
return sum(flt(parcel.weight) * parcel.count for parcel in self.shipment_parcel if parcel.count > 0)
def validate_pickup_time(self): def validate_pickup_time(self):
if self.pickup_from and self.pickup_to and get_time(self.pickup_to) < get_time(self.pickup_from): if self.pickup_from and self.pickup_to and get_time(self.pickup_to) < get_time(self.pickup_from):
frappe.throw(_("Pickup To time should be greater than Pickup From time")) frappe.throw(_("Pickup To time should be greater than Pickup From time"))

View File

@@ -29,6 +29,17 @@ class TestShipment(IntegrationTestCase):
self.assertEqual(len(second_shipment.shipment_delivery_note), 1) self.assertEqual(len(second_shipment.shipment_delivery_note), 1)
self.assertEqual(second_shipment.shipment_delivery_note[0].delivery_note, delivery_note.name) self.assertEqual(second_shipment.shipment_delivery_note[0].delivery_note, delivery_note.name)
def test_get_total_weight(self):
shipment = frappe.new_doc("Shipment")
shipment.extend(
"shipment_parcel",
[
{"length": 5, "width": 5, "height": 5, "weight": 5, "count": 5},
{"length": 5, "width": 5, "height": 5, "weight": 10, "count": 1},
],
)
self.assertEqual(shipment.get_total_weight(), 35)
def create_test_delivery_note(): def create_test_delivery_note():
company = get_shipment_company() company = get_shipment_company()

View File

@@ -0,0 +1,9 @@
from erpnext.stock.doctype.stock_ledger_entry.stock_ledger_entry import (
on_doctype_update as create_sle_indexes,
)
def execute():
"""Ensure SLE Indexes"""
create_sle_indexes()

View File

@@ -47,18 +47,20 @@
"auto_reserve_serial_and_batch", "auto_reserve_serial_and_batch",
"serial_and_batch_item_settings_tab", "serial_and_batch_item_settings_tab",
"section_break_7", "section_break_7",
"allow_existing_serial_no",
"do_not_use_batchwise_valuation", "do_not_use_batchwise_valuation",
"auto_create_serial_and_batch_bundle_for_outward", "auto_create_serial_and_batch_bundle_for_outward",
"pick_serial_and_batch_based_on", "pick_serial_and_batch_based_on",
"naming_series_prefix",
"column_break_mhzc", "column_break_mhzc",
"disable_serial_no_and_batch_selector", "disable_serial_no_and_batch_selector",
"use_naming_series",
"use_serial_batch_fields", "use_serial_batch_fields",
"do_not_update_serial_batch_on_creation_of_auto_bundle", "do_not_update_serial_batch_on_creation_of_auto_bundle",
"allow_existing_serial_no",
"serial_and_batch_bundle_section", "serial_and_batch_bundle_section",
"set_serial_and_batch_bundle_naming_based_on_naming_series", "set_serial_and_batch_bundle_naming_based_on_naming_series",
"section_break_gnhq",
"use_naming_series",
"column_break_wslv",
"naming_series_prefix",
"stock_planning_tab", "stock_planning_tab",
"auto_material_request", "auto_material_request",
"auto_indent", "auto_indent",
@@ -488,6 +490,14 @@
"fieldname": "set_serial_and_batch_bundle_naming_based_on_naming_series", "fieldname": "set_serial_and_batch_bundle_naming_based_on_naming_series",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Set Serial and Batch Bundle Naming Based on Naming Series" "label": "Set Serial and Batch Bundle Naming Based on Naming Series"
},
{
"fieldname": "section_break_gnhq",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_wslv",
"fieldtype": "Column Break"
} }
], ],
"icon": "icon-cog", "icon": "icon-cog",
@@ -495,7 +505,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2025-02-17 13:36:36.177743", "modified": "2025-02-28 15:08:35.938840",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Stock Settings", "name": "Stock Settings",

View File

@@ -227,7 +227,7 @@ def update_stock(ctx, out, doc=None):
else: else:
qty -= batch_qty qty -= batch_qty
out.update({"batch_no": batch_no, "actual_batch_qty": qty}) out.update({"batch_no": batch_no, "actual_batch_qty": batch_qty})
if rate: if rate:
out.update({"rate": rate, "price_list_rate": rate}) out.update({"rate": rate, "price_list_rate": rate})
@@ -1095,7 +1095,9 @@ def get_batch_based_item_price(pctx: ItemPriceCtx | dict | str, item_code) -> fl
if not item_price: if not item_price:
item_price = get_item_price(pctx, item_code, ignore_party=True, force_batch_no=True) item_price = get_item_price(pctx, item_code, ignore_party=True, force_batch_no=True)
if item_price and item_price[0].uom == pctx.uom: is_free_item = pctx.get("items", [{}])[0].get("is_free_item")
if item_price and item_price[0].uom == pctx.uom and not is_free_item:
return item_price[0].price_list_rate return item_price[0].price_list_rate
return 0.0 return 0.0

View File

@@ -179,7 +179,7 @@ def get_stock_ledger_entries_for_batch_bundle(filters):
sle = frappe.qb.DocType("Stock Ledger Entry") sle = frappe.qb.DocType("Stock Ledger Entry")
batch_package = frappe.qb.DocType("Serial and Batch Entry") batch_package = frappe.qb.DocType("Serial and Batch Entry")
to_date = get_datetime(filters.to_date + " 23:59:59") to_date = get_datetime(str(filters.to_date) + " 23:59:59")
query = ( query = (
frappe.qb.from_(sle) frappe.qb.from_(sle)

View File

@@ -51,6 +51,10 @@ def format_report_data(filters: Filters, item_details: dict, to_date: str) -> li
latest_age = date_diff(to_date, fifo_queue[-1][1]) latest_age = date_diff(to_date, fifo_queue[-1][1])
range_values = get_range_age(filters, fifo_queue, to_date, item_dict) range_values = get_range_age(filters, fifo_queue, to_date, item_dict)
check_and_replace_valuations_if_moving_average(
range_values, details.valuation_method, details.valuation_rate
)
row = [details.name, details.item_name, details.description, details.item_group, details.brand] row = [details.name, details.item_name, details.description, details.item_group, details.brand]
if filters.get("show_warehouse_wise_stock"): if filters.get("show_warehouse_wise_stock"):
@@ -72,6 +76,15 @@ def format_report_data(filters: Filters, item_details: dict, to_date: str) -> li
return data return data
def check_and_replace_valuations_if_moving_average(range_values, item_valuation_method, valuation_rate):
if item_valuation_method == "Moving Average" or (
not item_valuation_method
and frappe.db.get_single_value("Stock Settings", "valuation_method") == "Moving Average"
):
for i in range(0, len(range_values), 2):
range_values[i + 1] = range_values[i] * valuation_rate
def get_average_age(fifo_queue: list, to_date: str) -> float: def get_average_age(fifo_queue: list, to_date: str) -> float:
batch_age = age_qty = total_qty = 0.0 batch_age = age_qty = total_qty = 0.0
for batch in fifo_queue: for batch in fifo_queue:
@@ -396,6 +409,7 @@ class FIFOSlots:
self.item_details[key]["total_qty"] += row.actual_qty self.item_details[key]["total_qty"] += row.actual_qty
self.item_details[key]["has_serial_no"] = row.has_serial_no self.item_details[key]["has_serial_no"] = row.has_serial_no
self.item_details[key]["details"].valuation_rate = row.valuation_rate
def __aggregate_details_by_item(self, wh_wise_data: dict) -> dict: def __aggregate_details_by_item(self, wh_wise_data: dict) -> dict:
"Aggregate Item-Wh wise data into single Item entry." "Aggregate Item-Wh wise data into single Item entry."
@@ -437,8 +451,10 @@ class FIFOSlots:
item.description, item.description,
item.stock_uom, item.stock_uom,
item.has_serial_no, item.has_serial_no,
item.valuation_method,
sle.actual_qty, sle.actual_qty,
sle.stock_value_difference, sle.stock_value_difference,
sle.valuation_rate,
sle.posting_date, sle.posting_date,
sle.voucher_type, sle.voucher_type,
sle.voucher_no, sle.voucher_no,
@@ -506,7 +522,14 @@ class FIFOSlots:
item_table = frappe.qb.DocType("Item") item_table = frappe.qb.DocType("Item")
item = frappe.qb.from_("Item").select( item = frappe.qb.from_("Item").select(
"name", "item_name", "description", "stock_uom", "brand", "item_group", "has_serial_no" "name",
"item_name",
"description",
"stock_uom",
"brand",
"item_group",
"has_serial_no",
"valuation_method",
) )
if self.filters.get("item_code"): if self.filters.get("item_code"):

View File

@@ -1006,6 +1006,10 @@ class SerialBatchCreation:
elif self.has_serial_no and not self.get("serial_nos"): elif self.has_serial_no and not self.get("serial_nos"):
self.serial_nos = get_serial_nos_for_outward(kwargs) self.serial_nos = get_serial_nos_for_outward(kwargs)
elif not self.has_serial_no and self.has_batch_no and not self.get("batches"): elif not self.has_serial_no and self.has_batch_no and not self.get("batches"):
if self.get("posting_date"):
kwargs["posting_date"] = self.get("posting_date")
kwargs["posting_time"] = self.get("posting_time")
self.batches = get_available_batches(kwargs) self.batches = get_available_batches(kwargs)
def set_auto_serial_batch_entries_for_inward(self): def set_auto_serial_batch_entries_for_inward(self):

View File

@@ -562,12 +562,28 @@ class update_entries_after:
self.new_items_found = False self.new_items_found = False
self.distinct_item_warehouses = args.get("distinct_item_warehouses", frappe._dict()) self.distinct_item_warehouses = args.get("distinct_item_warehouses", frappe._dict())
self.affected_transactions: set[tuple[str, str]] = set() self.affected_transactions: set[tuple[str, str]] = set()
self.reserved_stock = flt(self.args.reserved_stock) self.reserved_stock = self.get_reserved_stock()
self.data = frappe._dict() self.data = frappe._dict()
self.initialize_previous_data(self.args) self.initialize_previous_data(self.args)
self.build() self.build()
def get_reserved_stock(self):
sre = frappe.qb.DocType("Stock Reservation Entry")
posting_datetime = get_combine_datetime(self.args.posting_date, self.args.posting_time)
query = (
frappe.qb.from_(sre)
.select(Sum(sre.reserved_qty) - Sum(sre.delivered_qty))
.where(
(sre.item_code == self.item_code)
& (sre.warehouse == self.args.warehouse)
& (sre.docstatus == 1)
& (sre.creation <= posting_datetime)
)
).run()
return flt(query[0][0]) if query else 0.0
def set_precision(self): def set_precision(self):
self.flt_precision = cint(frappe.db.get_default("float_precision")) or 2 self.flt_precision = cint(frappe.db.get_default("float_precision")) or 2
self.currency_precision = get_field_precision( self.currency_precision = get_field_precision(
@@ -884,6 +900,10 @@ class update_entries_after:
if not sle.is_adjustment_entry: if not sle.is_adjustment_entry:
sle.stock_value_difference = stock_value_difference sle.stock_value_difference = stock_value_difference
elif sle.is_adjustment_entry and not self.args.get("sle_id"):
sle.stock_value_difference = get_stock_value_difference(
sle.item_code, sle.warehouse, sle.posting_date, sle.posting_time, sle.voucher_no
)
sle.doctype = "Stock Ledger Entry" sle.doctype = "Stock Ledger Entry"
frappe.get_doc(sle).db_update() frappe.get_doc(sle).db_update()

View File

@@ -1 +1 @@
{{ _("Powered by {0}").format('<a href="https://erpnext.com?source=website_footer" target="_blank" class="text-muted">ERPNext</a>') }} {{ _("Powered by {0}").format('<a href="https://frappe.io/erpnext?source=website_footer" target="_blank" class="text-muted">ERPNext</a>') }}

View File

@@ -3,6 +3,7 @@
import frappe import frappe
from frappe.utils import escape_html
@frappe.whitelist(allow_guest=True) @frappe.whitelist(allow_guest=True)
@@ -11,6 +12,8 @@ def send_message(sender, message, subject="Website Query"):
website_send_message(sender, message, subject) website_send_message(sender, message, subject)
message = escape_html(message)
lead = customer = None lead = customer = None
customer = frappe.db.sql( customer = frappe.db.sql(
"""select distinct dl.link_name from `tabDynamic Link` dl """select distinct dl.link_name from `tabDynamic Link` dl

View File

@@ -5,7 +5,7 @@
"type": "git", "type": "git",
"url": "git+https://github.com/frappe/erpnext.git" "url": "git+https://github.com/frappe/erpnext.git"
}, },
"homepage": "https://erpnext.com", "homepage": "https://frappe.io/erpnext",
"author": "Frappe Technologies Pvt. Ltd.", "author": "Frappe Technologies Pvt. Ltd.",
"license": "GPL-3.0", "license": "GPL-3.0",
"bugs": { "bugs": {

View File

@@ -77,6 +77,6 @@ docstring-code-format = true
[project.urls] [project.urls]
Homepage = "https://erpnext.com/" Homepage = "https://frappe.io/erpnext"
Repository = "https://github.com/frappe/erpnext.git" Repository = "https://github.com/frappe/erpnext.git"
"Bug Reports" = "https://github.com/frappe/erpnext/issues" "Bug Reports" = "https://github.com/frappe/erpnext/issues"