Merge pull request #52597 from frappe/version-16-hotfix

chore: release v16
This commit is contained in:
ruthra kumar
2026-02-11 11:32:00 +05:30
committed by GitHub
62 changed files with 2535 additions and 855 deletions

View File

@@ -50,6 +50,7 @@
"fieldname": "amount",
"fieldtype": "Currency",
"label": "Amount",
"options": "currency",
"read_only": 1
},
{

View File

@@ -541,7 +541,7 @@ class FinancialQueryBuilder:
.where(acb_table.period_closing_voucher == closing_voucher)
)
query = self._apply_standard_filters(query, acb_table)
query = self._apply_standard_filters(query, acb_table, "Account Closing Balance")
results = self._execute_with_permissions(query, "Account Closing Balance")
for row in results:
@@ -636,12 +636,15 @@ class FinancialQueryBuilder:
return self._execute_with_permissions(query, "GL Entry")
def _calculate_running_balances(self, balances_data: dict, gl_data: list[dict]) -> dict:
for row in gl_data:
account = row["account"]
gl_dict = {row["account"]: row for row in gl_data}
accounts = set(balances_data.keys()) | set(gl_dict.keys())
for account in accounts:
if account not in balances_data:
balances_data[account] = AccountData(account=account, **self._get_account_meta(account))
account_data: AccountData = balances_data[account]
gl_movement = gl_dict.get(account, {})
if account_data.has_periods():
first_period = account_data.get_period(self.periods[0]["key"])
@@ -651,20 +654,13 @@ class FinancialQueryBuilder:
for period in self.periods:
period_key = period["key"]
movement = row.get(period_key, 0.0)
movement = gl_movement.get(period_key, 0.0)
closing_balance = current_balance + movement
account_data.add_period(PeriodValue(period_key, current_balance, closing_balance, movement))
current_balance = closing_balance
# Accounts with no movements
for account_data in balances_data.values():
for period in self.periods:
period_key = period["key"]
if period_key not in account_data.period_values:
account_data.add_period(PeriodValue(period_key, 0.0, 0.0, 0.0))
def _handle_balance_accumulation(self, balances_data):
for account_data in balances_data.values():
account_data: AccountData
@@ -683,12 +679,12 @@ class FinancialQueryBuilder:
else:
account_data.unaccumulate_values()
def _apply_standard_filters(self, query, table):
def _apply_standard_filters(self, query, table, doctype: str = "GL Entry"):
if self.filters.get("ignore_closing_entries"):
if hasattr(table, "is_period_closing_voucher_entry"):
query = query.where(table.is_period_closing_voucher_entry == 0)
else:
if doctype == "GL Entry":
query = query.where(table.voucher_type != "Period Closing Voucher")
else:
query = query.where(table.is_period_closing_voucher_entry == 0)
if self.filters.get("project"):
projects = self.filters.get("project")

View File

@@ -16,7 +16,8 @@ from erpnext.accounts.doctype.financial_report_template.financial_report_engine
from erpnext.accounts.doctype.financial_report_template.test_financial_report_template import (
FinancialReportTemplateTestCase,
)
from erpnext.accounts.utils import get_currency_precision
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
from erpnext.accounts.utils import get_currency_precision, get_fiscal_year
# On IntegrationTestCase, the doctype test records and all
# link-field test record dependencies are recursively loaded
@@ -1672,3 +1673,360 @@ class TestFilterExpressionParser(FinancialReportTemplateTestCase):
mock_row_invalid = self._create_mock_report_row(invalid_formula)
condition = parser.build_condition(mock_row_invalid, account_table)
self.assertIsNone(condition)
class TestFinancialQueryBuilder(FinancialReportTemplateTestCase):
def test_fetch_balances_with_journal_entries(self):
company = "_Test Company"
cash_account = "_Test Cash - _TC"
bank_account = "_Test Bank - _TC"
# Create journal entries in different periods
# October: Transfer 1000 from Bank to Cash
jv_oct = make_journal_entry(
account1=cash_account,
account2=bank_account,
amount=1000,
posting_date="2024-10-15",
company=company,
submit=True,
)
# November: Transfer 500 from Bank to Cash
jv_nov = make_journal_entry(
account1=cash_account,
account2=bank_account,
amount=500,
posting_date="2024-11-20",
company=company,
submit=True,
)
# December: No transactions (test zero movement period)
try:
# Set up filters and periods for Q4 2024
filters = {
"company": company,
"from_fiscal_year": "2024",
"to_fiscal_year": "2024",
"period_start_date": "2024-10-01",
"period_end_date": "2024-12-31",
"filter_based_on": "Date Range",
"periodicity": "Monthly",
}
periods = [
{"key": "2024_oct", "from_date": "2024-10-01", "to_date": "2024-10-31"},
{"key": "2024_nov", "from_date": "2024-11-01", "to_date": "2024-11-30"},
{"key": "2024_dec", "from_date": "2024-12-01", "to_date": "2024-12-31"},
]
query_builder = FinancialQueryBuilder(filters, periods)
# Create account objects as expected by fetch_account_balances
accounts = [
frappe._dict({"name": cash_account, "account_name": "Cash", "account_number": "1001"}),
frappe._dict({"name": bank_account, "account_name": "Bank", "account_number": "1002"}),
]
# Fetch balances using the full workflow
balances_data = query_builder.fetch_account_balances(accounts)
# Verify Cash account balances
cash_data = balances_data.get(cash_account)
self.assertIsNotNone(cash_data, "Cash account should exist in results")
# October: movement = +1000 (debit)
oct_cash = cash_data.get_period("2024_oct")
self.assertIsNotNone(oct_cash, "October period should exist for cash")
self.assertEqual(oct_cash.movement, 1000.0, "October cash movement should be 1000")
# November: movement = +500
nov_cash = cash_data.get_period("2024_nov")
self.assertIsNotNone(nov_cash, "November period should exist for cash")
self.assertEqual(nov_cash.movement, 500.0, "November cash movement should be 500")
self.assertEqual(
nov_cash.opening, oct_cash.closing, "November opening should equal October closing"
)
# December: movement = 0 (no transactions)
dec_cash = cash_data.get_period("2024_dec")
self.assertIsNotNone(dec_cash, "December period should exist for cash")
self.assertEqual(dec_cash.movement, 0.0, "December cash movement should be 0")
self.assertEqual(
dec_cash.closing,
nov_cash.closing,
"December closing should equal November closing when no movement",
)
# Verify Bank account balances (opposite direction)
bank_data = balances_data.get(bank_account)
self.assertIsNotNone(bank_data, "Bank account should exist in results")
oct_bank = bank_data.get_period("2024_oct")
self.assertEqual(oct_bank.movement, -1000.0, "October bank movement should be -1000")
nov_bank = bank_data.get_period("2024_nov")
self.assertEqual(nov_bank.movement, -500.0, "November bank movement should be -500")
finally:
# Clean up: cancel journal entries
jv_nov.cancel()
jv_oct.cancel()
def test_opening_balance_from_previous_period_closing(self):
company = "_Test Company"
cash_account = "_Test Cash - _TC"
sales_account = "Sales - _TC"
posting_date_2023 = "2023-06-15"
# Create journal entry in prior period (2023)
# Cash Dr 5000, Sales Cr 5000
jv_2023 = make_journal_entry(
account1=cash_account,
account2=sales_account,
amount=5000,
posting_date=posting_date_2023,
company=company,
submit=True,
)
pcv = None
jv_2024 = None
original_pcv_setting = frappe.db.get_single_value(
"Accounts Settings", "use_legacy_controller_for_pcv"
)
try:
# Create Period Closing Voucher for 2023
# This will create Account Closing Balance entries
closing_account = frappe.db.get_value(
"Account",
{
"company": company,
"root_type": "Liability",
"is_group": 0,
"account_type": ["not in", ["Payable", "Receivable"]],
},
"name",
)
fy_2023 = get_fiscal_year(posting_date_2023, company=company)
frappe.db.set_single_value("Accounts Settings", "use_legacy_controller_for_pcv", 1)
pcv = frappe.get_doc(
{
"doctype": "Period Closing Voucher",
"transaction_date": "2023-12-31",
"period_start_date": fy_2023[1],
"period_end_date": fy_2023[2],
"company": company,
"fiscal_year": fy_2023[0],
"cost_center": "_Test Cost Center - _TC",
"closing_account_head": closing_account,
"remarks": "Test Period Closing",
}
)
pcv.insert()
pcv.submit()
pcv.reload()
# Now create a small transaction in 2024 to ensure the account appears
jv_2024 = make_journal_entry(
account1=cash_account,
account2=sales_account,
amount=100,
posting_date="2024-01-15",
company=company,
submit=True,
)
# Set up filters for Q1 2024 (after the period closing)
filters = {
"company": company,
"from_fiscal_year": "2024",
"to_fiscal_year": "2024",
"period_start_date": "2024-01-01",
"period_end_date": "2024-03-31",
"filter_based_on": "Date Range",
"periodicity": "Monthly",
"ignore_closing_entries": True, # Don't include PCV entries in movements
}
periods = [
{"key": "2024_jan", "from_date": "2024-01-01", "to_date": "2024-01-31"},
{"key": "2024_feb", "from_date": "2024-02-01", "to_date": "2024-02-29"},
{"key": "2024_mar", "from_date": "2024-03-01", "to_date": "2024-03-31"},
]
query_builder = FinancialQueryBuilder(filters, periods)
accounts = [
frappe._dict({"name": cash_account, "account_name": "Cash", "account_number": "1001"}),
]
balances_data = query_builder.fetch_account_balances(accounts)
# Verify Cash account has opening balance from 2023 transactions
cash_data = balances_data.get(cash_account)
self.assertIsNotNone(cash_data, "Cash account should exist in results")
jan_cash = cash_data.get_period("2024_jan")
self.assertIsNotNone(jan_cash, "January period should exist")
# Opening balance should be from prior period
# Cash had 5000 debit in 2023, so opening in 2024 should be >= 5000
# (may be higher if there were other test transactions)
self.assertEqual(
jan_cash.opening,
5000.0,
"January opening should equal to balance from 2023 (5000)",
)
# Verify running balance logic
# Movement in January is 100 (from jv_2024)
self.assertEqual(jan_cash.movement, 100.0, "January movement should be 100")
self.assertEqual(
jan_cash.closing, jan_cash.opening + jan_cash.movement, "Closing = Opening + Movement"
)
# February and March should have no movement but carry the balance
feb_cash = cash_data.get_period("2024_feb")
self.assertEqual(feb_cash.opening, jan_cash.closing, "Feb opening = Jan closing")
self.assertEqual(feb_cash.movement, 0.0, "February should have no movement")
self.assertEqual(feb_cash.closing, feb_cash.opening, "Feb closing = opening when no movement")
mar_cash = cash_data.get_period("2024_mar")
self.assertEqual(mar_cash.opening, feb_cash.closing, "Mar opening = Feb closing")
self.assertEqual(mar_cash.movement, 0.0, "March should have no movement")
self.assertEqual(mar_cash.closing, mar_cash.opening, "Mar closing = opening when no movement")
# Set up filters for Q2 2024
filters_q2 = {
"company": company,
"from_fiscal_year": "2024",
"to_fiscal_year": "2024",
"period_start_date": "2024-04-01",
"period_end_date": "2024-06-30",
"filter_based_on": "Date Range",
"periodicity": "Monthly",
"ignore_closing_entries": True,
}
periods_q2 = [
{"key": "2024_apr", "from_date": "2024-04-01", "to_date": "2024-04-30"},
{"key": "2024_may", "from_date": "2024-05-01", "to_date": "2024-05-31"},
{"key": "2024_jun", "from_date": "2024-06-01", "to_date": "2024-06-30"},
]
query_builder_q2 = FinancialQueryBuilder(filters_q2, periods_q2)
balances_data_q2 = query_builder_q2.fetch_account_balances(accounts)
# Verify Cash account in Q2
cash_data_q2 = balances_data_q2.get(cash_account)
self.assertIsNotNone(cash_data_q2, "Cash account should exist in Q2 results")
apr_cash = cash_data_q2.get_period("2024_apr")
self.assertIsNotNone(apr_cash, "April period should exist")
# Opening balance in April should equal closing in March
self.assertEqual(
apr_cash.opening,
mar_cash.closing,
"April opening should equal March closing balance",
)
self.assertEqual(apr_cash.closing, apr_cash.opening, "April closing = opening when no movement")
finally:
# Clean up
frappe.db.set_single_value(
"Accounts Settings", "use_legacy_controller_for_pcv", original_pcv_setting or 0
)
if jv_2024:
jv_2024.cancel()
if pcv:
pcv.reload()
if pcv.docstatus == 1:
pcv.cancel()
jv_2023.cancel()
def test_account_with_gl_entries_but_no_prior_closing_balance(self):
company = "_Test Company"
cash_account = "_Test Cash - _TC"
bank_account = "_Test Bank - _TC"
# Create journal entries WITHOUT any prior Period Closing Voucher
# This ensures the account exists in gl_dict but NOT in balances_data
jv = make_journal_entry(
account1=cash_account,
account2=bank_account,
amount=2500,
posting_date="2024-07-15",
company=company,
submit=True,
)
try:
# Set up filters - use a period with no prior PCV
filters = {
"company": company,
"from_fiscal_year": "2024",
"to_fiscal_year": "2024",
"period_start_date": "2024-07-01",
"period_end_date": "2024-09-30",
"filter_based_on": "Date Range",
"periodicity": "Monthly",
}
periods = [
{"key": "2024_jul", "from_date": "2024-07-01", "to_date": "2024-07-31"},
{"key": "2024_aug", "from_date": "2024-08-01", "to_date": "2024-08-31"},
{"key": "2024_sep", "from_date": "2024-09-01", "to_date": "2024-09-30"},
]
query_builder = FinancialQueryBuilder(filters, periods)
# Use accounts that have GL entries but may not have Account Closing Balance
accounts = [
frappe._dict({"name": cash_account, "account_name": "Cash", "account_number": "1001"}),
frappe._dict({"name": bank_account, "account_name": "Bank", "account_number": "1002"}),
]
balances_data = query_builder.fetch_account_balances(accounts)
# Verify accounts are present in results even without prior closing balance
cash_data = balances_data.get(cash_account)
self.assertIsNotNone(cash_data, "Cash account should exist in results")
bank_data = balances_data.get(bank_account)
self.assertIsNotNone(bank_data, "Bank account should exist in results")
# Verify July has the movement from journal entry
jul_cash = cash_data.get_period("2024_jul")
self.assertIsNotNone(jul_cash, "July period should exist for cash")
self.assertEqual(jul_cash.movement, 2500.0, "July cash movement should be 2500")
jul_bank = bank_data.get_period("2024_jul")
self.assertIsNotNone(jul_bank, "July period should exist for bank")
self.assertEqual(jul_bank.movement, -2500.0, "July bank movement should be -2500")
# Verify subsequent periods exist with zero movement
aug_cash = cash_data.get_period("2024_aug")
self.assertIsNotNone(aug_cash, "August period should exist for cash")
self.assertEqual(aug_cash.movement, 0.0, "August cash movement should be 0")
self.assertEqual(aug_cash.opening, jul_cash.closing, "August opening = July closing")
sep_cash = cash_data.get_period("2024_sep")
self.assertIsNotNone(sep_cash, "September period should exist for cash")
self.assertEqual(sep_cash.movement, 0.0, "September cash movement should be 0")
self.assertEqual(sep_cash.opening, aug_cash.closing, "September opening = August closing")
finally:
jv.cancel()

View File

@@ -277,7 +277,21 @@ frappe.ui.form.on("Journal Entry", {
var update_jv_details = function (doc, r) {
$.each(r, function (i, d) {
var row = frappe.model.add_child(doc, "Journal Entry Account", "accounts");
frappe.model.set_value(row.doctype, row.name, "account", d.account);
const {
idx,
name,
owner,
parent,
parenttype,
parentfield,
creation,
modified,
modified_by,
doctype,
docstatus,
...fields
} = d;
frappe.model.set_value(row.doctype, row.name, fields);
});
refresh_field("accounts");
};

View File

@@ -9,6 +9,7 @@
"engine": "InnoDB",
"field_order": [
"entry_type_and_date",
"company",
"is_system_generated",
"title",
"voucher_type",
@@ -17,7 +18,6 @@
"reversal_of",
"column_break1",
"from_template",
"company",
"posting_date",
"finance_book",
"apply_tds",
@@ -638,7 +638,7 @@
"table_fieldname": "payment_entries"
}
],
"modified": "2025-11-13 17:54:14.542903",
"modified": "2026-02-03 14:40:39.944524",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Journal Entry",

View File

@@ -74,8 +74,8 @@ class JournalEntry(AccountsController):
mode_of_payment: DF.Link | None
multi_currency: DF.Check
naming_series: DF.Literal["ACC-JV-.YYYY.-"]
party_not_required: DF.Check
override_tax_withholding_entries: DF.Check
party_not_required: DF.Check
pay_to_recd_from: DF.Data | None
payment_order: DF.Link | None
periodic_entry_difference_account: DF.Link | None

View File

@@ -3,6 +3,7 @@
frappe.ui.form.on("Journal Entry Template", {
onload: function (frm) {
erpnext.accounts.dimensions.setup_dimension_filters(frm, frm.doctype);
if (frm.is_new()) {
frappe.call({
type: "GET",
@@ -37,6 +38,31 @@ frappe.ui.form.on("Journal Entry Template", {
return { filters: filters };
});
frm.set_query("project", "accounts", function (doc, cdt, cdn) {
let row = frappe.get_doc(cdt, cdn);
let filters = {
company: doc.company,
};
if (row.party_type == "Customer") {
filters.customer = row.party;
}
return {
query: "erpnext.controllers.queries.get_project_name",
filters,
};
});
frm.set_query("party_type", "accounts", function (doc, cdt, cdn) {
const row = locals[cdt][cdn];
return {
query: "erpnext.setup.doctype.party_type.party_type.get_party_type",
filters: {
account: row.account,
},
};
});
},
voucher_type: function (frm) {
var add_accounts = function (doc, r) {

View File

@@ -3,6 +3,7 @@
import frappe
from frappe import _
from frappe.model.document import Document
@@ -42,7 +43,29 @@ class JournalEntryTemplate(Document):
]
# end: auto-generated types
pass
def validate(self):
self.validate_party()
def validate_party(self):
"""
Loop over all accounts and see if party and party type is set correctly
"""
for account in self.accounts:
if account.party_type:
account_type = frappe.get_cached_value("Account", account.account, "account_type")
if account_type not in ["Receivable", "Payable"]:
frappe.throw(
_(
"Check row {0} for account {1}: Party Type is only allowed for Receivable or Payable accounts"
).format(account.idx, account.account)
)
if account.party and not account.party_type:
frappe.throw(
_("Check row {0} for account {1}: Party is only allowed if Party Type is set").format(
account.idx, account.account
)
)
@frappe.whitelist()

View File

@@ -5,7 +5,13 @@
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"account"
"account",
"party_type",
"party",
"accounting_dimensions_section",
"cost_center",
"dimension_col_break",
"project"
],
"fields": [
{
@@ -15,18 +21,55 @@
"label": "Account",
"options": "Account",
"reqd": 1
},
{
"fieldname": "party_type",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Party Type",
"options": "DocType"
},
{
"fieldname": "party",
"fieldtype": "Dynamic Link",
"in_list_view": 1,
"label": "Party",
"options": "party_type"
},
{
"collapsible": 1,
"fieldname": "accounting_dimensions_section",
"fieldtype": "Section Break",
"label": "Accounting Dimensions"
},
{
"fieldname": "cost_center",
"fieldtype": "Link",
"label": "Cost Center",
"options": "Cost Center"
},
{
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
},
{
"fieldname": "dimension_col_break",
"fieldtype": "Column Break"
}
],
"istable": 1,
"links": [],
"modified": "2024-03-27 13:09:58.986448",
"modified": "2026-01-09 13:16:27.615083",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Journal Entry Template Account",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -16,9 +16,13 @@ class JournalEntryTemplateAccount(Document):
from frappe.types import DF
account: DF.Link
cost_center: DF.Link | None
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
party: DF.DynamicLink | None
party_type: DF.Link | None
project: DF.Link | None
# end: auto-generated types
pass

View File

@@ -99,8 +99,7 @@ def get_customers_list(pos_profile=None):
return (
frappe.db.sql(
f""" select name, customer_name, customer_group,
territory, customer_pos_id from tabCustomer where disabled = 0
f""" select name, customer_name, customer_group, territory from tabCustomer where disabled = 0
and {cond}""",
tuple(customer_groups),
as_dict=1,

View File

@@ -8,12 +8,12 @@
"email_append_to": 1,
"engine": "InnoDB",
"field_order": [
"company",
"title",
"naming_series",
"supplier",
"supplier_name",
"tax_id",
"company",
"column_break_6",
"posting_date",
"posting_time",
@@ -606,6 +606,7 @@
},
{
"default": "0",
"depends_on": "eval:doc.items.every((item) => !item.pr_detail)",
"fieldname": "update_stock",
"fieldtype": "Check",
"label": "Update Stock",
@@ -1668,7 +1669,7 @@
"idx": 204,
"is_submittable": 1,
"links": [],
"modified": "2026-01-29 21:21:53.051193",
"modified": "2026-02-05 20:45:16.964500",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice",

View File

@@ -1,19 +1,18 @@
{
"actions": [],
"allow_import": 1,
"allow_rename": 1,
"autoname": "naming_series:",
"creation": "2022-01-25 10:29:57.771398",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"customer_section",
"company",
"company_tax_id",
"naming_series",
"customer",
"customer_name",
"tax_id",
"company",
"company_tax_id",
"column_break1",
"posting_date",
"posting_time",
@@ -704,6 +703,7 @@
},
{
"default": "0",
"depends_on": "eval:doc.items.every((item) => !item.dn_detail)",
"fieldname": "update_stock",
"fieldtype": "Check",
"hide_days": 1,
@@ -2306,7 +2306,7 @@
"link_fieldname": "consolidated_invoice"
}
],
"modified": "2026-01-30 16:45:59.682473",
"modified": "2026-02-06 20:43:44.732805",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice",

View File

@@ -232,11 +232,11 @@ def get_report_summary(
def get_chart_data(filters, columns, asset, liability, equity, currency):
labels = [d.get("label") for d in columns[2:]]
labels = [d.get("label") for d in columns[4:]]
asset_data, liability_data, equity_data = [], [], []
for p in columns[2:]:
for p in columns[4:]:
if asset:
asset_data.append(asset[-2].get(p.get("fieldname")))
if liability:

View File

@@ -5,15 +5,16 @@ from collections import OrderedDict
import frappe
from frappe import _, qb, scrub
from frappe.query_builder import Order
from frappe.query_builder import Case, Order
from frappe.query_builder.functions import Coalesce
from frappe.utils import cint, flt, formatdate
from pypika.terms import ExistsCriterion
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
get_dimension_with_children,
)
from erpnext.accounts.report.financial_statements import get_cost_centers_with_children
from erpnext.controllers.queries import get_match_cond
from erpnext.stock.report.stock_ledger.stock_ledger import get_item_group_condition
from erpnext.stock.utils import get_incoming_rate
@@ -176,7 +177,9 @@ def get_data_when_grouped_by_invoice(columns, gross_profit_data, filters, group_
column_names = get_column_names()
# to display item as Item Code: Item Name
columns[0] = "Sales Invoice:Link/Item:300"
columns[0]["fieldname"] = "sales_invoice"
columns[0]["options"] = "Item"
columns[0]["width"] = 300
# removing Item Code and Item Name columns
supplier_master_name = frappe.db.get_single_value("Buying Settings", "supp_master_name")
customer_master_name = frappe.db.get_single_value("Selling Settings", "cust_master_name")
@@ -203,7 +206,11 @@ def get_data_when_grouped_by_invoice(columns, gross_profit_data, filters, group_
data.append(row)
total_gross_profit = total_base_amount - total_buying_amount
total_gross_profit = flt(
total_base_amount + abs(total_buying_amount)
if total_buying_amount < 0
else total_base_amount - total_buying_amount,
)
data.append(
frappe._dict(
{
@@ -215,7 +222,7 @@ def get_data_when_grouped_by_invoice(columns, gross_profit_data, filters, group_
"buying_amount": total_buying_amount,
"gross_profit": total_gross_profit,
"gross_profit_%": flt(
(total_gross_profit / total_base_amount) * 100.0,
(total_gross_profit / abs(total_base_amount)) * 100.0,
cint(frappe.db.get_default("currency_precision")) or 3,
)
if total_base_amount
@@ -248,9 +255,13 @@ def get_data_when_not_grouped_by_invoice(gross_profit_data, filters, group_wise_
data.append(row)
total_gross_profit = total_base_amount - total_buying_amount
total_gross_profit = flt(
total_base_amount + abs(total_buying_amount)
if total_buying_amount < 0
else total_base_amount - total_buying_amount,
)
currency_precision = cint(frappe.db.get_default("currency_precision")) or 3
gross_profit_percent = (total_gross_profit / total_base_amount * 100.0) if total_base_amount else 0
gross_profit_percent = (total_gross_profit / abs(total_base_amount) * 100.0) if total_base_amount else 0
total_row = {
group_columns[0]: "Total",
@@ -581,10 +592,15 @@ class GrossProfitGenerator:
base_amount += row.base_amount
# calculate gross profit
row.gross_profit = flt(row.base_amount - row.buying_amount, self.currency_precision)
row.gross_profit = flt(
row.base_amount + abs(row.buying_amount)
if row.buying_amount < 0
else row.base_amount - row.buying_amount,
self.currency_precision,
)
if row.base_amount:
row.gross_profit_percent = flt(
(row.gross_profit / row.base_amount) * 100.0,
(row.gross_profit / abs(row.base_amount)) * 100.0,
self.currency_precision,
)
else:
@@ -673,9 +689,14 @@ class GrossProfitGenerator:
return new_row
def set_average_gross_profit(self, new_row):
new_row.gross_profit = flt(new_row.base_amount - new_row.buying_amount, self.currency_precision)
new_row.gross_profit = flt(
new_row.base_amount + abs(new_row.buying_amount)
if new_row.buying_amount < 0
else new_row.base_amount - new_row.buying_amount,
self.currency_precision,
)
new_row.gross_profit_percent = (
flt(((new_row.gross_profit / new_row.base_amount) * 100.0), self.currency_precision)
flt(((new_row.gross_profit / abs(new_row.base_amount)) * 100.0), self.currency_precision)
if new_row.base_amount
else 0
)
@@ -851,129 +872,173 @@ class GrossProfitGenerator:
return flt(last_purchase_rate[0][0]) if last_purchase_rate else 0
def load_invoice_items(self):
conditions = ""
if self.filters.company:
conditions += " and `tabSales Invoice`.company = %(company)s"
if self.filters.from_date:
conditions += " and posting_date >= %(from_date)s"
if self.filters.to_date:
conditions += " and posting_date <= %(to_date)s"
self.si_list = []
SalesInvoice = frappe.qb.DocType("Sales Invoice")
base_query = self.prepare_invoice_query()
if self.filters.include_returned_invoices:
conditions += " and (is_return = 0 or (is_return=1 and return_against is null))"
invoice_query = base_query.where(
(SalesInvoice.is_return == 0)
| ((SalesInvoice.is_return == 1) & SalesInvoice.return_against.isnull())
)
else:
conditions += " and is_return = 0"
invoice_query = base_query.where(SalesInvoice.is_return == 0)
if self.filters.item_group:
conditions += f" and {get_item_group_condition(self.filters.item_group)}"
self.si_list += invoice_query.run(as_dict=True)
self.prepare_vouchers_to_ignore()
if self.filters.sales_person:
conditions += """
and exists(select 1
from `tabSales Team` st
where st.parent = `tabSales Invoice`.name
and st.sales_person = %(sales_person)s)
"""
ret_invoice_query = base_query.where(
(SalesInvoice.is_return == 1) & SalesInvoice.return_against.isnotnull()
)
if self.vouchers_to_ignore:
ret_invoice_query = ret_invoice_query.where(
SalesInvoice.return_against.notin(self.vouchers_to_ignore)
)
self.si_list += ret_invoice_query.run(as_dict=True)
def prepare_invoice_query(self):
SalesInvoice = frappe.qb.DocType("Sales Invoice")
SalesInvoiceItem = frappe.qb.DocType("Sales Invoice Item")
Item = frappe.qb.DocType("Item")
SalesTeam = frappe.qb.DocType("Sales Team")
PaymentSchedule = frappe.qb.DocType("Payment Schedule")
query = (
frappe.qb.from_(SalesInvoice)
.join(SalesInvoiceItem)
.on(SalesInvoiceItem.parent == SalesInvoice.name)
.join(Item)
.on(Item.name == SalesInvoiceItem.item_code)
.where((SalesInvoice.docstatus == 1) & (SalesInvoice.is_opening != "Yes"))
)
query = self.apply_common_filters(query, SalesInvoice, SalesInvoiceItem, SalesTeam, Item)
query = query.select(
SalesInvoiceItem.parenttype,
SalesInvoiceItem.parent,
SalesInvoice.posting_date,
SalesInvoice.posting_time,
SalesInvoice.project,
SalesInvoice.update_stock,
SalesInvoice.customer,
SalesInvoice.customer_group,
SalesInvoice.customer_name,
SalesInvoice.territory,
SalesInvoiceItem.item_code,
SalesInvoice.base_net_total.as_("invoice_base_net_total"),
SalesInvoiceItem.item_name,
SalesInvoiceItem.description,
SalesInvoiceItem.warehouse,
SalesInvoiceItem.item_group,
SalesInvoiceItem.brand,
SalesInvoiceItem.so_detail,
SalesInvoiceItem.sales_order,
SalesInvoiceItem.dn_detail,
SalesInvoiceItem.delivery_note,
SalesInvoiceItem.stock_qty.as_("qty"),
SalesInvoiceItem.base_net_rate,
SalesInvoiceItem.base_net_amount,
SalesInvoiceItem.name.as_("item_row"),
SalesInvoice.is_return,
SalesInvoiceItem.cost_center,
SalesInvoiceItem.serial_and_batch_bundle,
)
if self.filters.group_by == "Sales Person":
sales_person_cols = """, sales.sales_person,
sales.allocated_percentage * `tabSales Invoice Item`.base_net_amount / 100 as allocated_amount,
sales.incentives
"""
sales_team_table = "left join `tabSales Team` sales on sales.parent = `tabSales Invoice`.name"
else:
sales_person_cols = ""
sales_team_table = ""
query = query.select(
SalesTeam.sales_person,
(SalesTeam.allocated_percentage * SalesInvoiceItem.base_net_amount / 100).as_(
"allocated_amount"
),
SalesTeam.incentives,
)
query = query.left_join(SalesTeam).on(SalesTeam.parent == SalesInvoice.name)
if self.filters.group_by == "Payment Term":
payment_term_cols = """,if(`tabSales Invoice`.is_return = 1,
'{}',
coalesce(schedule.payment_term, '{}')) as payment_term,
schedule.invoice_portion,
schedule.payment_amount """.format(_("Sales Return"), _("No Terms"))
payment_term_table = """ left join `tabPayment Schedule` schedule on schedule.parent = `tabSales Invoice`.name and
`tabSales Invoice`.is_return = 0 """
else:
payment_term_cols = ""
payment_term_table = ""
query = query.select(
Case()
.when(SalesInvoice.is_return == 1, _("Sales Return"))
.else_(Coalesce(PaymentSchedule.payment_term, _("No Terms")))
.as_("payment_term"),
PaymentSchedule.invoice_portion,
PaymentSchedule.payment_amount,
)
if self.filters.get("sales_invoice"):
conditions += " and `tabSales Invoice`.name = %(sales_invoice)s"
query = query.left_join(PaymentSchedule).on(
(PaymentSchedule.parent == SalesInvoice.name) & (SalesInvoice.is_return == 0)
)
if self.filters.get("item_code"):
conditions += " and `tabSales Invoice Item`.item_code = %(item_code)s"
query = query.orderby(SalesInvoice.posting_date, order=Order.desc).orderby(
SalesInvoice.posting_time, order=Order.desc
)
if self.filters.get("cost_center"):
return query
def apply_common_filters(self, query, SalesInvoice, SalesInvoiceItem, SalesTeam, Item):
if self.filters.company:
query = query.where(SalesInvoice.company == self.filters.company)
if self.filters.from_date:
query = query.where(SalesInvoice.posting_date >= self.filters.from_date)
if self.filters.to_date:
query = query.where(SalesInvoice.posting_date <= self.filters.to_date)
if self.filters.item_group:
query = query.where(get_item_group_condition(self.filters.item_group, Item))
if self.filters.sales_person:
query = query.where(
ExistsCriterion(
frappe.qb.from_(SalesTeam)
.select(1)
.where(
(SalesTeam.parent == SalesInvoice.name)
& (SalesTeam.sales_person == self.filters.sales_person)
)
)
)
if self.filters.sales_invoice:
query = query.where(SalesInvoice.name == self.filters.sales_invoice)
if self.filters.item_code:
query = query.where(SalesInvoiceItem.item_code == self.filters.item_code)
if self.filters.cost_center:
self.filters.cost_center = frappe.parse_json(self.filters.get("cost_center"))
self.filters.cost_center = get_cost_centers_with_children(self.filters.cost_center)
conditions += " and `tabSales Invoice Item`.cost_center in %(cost_center)s"
query = query.where(SalesInvoiceItem.cost_center.isin(self.filters.cost_center))
if self.filters.get("project"):
if self.filters.project:
self.filters.project = frappe.parse_json(self.filters.get("project"))
conditions += " and `tabSales Invoice Item`.project in %(project)s"
query = query.where(SalesInvoiceItem.project.isin(self.filters.project))
accounting_dimensions = get_accounting_dimensions(as_list=False)
if accounting_dimensions:
for dimension in accounting_dimensions:
if self.filters.get(dimension.fieldname):
if frappe.get_cached_value("DocType", dimension.document_type, "is_tree"):
self.filters[dimension.fieldname] = get_dimension_with_children(
dimension.document_type, self.filters.get(dimension.fieldname)
)
conditions += (
f" and `tabSales Invoice Item`.{dimension.fieldname} in %({dimension.fieldname})s"
)
else:
conditions += (
f" and `tabSales Invoice Item`.{dimension.fieldname} in %({dimension.fieldname})s"
)
for dim in get_accounting_dimensions(as_list=False) or []:
if self.filters.get(dim.fieldname):
if frappe.get_cached_value("DocType", dim.document_type, "is_tree"):
self.filters[dim.fieldname] = get_dimension_with_children(
dim.document_type, self.filters.get(dim.fieldname)
)
query = query.where(SalesInvoiceItem[dim.fieldname].isin(self.filters[dim.fieldname]))
if self.filters.get("warehouse"):
warehouse_details = frappe.db.get_value(
"Warehouse", self.filters.get("warehouse"), ["lft", "rgt"], as_dict=1
if self.filters.warehouse:
lft, rgt = frappe.db.get_value("Warehouse", self.filters.warehouse, ["lft", "rgt"])
WH = frappe.qb.DocType("Warehouse")
query = query.where(
SalesInvoiceItem.warehouse.isin(
frappe.qb.from_(WH).select(WH.name).where((WH.lft >= lft) & (WH.rgt <= rgt))
)
)
if warehouse_details:
conditions += f" and `tabSales Invoice Item`.warehouse in (select name from `tabWarehouse` wh where wh.lft >= {warehouse_details.lft} and wh.rgt <= {warehouse_details.rgt} and warehouse = wh.name)"
self.si_list = frappe.db.sql(
"""
select
`tabSales Invoice Item`.parenttype, `tabSales Invoice Item`.parent,
`tabSales Invoice`.posting_date, `tabSales Invoice`.posting_time,
`tabSales Invoice`.project, `tabSales Invoice`.update_stock,
`tabSales Invoice`.customer, `tabSales Invoice`.customer_group, `tabSales Invoice`.customer_name,
`tabSales Invoice`.territory, `tabSales Invoice Item`.item_code,
`tabSales Invoice`.base_net_total as "invoice_base_net_total",
`tabSales Invoice Item`.item_name, `tabSales Invoice Item`.description,
`tabSales Invoice Item`.warehouse, `tabSales Invoice Item`.item_group,
`tabSales Invoice Item`.brand, `tabSales Invoice Item`.so_detail,
`tabSales Invoice Item`.sales_order, `tabSales Invoice Item`.dn_detail,
`tabSales Invoice Item`.delivery_note, `tabSales Invoice Item`.stock_qty as qty,
`tabSales Invoice Item`.base_net_rate, `tabSales Invoice Item`.base_net_amount,
`tabSales Invoice Item`.name as "item_row", `tabSales Invoice`.is_return,
`tabSales Invoice Item`.cost_center, `tabSales Invoice Item`.serial_and_batch_bundle
{sales_person_cols}
{payment_term_cols}
from
`tabSales Invoice` inner join `tabSales Invoice Item`
on `tabSales Invoice Item`.parent = `tabSales Invoice`.name
join `tabItem` item on item.name = `tabSales Invoice Item`.item_code
{sales_team_table}
{payment_term_table}
where
`tabSales Invoice`.docstatus=1 and `tabSales Invoice`.is_opening!='Yes' {conditions} {match_cond}
order by
`tabSales Invoice`.posting_date desc, `tabSales Invoice`.posting_time desc""".format(
conditions=conditions,
sales_person_cols=sales_person_cols,
sales_team_table=sales_team_table,
payment_term_cols=payment_term_cols,
payment_term_table=payment_term_table,
match_cond=get_match_cond("Sales Invoice"),
),
self.filters,
as_dict=1,
)
return query
def prepare_vouchers_to_ignore(self):
self.vouchers_to_ignore = tuple(row["parent"] for row in self.si_list)
def get_delivery_notes(self):
self.delivery_notes = frappe._dict({})

View File

@@ -470,7 +470,7 @@ class TestGrossProfit(IntegrationTestCase):
"selling_amount": -100.0,
"buying_amount": 0.0,
"gross_profit": -100.0,
"gross_profit_%": 100.0,
"gross_profit_%": -100.0,
}
gp_entry = [x for x in data if x.parent_invoice == sinv.name]
report_output = {k: v for k, v in gp_entry[0].items() if k in expected_entry}
@@ -649,21 +649,24 @@ class TestGrossProfit(IntegrationTestCase):
def test_profit_for_later_period_return(self):
month_start_date, month_end_date = get_first_day(nowdate()), get_last_day(nowdate())
sales_inv_date = month_start_date
return_inv_date = add_days(month_end_date, 1)
# create sales invoice on month start date
sinv = self.create_sales_invoice(qty=1, rate=100, do_not_save=True, do_not_submit=True)
sinv.set_posting_time = 1
sinv.posting_date = month_start_date
sinv.posting_date = sales_inv_date
sinv.save().submit()
# create credit note on next month start date
cr_note = make_sales_return(sinv.name)
cr_note.set_posting_time = 1
cr_note.posting_date = add_days(month_end_date, 1)
cr_note.posting_date = return_inv_date
cr_note.save().submit()
# apply filters for invoiced period
filters = frappe._dict(
company=self.company, from_date=month_start_date, to_date=month_end_date, group_by="Invoice"
company=self.company, from_date=month_start_date, to_date=month_start_date, group_by="Invoice"
)
_, data = execute(filters=filters)
@@ -675,7 +678,7 @@ class TestGrossProfit(IntegrationTestCase):
self.assertEqual(total.get("gross_profit_%"), 100.0)
# extend filters upto returned period
filters.update(to_date=add_days(month_end_date, 1))
filters.update({"to_date": return_inv_date})
_, data = execute(filters=filters)
total = data[-1]
@@ -684,3 +687,63 @@ class TestGrossProfit(IntegrationTestCase):
self.assertEqual(total.buying_amount, 0.0)
self.assertEqual(total.gross_profit, 0.0)
self.assertEqual(total.get("gross_profit_%"), 0.0)
# apply filters only on returned period
filters.update({"from_date": return_inv_date, "to_date": return_inv_date})
_, data = execute(filters=filters)
total = data[-1]
self.assertEqual(total.selling_amount, -100.0)
self.assertEqual(total.buying_amount, 0.0)
self.assertEqual(total.gross_profit, -100.0)
self.assertEqual(total.get("gross_profit_%"), -100.0)
def test_sales_person_wise_gross_profit(self):
sales_person = make_sales_person("_Test Sales Person")
posting_date = get_first_day(nowdate())
qty = 10
rate = 100
sinv = self.create_sales_invoice(qty=qty, rate=rate, do_not_save=True, do_not_submit=True)
sinv.set_posting_time = 1
sinv.posting_date = posting_date
sinv.append(
"sales_team",
{
"sales_person": sales_person.name,
"allocated_percentage": 100,
"allocated_amount": 1000.0,
"commission_rate": 5,
"incentives": 5,
},
)
sinv.save().submit()
filters = frappe._dict(
company=self.company, from_date=posting_date, to_date=posting_date, group_by="Sales Person"
)
_, data = execute(filters=filters)
total = data[-1]
self.assertEqual(total[5], 1000.0)
self.assertEqual(total[6], 0.0)
self.assertEqual(total[7], 1000.0)
self.assertEqual(total[8], 100.0)
def make_sales_person(sales_person_name="_Test Sales Person"):
if not frappe.db.exists("Sales Person", {"sales_person_name": sales_person_name}):
sales_person_doc = frappe.get_doc(
{
"doctype": "Sales Person",
"is_group": 0,
"parent_sales_person": "Sales Team",
"sales_person_name": sales_person_name,
}
).insert(ignore_permissions=True)
else:
sales_person_doc = frappe.get_doc("Sales Person", {"sales_person_name": sales_person_name})
return sales_person_doc

View File

@@ -154,17 +154,11 @@ def get_columns(filters):
"width": 60,
},
{
"label": _("Total Amount"),
"label": _("Taxable Amount"),
"fieldname": "total_amount",
"fieldtype": "Currency",
"width": 120,
},
{
"label": _("Base Total"),
"fieldname": "base_total",
"fieldtype": "Currency",
"width": 120,
},
{
"label": _("Tax Amount"),
"fieldname": "tax_amount",
@@ -172,10 +166,16 @@ def get_columns(filters):
"width": 120,
},
{
"label": _("Grand Total"),
"label": _("Grand Total (Company Currency)"),
"fieldname": "base_total",
"fieldtype": "Currency",
"width": 150,
},
{
"label": _("Grand Total (Transaction Currency)"),
"fieldname": "grand_total",
"fieldtype": "Currency",
"width": 120,
"width": 170,
},
{
"label": _("Reference Date"),

View File

@@ -106,7 +106,7 @@ def get_columns(filters):
"width": 120,
},
{
"label": _("Total Amount"),
"label": _("Total Taxable Amount"),
"fieldname": "total_amount",
"fieldtype": "Float",
"width": 120,

View File

@@ -513,12 +513,14 @@ frappe.ui.form.on("Asset", {
},
is_composite_asset: function (frm) {
if (frm.doc.is_composite_asset) {
frm.set_value("net_purchase_amount", 0);
} else {
frm.set_df_property("net_purchase_amount", "read_only", 0);
if (frm.doc.docstatus == 0) {
if (frm.doc.is_composite_asset) {
frm.set_value("net_purchase_amount", 0);
} else {
frm.set_df_property("net_purchase_amount", "read_only", 0);
}
frm.trigger("toggle_reference_doc");
}
frm.trigger("toggle_reference_doc");
},
make_sales_invoice: function (frm) {

View File

@@ -5,7 +5,7 @@
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import cstr, get_link_to_form
from frappe.utils import cstr, get_datetime, get_link_to_form
from erpnext.assets.doctype.asset_activity.asset_activity import add_asset_activity
@@ -34,6 +34,7 @@ class AssetMovement(Document):
for d in self.assets:
self.validate_asset(d)
self.validate_movement(d)
self.validate_transaction_date(d)
def validate_asset(self, d):
status, company = frappe.db.get_value("Asset", d.asset, ["status", "company"])
@@ -51,6 +52,18 @@ class AssetMovement(Document):
else:
self.validate_employee(d)
def validate_transaction_date(self, d):
previous_movement_date = frappe.db.get_value(
"Asset Movement",
[["Asset Movement Item", "asset", "=", d.asset], ["docstatus", "=", 1]],
"transaction_date",
order_by="transaction_date desc",
)
if previous_movement_date and get_datetime(previous_movement_date) > get_datetime(
self.transaction_date
):
frappe.throw(_("Transaction date can't be earlier than previous movement date"))
def validate_location_and_employee(self, d):
self.validate_location(d)
self.validate_employee(d)

View File

@@ -4,9 +4,9 @@ import unittest
import frappe
from frappe.tests import IntegrationTestCase
from frappe.utils import now
from frappe.utils import add_days, now
from erpnext.assets.doctype.asset.test_asset import create_asset_data
from erpnext.assets.doctype.asset.test_asset import create_asset, create_asset_data
from erpnext.setup.doctype.employee.test_employee import make_employee
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
@@ -147,6 +147,33 @@ class TestAssetMovement(IntegrationTestCase):
movement1.cancel()
self.assertEqual(frappe.db.get_value("Asset", asset.name, "location"), "Test Location")
def test_movement_transaction_date(self):
asset = create_asset(item_code="Macbook Pro", do_not_save=1)
asset.save().submit()
if not frappe.db.exists("Location", "Test Location 2"):
frappe.get_doc({"doctype": "Location", "location_name": "Test Location 2"}).insert()
asset_creation_date = frappe.db.get_value(
"Asset Movement",
[["Asset Movement Item", "asset", "=", asset.name], ["docstatus", "=", 1]],
"transaction_date",
)
asset_movement = create_asset_movement(
purpose="Transfer",
company=asset.company,
assets=[
{
"asset": asset.name,
"source_location": "Test Location",
"target_location": "Test Location 2",
}
],
transaction_date=add_days(asset_creation_date, -1),
do_not_save=True,
)
self.assertRaises(frappe.ValidationError, asset_movement.save)
def create_asset_movement(**args):
args = frappe._dict(args)
@@ -165,9 +192,10 @@ def create_asset_movement(**args):
"reference_name": args.reference_name,
}
)
movement.insert()
movement.submit()
if not args.do_not_save:
movement.insert(ignore_if_duplicate=True)
if not args.do_not_submit:
movement.submit()
return movement

View File

@@ -9,10 +9,9 @@
"engine": "InnoDB",
"field_order": [
"supplier_section",
"company",
"title",
"naming_series",
"supplier",
"supplier_name",
"order_confirmation_no",
"order_confirmation_date",
"get_items_from_open_material_requests",
@@ -21,8 +20,9 @@
"transaction_date",
"schedule_date",
"column_break1",
"company",
"supplier",
"is_subcontracted",
"supplier_name",
"has_unit_price_items",
"supplier_warehouse",
"amended_from",
@@ -1310,7 +1310,7 @@
"idx": 105,
"is_submittable": 1,
"links": [],
"modified": "2026-01-29 21:22:54.323838",
"modified": "2026-02-03 14:44:55.192192",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Order",

View File

@@ -135,14 +135,6 @@ frappe.ui.form.on("Supplier", {
// indicators
erpnext.utils.set_party_dashboard_indicators(frm);
}
frm.set_query("supplier_group", () => {
return {
filters: {
is_group: 0,
},
};
});
},
get_supplier_group_details: function (frm) {
frappe.call({

View File

@@ -167,6 +167,7 @@
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Supplier Group",
"link_filters": "[[\"Supplier Group\",\"is_group\",\"=\",0]]",
"oldfieldname": "supplier_type",
"oldfieldtype": "Link",
"options": "Supplier Group"
@@ -500,7 +501,7 @@
"link_fieldname": "party"
}
],
"modified": "2026-01-16 15:56:31.139206",
"modified": "2026-02-06 12:58:01.398824",
"modified_by": "Administrator",
"module": "Buying",
"name": "Supplier",

View File

@@ -38,18 +38,18 @@ class EmailCampaign(Document):
def set_date(self):
if getdate(self.start_date) < getdate(today()):
frappe.throw(_("Start Date cannot be before the current date"))
# set the end date as start date + max(send after days) in campaign schedule
send_after_days = []
campaign = frappe.get_doc("Campaign", self.campaign_name)
for entry in campaign.get("campaign_schedules"):
send_after_days.append(entry.send_after_days)
try:
self.end_date = add_days(getdate(self.start_date), max(send_after_days))
except ValueError:
campaign = frappe.get_cached_doc("Campaign", self.campaign_name)
send_after_days = [entry.send_after_days for entry in campaign.get("campaign_schedules")]
if not send_after_days:
frappe.throw(
_("Please set up the Campaign Schedule in the Campaign {0}").format(self.campaign_name)
)
self.end_date = add_days(getdate(self.start_date), max(send_after_days))
def validate_lead(self):
lead_email_id = frappe.db.get_value("Lead", self.recipient, "email_id")
if not lead_email_id:
@@ -77,58 +77,128 @@ class EmailCampaign(Document):
start_date = getdate(self.start_date)
end_date = getdate(self.end_date)
today_date = getdate(today())
if start_date > today_date:
self.db_set("status", "Scheduled", update_modified=False)
new_status = "Scheduled"
elif end_date >= today_date:
self.db_set("status", "In Progress", update_modified=False)
elif end_date < today_date:
self.db_set("status", "Completed", update_modified=False)
new_status = "In Progress"
else:
new_status = "Completed"
if self.status != new_status:
self.db_set("status", new_status, update_modified=False)
# called through hooks to send campaign mails to leads
def send_email_to_leads_or_contacts():
today_date = getdate(today())
# Get all active email campaigns in a single query
email_campaigns = frappe.get_all(
"Email Campaign", filters={"status": ("not in", ["Unsubscribed", "Completed", "Scheduled"])}
"Email Campaign",
filters={"status": "In Progress"},
fields=["name", "campaign_name", "email_campaign_for", "recipient", "start_date", "sender"],
)
for camp in email_campaigns:
email_campaign = frappe.get_doc("Email Campaign", camp.name)
campaign = frappe.get_cached_doc("Campaign", email_campaign.campaign_name)
if not email_campaigns:
return
# Process each email campaign
for email_campaign in email_campaigns:
try:
campaign = frappe.get_cached_doc("Campaign", email_campaign.campaign_name)
except frappe.DoesNotExistError:
frappe.log_error(
title=_("Email Campaign Error"),
message=_("Campaign {0} not found").format(email_campaign.campaign_name),
)
continue
# Find schedules that match today
for entry in campaign.get("campaign_schedules"):
scheduled_date = add_days(email_campaign.get("start_date"), entry.get("send_after_days"))
if scheduled_date == getdate(today()):
send_mail(entry, email_campaign)
try:
scheduled_date = add_days(getdate(email_campaign.start_date), entry.get("send_after_days"))
if scheduled_date == today_date:
send_mail(entry, email_campaign)
except Exception:
frappe.log_error(
title=_("Email Campaign Send Error"),
message=_("Failed to send email for campaign {0} to {1}").format(
email_campaign.name, email_campaign.recipient
),
)
def send_mail(entry, email_campaign):
recipient_list = []
if email_campaign.email_campaign_for == "Email Group":
for member in frappe.db.get_list(
"Email Group Member", filters={"email_group": email_campaign.get("recipient")}, fields=["email"]
):
recipient_list.append(member["email"])
campaign_for = email_campaign.get("email_campaign_for")
recipient = email_campaign.get("recipient")
sender_user = email_campaign.get("sender")
campaign_name = email_campaign.get("name")
# Get recipient emails
if campaign_for == "Email Group":
recipient_list = frappe.get_all(
"Email Group Member",
filters={"email_group": recipient, "unsubscribed": 0},
pluck="email",
)
else:
recipient_list.append(
frappe.db.get_value(
email_campaign.email_campaign_for, email_campaign.get("recipient"), "email_id"
email_id = frappe.db.get_value(campaign_for, recipient, "email_id")
if not email_id:
frappe.log_error(
title=_("Email Campaign Error"),
message=_("No email found for {0} {1}").format(campaign_for, recipient),
)
return
recipient_list = [email_id]
if not recipient_list:
frappe.log_error(
title=_("Email Campaign Error"),
message=_("No recipients found for campaign {0}").format(campaign_name),
)
return
# Get email template and sender
email_template = frappe.get_cached_doc("Email Template", entry.get("email_template"))
sender = frappe.db.get_value("User", sender_user, "email") if sender_user else None
# Build context for template rendering
if campaign_for != "Email Group":
context = {"doc": frappe.get_doc(campaign_for, recipient)}
else:
# For email groups, use the email group document as context
context = {"doc": frappe.get_doc("Email Group", recipient)}
# Render template
subject = frappe.render_template(email_template.get("subject"), context)
content = frappe.render_template(email_template.response_, context)
try:
comm = make(
doctype="Email Campaign",
name=campaign_name,
subject=subject,
content=content,
sender=sender,
recipients=recipient_list,
communication_medium="Email",
sent_or_received="Sent",
send_email=False,
email_template=email_template.name,
)
email_template = frappe.get_doc("Email Template", entry.get("email_template"))
sender = frappe.db.get_value("User", email_campaign.get("sender"), "email")
context = {"doc": frappe.get_doc(email_campaign.email_campaign_for, email_campaign.recipient)}
# send mail and link communication to document
comm = make(
doctype="Email Campaign",
name=email_campaign.name,
subject=frappe.render_template(email_template.get("subject"), context),
content=frappe.render_template(email_template.response_, context),
sender=sender,
bcc=recipient_list,
communication_medium="Email",
sent_or_received="Sent",
send_email=True,
email_template=email_template.name,
)
frappe.sendmail(
recipients=recipient_list,
subject=subject,
content=content,
sender=sender,
communication=comm["name"],
queue_separately=True,
)
except Exception:
frappe.log_error(title="Email Campaign Failed.")
return comm
@@ -140,7 +210,12 @@ def unsubscribe_recipient(unsubscribe, method):
# called through hooks to update email campaign status daily
def set_email_campaign_status():
email_campaigns = frappe.get_all("Email Campaign", filters={"status": ("!=", "Unsubscribed")})
for entry in email_campaigns:
email_campaign = frappe.get_doc("Email Campaign", entry.name)
email_campaigns = frappe.get_all(
"Email Campaign",
filters={"status": ("!=", "Unsubscribed")},
pluck="name",
)
for name in email_campaigns:
email_campaign = frappe.get_doc("Email Campaign", name)
email_campaign.update_status()

View File

@@ -534,6 +534,7 @@ accounting_dimension_doctypes = [
"Purchase Order Item",
"Sales Order Item",
"Journal Entry Account",
"Journal Entry Template Account",
"Material Request Item",
"Delivery Note Item",
"Purchase Receipt Item",

File diff suppressed because it is too large Load Diff

View File

@@ -580,9 +580,12 @@ frappe.ui.form.on("BOM", {
frappe.ui.form.on("BOM Operation", {
finished_good(frm, cdt, cdn) {
let row = locals[cdt][cdn];
if (row.finished_good === frm.doc.item) {
frappe.model.set_value(row.doctype, row.name, "is_final_finished_good", 1);
}
frappe.model.set_value(
row.doctype,
row.name,
"is_final_finished_good",
row.finished_good === frm.doc.item
);
},
bom_no(frm, cdt, cdn) {

View File

@@ -296,6 +296,57 @@ class BOM(WebsiteGenerator):
self.set_process_loss_qty()
self.validate_scrap_items()
self.set_default_uom()
self.validate_semi_finished_goods()
if self.docstatus == 1:
self.validate_raw_materials_of_operation()
def validate_semi_finished_goods(self):
if not self.track_semi_finished_goods or not self.operations:
return
fg_items = []
for row in self.operations:
if not row.is_final_finished_good:
continue
fg_items.append(row.finished_good)
if not fg_items:
frappe.throw(
_(
"Since you have enabled 'Track Semi Finished Goods', at least one operation must have 'Is Final Finished Good' checked. For that set the FG / Semi FG Item as {0} against an operation."
).format(bold(self.item)),
)
if fg_items and len(fg_items) > 1:
frappe.throw(
_(
"Only one operation can have 'Is Final Finished Good' checked when 'Track Semi Finished Goods' is enabled."
),
)
def validate_raw_materials_of_operation(self):
if not self.track_semi_finished_goods or not self.operations:
return
operation_idx_with_no_rm = {}
for row in self.operations:
if row.bom_no:
continue
operation_idx_with_no_rm[row.idx] = row
for row in self.items:
if row.operation_row_id and row.operation_row_id in operation_idx_with_no_rm:
del operation_idx_with_no_rm[row.operation_row_id]
for idx, row in operation_idx_with_no_rm.items():
frappe.throw(
_("For operation {0} at row {1}, please add raw materials or set a BOM against it.").format(
bold(row.operation), idx
),
)
def set_default_uom(self):
if not self.get("items"):

View File

@@ -23,6 +23,10 @@ from frappe.utils import (
time_diff_in_hours,
)
from erpnext.controllers.stock_controller import (
QualityInspectionNotSubmittedError,
QualityInspectionRejectedError,
)
from erpnext.manufacturing.doctype.bom.bom import add_additional_cost, get_bom_items_as_dict
from erpnext.manufacturing.doctype.manufacturing_settings.manufacturing_settings import (
get_mins_between_operations,
@@ -167,6 +171,27 @@ class JobCard(Document):
self.validate_work_order()
self.set_employees()
if self.docstatus == 1:
self.validate_semi_finished_goods()
def validate_semi_finished_goods(self):
if not self.track_semi_finished_goods:
return
if self.items and not self.transferred_qty and not self.skip_material_transfer:
frappe.throw(
_(
"Materials needs to be transferred to the work in progress warehouse for the job card {0}"
).format(self.name)
)
if self.docstatus == 1 and not self.total_completed_qty:
frappe.throw(
_(
"Total Completed Qty is required for Job Card {0}, please start and complete the job card before submission"
).format(self.name)
)
def on_update(self):
self.validate_job_card_qty()
@@ -732,6 +757,7 @@ class JobCard(Document):
self.set_process_loss()
def on_submit(self):
self.validate_inspection()
self.validate_transfer_qty()
self.validate_job_card()
self.update_work_order()
@@ -741,6 +767,66 @@ class JobCard(Document):
self.update_work_order()
self.set_transferred_qty()
def validate_inspection(self):
action_submit, action_reject = frappe.get_single_value(
"Stock Settings",
["action_if_quality_inspection_is_not_submitted", "action_if_quality_inspection_is_rejected"],
)
item = self.finished_good or self.production_item
bom_inspection_required = frappe.db.get_value(
"BOM", self.semi_fg_bom or self.bom_no, "inspection_required"
)
if bom_inspection_required:
if not self.quality_inspection:
frappe.throw(
_(
"Quality Inspection is required for the item {0} before completing the job card {1}"
).format(get_link_to_form("Item", item), bold(self.name))
)
qa_status, docstatus = frappe.db.get_value(
"Quality Inspection", self.quality_inspection, ["status", "docstatus"]
)
if docstatus != 1:
if action_submit == "Stop":
frappe.throw(
_("Quality Inspection {0} is not submitted for the item: {1}").format(
get_link_to_form("Quality Inspection", self.quality_inspection),
get_link_to_form("Item", item),
),
title=_("Inspection Submission"),
exc=QualityInspectionNotSubmittedError,
)
else:
frappe.msgprint(
_("Quality Inspection {0} is not submitted for the item: {1}").format(
get_link_to_form("Quality Inspection", self.quality_inspection),
get_link_to_form("Item", item),
),
alert=True,
indicator="orange",
)
elif qa_status == "Rejected":
if action_reject == "Stop":
frappe.throw(
_("Quality Inspection {0} is rejected for the item: {1}").format(
get_link_to_form("Quality Inspection", self.quality_inspection),
get_link_to_form("Item", item),
),
title=_("Inspection Rejected"),
exc=QualityInspectionRejectedError,
)
else:
frappe.msgprint(
_("Quality Inspection {0} is rejected for the item: {1}").format(
get_link_to_form("Quality Inspection", self.quality_inspection),
get_link_to_form("Item", item),
),
alert=True,
indicator="orange",
)
def validate_transfer_qty(self):
if (
not self.finished_good
@@ -1251,10 +1337,26 @@ class JobCard(Document):
frappe.db.set_value("Workstation", self.workstation, "status", status)
def add_time_logs(self, **kwargs):
row = None
kwargs = frappe._dict(kwargs)
if not kwargs.employees and kwargs.to_time:
for row in self.time_logs:
if not row.to_time and row.from_time:
row.to_time = kwargs.to_time
row.time_in_mins = time_diff_in_minutes(row.to_time, row.from_time)
if kwargs.completed_qty:
row.completed_qty = kwargs.completed_qty
row.db_update()
else:
self.add_time_logs_for_employess(kwargs)
self.validate_time_logs(save=True)
self.save()
def add_time_logs_for_employess(self, kwargs):
row = None
update_status = False
for employee in kwargs.employees:
kwargs.employee = employee.get("employee")
if kwargs.from_time and not kwargs.to_time:
@@ -1290,9 +1392,6 @@ class JobCard(Document):
self.set_status(update_status=update_status)
self.validate_time_logs(save=True)
self.save()
def update_workstation_status(self):
status_map = {
"Open": "Off",
@@ -1341,6 +1440,9 @@ class JobCard(Document):
employees=self.employee,
sub_operation=kwargs.get("sub_operation"),
)
if self.docstatus == 1:
self.update_work_order()
else:
self.add_time_logs(completed_qty=kwargs.qty, employees=self.employee)
self.save()

View File

@@ -20,8 +20,9 @@ from erpnext.manufacturing.doctype.job_card.job_card import (
make_stock_entry as make_stock_entry_from_jc,
)
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
from erpnext.manufacturing.doctype.work_order.work_order import WorkOrder
from erpnext.manufacturing.doctype.work_order.work_order import WorkOrder, make_work_order
from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation
from erpnext.stock.doctype.item.test_item import create_item
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.tests.utils import ERPNextTestSuite
@@ -73,6 +74,68 @@ class TestJobCard(ERPNextTestSuite):
def tearDown(self):
frappe.db.rollback()
def test_quality_inspection_mandatory_check(self):
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
raw = create_item("Fabric-Raw")
cut_fg = create_item("Cut-Fabric-SFG")
stitch_fg = create_item("Stitched-TShirt-SFG")
final = create_item("Finished-TShirt")
row = {"operation": "Cutting", "workstation": "_Test Workstation 1"}
cutting = make_operation(row)
stitching = make_operation({"operation": "Stitching", "workstation": "_Test Workstation 1"})
ironing = make_operation({"operation": "Ironing", "workstation": "_Test Workstation 1"})
cut_bom = create_semi_fg_bom(cut_fg.name, raw.name, inspection_required=1)
stitch_bom = create_semi_fg_bom(stitch_fg.name, cut_fg.name, inspection_required=0)
final_bom = frappe.new_doc(
"BOM", item=final.name, quantity=1, with_operations=1, track_semi_finished_goods=1
)
final_bom.append("items", {"item_code": raw.name, "qty": 1})
final_bom.append(
"operations",
{
"operation": cutting.name,
"workstation": "_Test Workstation 1",
"bom_no": cut_bom,
"skip_material_transfer": 1,
},
)
final_bom.append(
"operations",
{
"operation": stitching.name,
"workstation": "_Test Workstation 1",
"bom_no": stitch_bom,
"skip_material_transfer": 1,
},
)
final_bom.append(
"operations",
{
"operation": ironing.name,
"workstation": "_Test Workstation 1",
"is_final_finished_good": 1,
"bom_no": final_bom.name,
"skip_material_transfer": 1,
},
)
final_bom.append("items", {"item_code": stitch_fg.name, "qty": 1, "operation_row_id": 3})
final_bom.submit()
work_order = make_work_order(final_bom.name, final.name, 1, variant_items=[], use_multi_level_bom=0)
work_order.wip_warehouse = "Work In Progress - WP"
work_order.fg_warehouse = "Finished Goods - WP"
work_order.scrap_warehouse = "All Warehouses - WP"
for operation in work_order.operations:
operation.time_in_mins = 60
work_order.submit()
job_card = frappe.get_all("Job Card", filters={"work_order": work_order.name, "operation": "Cutting"})
job_card_doc = frappe.get_doc("Job Card", job_card[0].name)
self.assertRaises(frappe.ValidationError, job_card_doc.submit)
def test_job_card_operations(self):
job_cards = frappe.get_all(
"Job Card", filters={"work_order": self.work_order.name}, fields=["operation_id", "name"]
@@ -871,3 +934,13 @@ def make_wo_with_transfer_against_jc():
work_order.submit()
return work_order
def create_semi_fg_bom(semi_fg_item, raw_item, inspection_required):
bom = frappe.new_doc("BOM")
bom.item = semi_fg_item
bom.quantity = 1
bom.inspection_required = inspection_required
bom.append("items", {"item_code": raw_item, "qty": 1})
bom.submit()
return bom.name

View File

@@ -497,7 +497,7 @@ class ProductionPlan(Document):
item_details = get_item_details(data.item_code, throw=False)
if self.combine_items:
bom_no = item_details.bom_no
bom_no = item_details.get("bom_no")
if data.get("bom_no"):
bom_no = data.get("bom_no")

View File

@@ -809,7 +809,7 @@ erpnext.work_order = {
}
}
if (frm.doc.status != "Stopped") {
if (frm.doc.status != "Stopped" && !frm.doc.track_semi_finished_goods) {
// If "Material Consumption is check in Manufacturing Settings, allow Material Consumption
if (frm.doc.__onload && frm.doc.__onload.material_consumption == 1) {
if (flt(doc.material_transferred_for_manufacturing) > 0 || frm.doc.skip_transfer) {

View File

@@ -248,6 +248,16 @@ class WorkOrder(Document):
if self.is_new() and frappe.db.get_single_value("Stock Settings", "auto_reserve_stock"):
self.reserve_stock = 1
def before_save(self):
self.set_skip_transfer_for_operations()
def set_skip_transfer_for_operations(self):
if not self.track_semi_finished_goods:
return
for op in self.operations:
op.skip_material_transfer = self.skip_transfer
def validate_operations_sequence(self):
if all([not op.sequence_id for op in self.operations]):
for op in self.operations:
@@ -1599,6 +1609,7 @@ class WorkOrder(Document):
"item_code": row.item_code,
"voucher_detail_no": row.name,
"warehouse": row.source_warehouse,
"status": ("not in", ["Closed", "Cancelled", "Completed"]),
},
pluck="name",
):
@@ -1807,24 +1818,10 @@ class WorkOrder(Document):
elif stock_entry.job_card:
# Reserve the final product for the job card.
finished_good = frappe.db.get_value("Job Card", stock_entry.job_card, "finished_good")
if finished_good == self.production_item:
return
for row in stock_entry.items:
if row.item_code == finished_good:
item_details = [
frappe._dict(
{
"item_code": row.item_code,
"stock_qty": row.qty,
"stock_reserved_qty": 0,
"warehouse": row.t_warehouse,
"voucher_no": stock_entry.work_order,
"voucher_type": "Work Order",
"name": row.name,
"delivered_qty": 0,
}
)
]
break
item_details = self.get_items_to_reserve_for_job_card(stock_entry, finished_good)
else:
# Reserve the final product for the sales order.
item_details = self.get_so_details()
@@ -1878,6 +1875,53 @@ class WorkOrder(Document):
return items
def get_items_to_reserve_for_job_card(self, stock_entry, finished_good):
item_details = []
for row in stock_entry.items:
if row.item_code == finished_good:
name = frappe.db.get_value(
"Work Order Item",
{"item_code": finished_good, "parent": self.name},
"name",
)
sres = frappe.get_all(
"Stock Reservation Entry",
fields=["reserved_qty"],
filters={
"voucher_no": self.name,
"item_code": finished_good,
"voucher_detail_no": name,
"warehouse": row.t_warehouse,
"docstatus": 1,
"status": "Reserved",
},
)
pending_qty = row.qty
for d in sres:
pending_qty -= d.reserved_qty
if pending_qty > 0:
item_details = [
frappe._dict(
{
"item_code": row.item_code,
"stock_qty": pending_qty,
"stock_reserved_qty": 0,
"warehouse": row.t_warehouse,
"voucher_no": stock_entry.work_order,
"voucher_type": "Work Order",
"name": name,
"delivered_qty": 0,
}
)
]
break
return item_details
def get_wo_details(self):
doctype = frappe.qb.DocType("Work Order")
child_doctype = frappe.qb.DocType("Work Order Item")

View File

@@ -8,6 +8,8 @@ from frappe.utils import getdate, today
from erpnext.stock.report.stock_analytics.stock_analytics import get_period, get_period_date_ranges
WORK_ORDER_STATUS_LIST = ["Not Started", "Overdue", "Pending", "Completed", "Closed", "Stopped"]
def execute(filters=None):
columns = get_columns(filters)
@@ -16,119 +18,98 @@ def execute(filters=None):
def get_columns(filters):
columns = [{"label": _("Status"), "fieldname": "Status", "fieldtype": "Data", "width": 140}]
columns = [{"label": _("Status"), "fieldname": "status", "fieldtype": "Data", "width": 140}]
ranges = get_period_date_ranges(filters)
for _dummy, end_date in ranges:
period = get_period(end_date, filters)
columns.append({"label": _(period), "fieldname": scrub(period), "fieldtype": "Float", "width": 120})
return columns
def get_periodic_data(filters, entry):
periodic_data = {
"Not Started": {},
"Overdue": {},
"Pending": {},
"Completed": {},
"Closed": {},
"Stopped": {},
}
def get_work_orders(filters):
from_date = filters.get("from_date")
to_date = filters.get("to_date")
ranges = get_period_date_ranges(filters)
WorkOrder = frappe.qb.DocType("Work Order")
for from_date, end_date in ranges:
period = get_period(end_date, filters)
for d in entry:
if getdate(from_date) <= getdate(d.creation) <= getdate(end_date) and d.status not in [
"Draft",
"Submitted",
"Completed",
"Cancelled",
]:
if d.status in ["Not Started", "Closed", "Stopped"]:
periodic_data = update_periodic_data(periodic_data, d.status, period)
elif getdate(today()) > getdate(d.planned_end_date):
periodic_data = update_periodic_data(periodic_data, "Overdue", period)
elif getdate(today()) < getdate(d.planned_end_date):
periodic_data = update_periodic_data(periodic_data, "Pending", period)
if (
getdate(from_date) <= getdate(d.actual_end_date) <= getdate(end_date)
and d.status == "Completed"
):
periodic_data = update_periodic_data(periodic_data, "Completed", period)
return periodic_data
def update_periodic_data(periodic_data, status, period):
if periodic_data.get(status).get(period):
periodic_data[status][period] += 1
else:
periodic_data[status][period] = 1
return periodic_data
return (
frappe.qb.from_(WorkOrder)
.select(WorkOrder.creation, WorkOrder.actual_end_date, WorkOrder.planned_end_date, WorkOrder.status)
.where(
(WorkOrder.docstatus == 1)
& (WorkOrder.company == filters.get("company"))
& (
(WorkOrder.creation.between(from_date, to_date))
| (WorkOrder.actual_end_date.between(from_date, to_date))
)
)
.run(as_dict=True)
)
def get_data(filters, columns):
data = []
entry = frappe.get_all(
"Work Order",
fields=[
"creation",
"actual_end_date",
"planned_end_date",
"status",
],
filters={"docstatus": 1, "company": filters["company"]},
)
ranges = build_ranges(filters)
period_labels = [scrub(pd) for _fd, _td, pd in ranges]
periodic_data = {status: {pd: 0 for pd in period_labels} for status in WORK_ORDER_STATUS_LIST}
entries = get_work_orders(filters)
periodic_data = get_periodic_data(filters, entry)
for d in entries:
if d.status == "Completed":
if not d.actual_end_date:
continue
labels = ["Not Started", "Overdue", "Pending", "Completed", "Closed", "Stopped"]
chart_data = get_chart_data(periodic_data, columns)
ranges = get_period_date_ranges(filters)
if period := scrub(get_period_for_date(getdate(d.actual_end_date), ranges)):
periodic_data["Completed"][period] += 1
continue
for label in labels:
work = {}
work["Status"] = _(label)
for _dummy, end_date in ranges:
period = get_period(end_date, filters)
if periodic_data.get(label).get(period):
work[scrub(period)] = periodic_data.get(label).get(period)
creation_date = getdate(d.creation)
period = scrub(get_period_for_date(creation_date, ranges))
if not period:
continue
if d.status in ("Not Started", "Closed", "Stopped"):
periodic_data[d.status][period] += 1
else:
if d.planned_end_date and getdate(today()) > getdate(d.planned_end_date):
periodic_data["Overdue"][period] += 1
else:
work[scrub(period)] = 0.0
data.append(work)
periodic_data["Pending"][period] += 1
return data, chart_data
data = []
for status in WORK_ORDER_STATUS_LIST:
row = {"status": _(status)}
for _fd, _td, period in ranges:
row[scrub(period)] = periodic_data[status].get(scrub(period), 0)
data.append(row)
chart = get_chart_data(periodic_data, columns)
return data, chart
def get_period_for_date(date, ranges):
for from_date, to_date, period in ranges:
if from_date <= date <= to_date:
return period
return None
def build_ranges(filters):
ranges = []
for from_date, end_date in get_period_date_ranges(filters):
period = get_period(end_date, filters)
ranges.append((getdate(from_date), getdate(end_date), period))
return ranges
def get_chart_data(periodic_data, columns):
labels = [d.get("label") for d in columns[1:]]
period_labels = [d.get("label") for d in columns[1:]]
period_fieldnames = [d.get("fieldname") for d in columns[1:]]
not_start, overdue, pending, completed, closed, stopped = [], [], [], [], [], []
datasets = []
for status in WORK_ORDER_STATUS_LIST:
values = [periodic_data.get(status, {}).get(fieldname, 0) for fieldname in period_fieldnames]
datasets.append({"name": _(status), "values": values})
for d in labels:
not_start.append(periodic_data.get("Not Started").get(d))
overdue.append(periodic_data.get("Overdue").get(d))
pending.append(periodic_data.get("Pending").get(d))
completed.append(periodic_data.get("Completed").get(d))
closed.append(periodic_data.get("Closed").get(d))
stopped.append(periodic_data.get("Stopped").get(d))
datasets.append({"name": _("Not Started"), "values": not_start})
datasets.append({"name": _("Overdue"), "values": overdue})
datasets.append({"name": _("Pending"), "values": pending})
datasets.append({"name": _("Completed"), "values": completed})
datasets.append({"name": _("Closed"), "values": closed})
datasets.append({"name": _("Stopped"), "values": stopped})
chart = {"data": {"labels": labels, "datasets": datasets}}
chart["type"] = "line"
return chart
return {"data": {"labels": period_labels, "datasets": datasets}, "type": "line"}

View File

@@ -456,9 +456,11 @@ erpnext.patches.v16_0.update_tax_withholding_field_in_payment_entry
erpnext.patches.v16_0.migrate_tax_withholding_data
erpnext.patches.v16_0.update_corrected_cancelled_status
erpnext.patches.v16_0.fix_barcode_typo
erpnext.patches.v16_0.add_accounting_dimensions_to_journal_template_accounts
erpnext.patches.v16_0.set_post_change_gl_entries_on_pos_settings
execute:frappe.delete_doc_if_exists("Workspace Sidebar", "Opening & Closing")
erpnext.patches.v15_0.create_accounting_dimensions_in_advance_taxes_and_charges
erpnext.patches.v16_0.set_ordered_qty_in_quotation_item
erpnext.patches.v16_0.migrate_transaction_deletion_task_flags_to_status # 2
erpnext.patches.v16_0.update_company_custom_field_in_bin
erpnext.patches.v15_0.replace_http_with_https_in_sales_partner

View File

@@ -0,0 +1,10 @@
import frappe
from frappe import qb
from pypika.functions import Replace
def execute():
sp = frappe.qb.DocType("Sales Partner")
qb.update(sp).set(sp.partner_website, Replace(sp.partner_website, "http://", "https://")).where(
sp.partner_website.rlike("^http://.*")
).run()

View File

@@ -0,0 +1,11 @@
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_dimensions,
make_dimension_in_accounting_doctypes,
)
def execute():
dimensions_and_defaults = get_dimensions()
if dimensions_and_defaults:
for dimension in dimensions_and_defaults[0]:
make_dimension_in_accounting_doctypes(dimension, ["Journal Entry Template Account"])

View File

@@ -989,7 +989,7 @@ erpnext.utils.map_current_doc = function (opts) {
}
if (query_args.filters || query_args.query) {
opts.get_query = () => query_args;
opts.get_query = () => JSON.parse(JSON.stringify(query_args));
}
if (opts.source_doctype) {

View File

@@ -110,12 +110,6 @@ frappe.ui.form.ContactAddressQuickEntryForm = class ContactAddressQuickEntryForm
options: "Country",
mandatory_depends_on: "eval:doc.city || doc.address_line1",
},
{
label: __("Customer POS Id"),
fieldname: "customer_pos_id",
fieldtype: "Data",
hidden: 1,
},
];
return variant_fields;

View File

@@ -28,4 +28,4 @@ erpnext.demo.clear_demo = function () {
},
});
});
};
};

View File

@@ -621,6 +621,9 @@ def handle_mandatory_error(e, customer, lead_name):
def get_ordered_items(quotation: str):
return frappe._dict(
frappe.get_all(
"Quotation Item", {"docstatus": 1, "parent": quotation}, ["name", "ordered_qty"], as_list=True
"Quotation Item",
{"docstatus": 1, "parent": quotation, "ordered_qty": (">", 0)},
["name", "ordered_qty"],
as_list=True,
)
)

View File

@@ -11,18 +11,18 @@
"field_order": [
"customer_section",
"column_break0",
"company",
"naming_series",
"customer",
"customer_name",
"tax_id",
"order_type",
"column_break_7",
"transaction_date",
"delivery_date",
"column_break1",
"customer",
"customer_name",
"tax_id",
"po_no",
"po_date",
"company",
"skip_delivery_note",
"has_unit_price_items",
"is_subcontracted",
@@ -1458,9 +1458,9 @@
},
{
"default": "0",
"depends_on": "eval:doc.order_type == 'Maintenance';",
"fieldname": "skip_delivery_note",
"fieldtype": "Check",
"hidden": 1,
"hide_days": 1,
"hide_seconds": 1,
"label": "Skip Delivery Note",
@@ -1713,7 +1713,7 @@
"idx": 105,
"is_submittable": 1,
"links": [],
"modified": "2026-01-29 21:23:48.362401",
"modified": "2026-02-06 11:06:16.092658",
"modified_by": "Administrator",
"module": "Selling",
"name": "Sales Order",

View File

@@ -57,6 +57,28 @@ class TestSalesOrder(AccountsTestMixin, IntegrationTestCase):
frappe.db.rollback()
frappe.set_user("Administrator")
def test_sales_order_skip_delivery_note(self):
so = make_sales_order(do_not_submit=True)
so.order_type = "Maintenance"
so.skip_delivery_note = 1
so.append(
"items",
{
"item_code": "_Test Item 2",
"qty": 2,
"rate": 100,
},
)
so.save()
so.submit()
so.reload()
si = make_sales_invoice(so.name)
si.insert()
si.submit()
so.reload()
self.assertEqual(so.status, "Completed")
@IntegrationTestCase.change_settings("Selling Settings", {"allow_negative_rates_for_items": 1})
def test_sales_order_with_negative_rate(self):
"""

View File

@@ -162,8 +162,6 @@ class EmailDigest(Document):
context.purchase_order_list,
context.purchase_orders_items_overdue_list,
) = self.get_purchase_orders_items_overdue_list()
if not context.purchase_order_list:
frappe.throw(_("No items to be received are overdue"))
if not context:
return None

View File

@@ -50,8 +50,17 @@ class SalesPartner(WebsiteGenerator):
if not self.route:
self.route = "partners/" + self.scrub(self.partner_name)
super().validate()
if self.partner_website and not self.partner_website.startswith("http"):
self.partner_website = "http://" + self.partner_website
if self.partner_website:
from urllib.parse import urlsplit, urlunsplit
# scrub http
parts = urlsplit(self.partner_website)
if not parts.netloc and parts.path:
parts = parts._replace(netloc=parts.path, path="")
if not parts.scheme or parts.scheme == "http":
parts = parts._replace(scheme="https")
self.partner_website = urlunsplit(parts)
def get_context(self, context):
address_names = frappe.db.get_all(

View File

@@ -1,5 +1,5 @@
import frappe
from frappe.model.db_query import DatabaseQuery
from frappe.desk.reportview import build_match_conditions
from frappe.utils import cint, flt
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
@@ -30,7 +30,7 @@ def get_data(
filters.append(["item_code", "in", items])
try:
# check if user has any restrictions based on user permissions on warehouse
if DatabaseQuery("Warehouse", user=frappe.session.user).build_match_conditions():
if build_match_conditions("Warehouse", user=frappe.session.user):
filters.append(["warehouse", "in", [w.name for w in frappe.get_list("Warehouse")]])
except frappe.PermissionError:
# user does not have access on warehouse

View File

@@ -1,5 +1,5 @@
import frappe
from frappe.model.db_query import DatabaseQuery
from frappe.desk.reportview import build_match_conditions
from frappe.utils import flt, nowdate
from erpnext.stock.utils import get_stock_balance
@@ -54,7 +54,7 @@ def get_filters(item_code=None, warehouse=None, parent_warehouse=None, company=N
def get_warehouse_filter_based_on_permissions(filters):
try:
# check if user has any restrictions based on user permissions on warehouse
if DatabaseQuery("Warehouse", user=frappe.session.user).build_match_conditions():
if build_match_conditions("Warehouse", user=frappe.session.user):
filters.append(["warehouse", "in", [w.name for w in frappe.get_list("Warehouse")]])
return False, filters
except frappe.PermissionError:

View File

@@ -123,6 +123,80 @@ class TestBatch(IntegrationTestCase):
for d in batches:
self.assertEqual(d.qty, batchwise_qty[(d.batch_no, d.warehouse)])
def test_batch_qty_on_pos_creation(self):
from erpnext.accounts.doctype.pos_closing_entry.test_pos_closing_entry import (
init_user_and_profile,
)
from erpnext.accounts.doctype.pos_invoice.test_pos_invoice import create_pos_invoice
from erpnext.accounts.doctype.pos_opening_entry.test_pos_opening_entry import create_opening_entry
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
get_auto_batch_nos,
)
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
create_batch_item_with_batch,
)
invoice_type = frappe.db.get_single_value("POS Settings", "invoice_type")
session_user = frappe.session.user
try:
# Set invoice type to POS Invoice
frappe.db.set_single_value("POS Settings", "invoice_type", "POS Invoice")
# Create batch item
create_batch_item_with_batch("_Test BATCH ITEM", "TestBatch-RS 02")
# Create stock entry
se = make_stock_entry(
target="_Test Warehouse - _TC",
item_code="_Test BATCH ITEM",
qty=30,
basic_rate=100,
)
se.reload()
batch_no = get_batch_from_bundle(se.items[0].serial_and_batch_bundle)
# Create opening entry
session_user = frappe.session.user
test_user, pos_profile = init_user_and_profile()
create_opening_entry(pos_profile, test_user.name)
# POS Invoice 1, for the batch without bundle
pos_inv1 = create_pos_invoice(item="_Test BATCH ITEM", rate=300, qty=15, do_not_save=1)
pos_inv1.append(
"payments",
{"mode_of_payment": "Cash", "amount": 4500},
)
pos_inv1.items[0].batch_no = batch_no
pos_inv1.save()
pos_inv1.submit()
pos_inv1.reload()
# Get auto batch nos after pos invoice
batches = get_auto_batch_nos(
frappe._dict(
{
"item_code": "_Test BATCH ITEM",
"warehouse": "_Test Warehouse - _TC",
"for_stock_levels": True,
"ignore_reserved_stock": True,
}
)
)
# Check batch qty after pos invoice
row = _find_batch_row(batches, batch_no, "_Test Warehouse - _TC")
self.assertIsNotNone(row)
self.assertEqual(row.qty, 30)
finally:
# Set invoice type to Sales Invoice
frappe.db.set_single_value("POS Settings", "invoice_type", invoice_type)
# Set user to session user
frappe.set_user(session_user)
def test_stock_entry_incoming(self):
"""Test batch creation via Stock Entry (Work Order)"""
@@ -610,6 +684,10 @@ def create_price_list_for_batch(item_code, batch, rate):
).insert()
def _find_batch_row(batches, batch_no, warehouse):
return next((b for b in batches if b.batch_no == batch_no and b.warehouse == warehouse), None)
def make_new_batch(**args):
args = frappe._dict(args)

View File

@@ -139,7 +139,7 @@ class InventoryDimension(Document):
self.source_fieldname = scrub(self.dimension_name)
if not self.target_fieldname:
self.target_fieldname = scrub(self.reference_document)
self.target_fieldname = scrub(self.dimension_name)
def on_update(self):
self.add_custom_fields()

View File

@@ -117,12 +117,12 @@ class TestInventoryDimension(IntegrationTestCase):
inward.load_from_db()
sle_data = frappe.db.get_value(
"Stock Ledger Entry", {"voucher_no": inward.name}, ["shelf", "warehouse"], as_dict=1
"Stock Ledger Entry", {"voucher_no": inward.name}, ["to_shelf", "warehouse"], as_dict=1
)
self.assertEqual(inward.items[0].to_shelf, "Shelf 1")
self.assertEqual(sle_data.warehouse, warehouse)
self.assertEqual(sle_data.shelf, "Shelf 1")
self.assertEqual(sle_data.to_shelf, "Shelf 1")
outward = make_stock_entry(
item_code=item_code,

View File

@@ -30,7 +30,10 @@ frappe.ui.form.on("Material Request", {
frm.set_query("from_warehouse", "items", function (doc) {
return {
filters: { company: doc.company },
filters: {
company: doc.company,
is_group: 0,
},
};
});
@@ -70,19 +73,28 @@ frappe.ui.form.on("Material Request", {
frm.set_query("warehouse", "items", function (doc) {
return {
filters: { company: doc.company },
filters: {
company: doc.company,
is_group: 0,
},
};
});
frm.set_query("set_warehouse", function (doc) {
return {
filters: { company: doc.company },
filters: {
company: doc.company,
is_group: 0,
},
};
});
frm.set_query("set_from_warehouse", function (doc) {
return {
filters: { company: doc.company },
filters: {
company: doc.company,
is_group: 0,
},
};
});

View File

@@ -777,6 +777,9 @@ def make_stock_entry(source_name, target_doc=None):
target.purpose = source.material_request_type
target.from_warehouse = source.set_from_warehouse
target.to_warehouse = source.set_warehouse
if source.material_request_type == "Material Issue":
target.from_warehouse = source.set_warehouse
target.to_warehouse = None
if source.job_card:
target.purpose = "Material Transfer for Manufacture"

View File

@@ -1017,15 +1017,27 @@ class TestMaterialRequest(IntegrationTestCase):
import json
from erpnext.stock.doctype.pick_list.pick_list import create_stock_entry
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
mr = make_material_request(material_request_type="Material Transfer")
new_item = create_item("_Test Pick List Item", is_stock_item=1)
item_code = new_item.name
make_stock_entry(
item_code=item_code,
target="_Test Warehouse - _TC",
qty=10,
do_not_save=False,
do_not_submit=False,
)
mr = make_material_request(item_code=item_code, material_request_type="Material Transfer")
pl = create_pick_list(mr.name)
pl.save()
pl.locations[0].qty = 5
pl.locations[0].stock_qty = 5
pl.submit()
to_warehouse = create_warehouse("Test To Warehouse")
to_warehouse = create_warehouse("_Test Warehouse - _TC")
se_data = create_stock_entry(json.dumps(pl.as_dict()))
se = frappe.get_doc(se_data)
@@ -1044,6 +1056,15 @@ class TestMaterialRequest(IntegrationTestCase):
def test_mr_pick_list_qty_validation(self):
"""Test for checking pick list qty validation from Material Request"""
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
make_stock_entry(
item_code="_Test Item",
target="_Test Warehouse - _TC",
qty=10,
do_not_save=False,
do_not_submit=False,
)
mr = make_material_request(material_request_type="Material Transfer")
pl = create_pick_list(mr.name)
@@ -1104,6 +1125,19 @@ class TestMaterialRequest(IntegrationTestCase):
self.assertRaises(frappe.ValidationError, end_transit_2.submit)
def test_make_stock_entry_material_issue_warehouse_mapping(self):
"""Test to ensure while making stock entry from material request of type Material Issue, warehouse is mapped correctly"""
mr = make_material_request(material_request_type="Material Issue", do_not_submit=True)
mr.set_warehouse = "_Test Warehouse - _TC"
mr.save()
mr.submit()
se = make_stock_entry(mr.name)
self.assertEqual(se.from_warehouse, "_Test Warehouse - _TC")
self.assertIsNone(se.to_warehouse)
se.save()
se.submit()
def get_in_transit_warehouse(company):
if not frappe.db.exists("Warehouse Type", "Transit"):

View File

@@ -1462,6 +1462,9 @@ class SerialandBatchBundle(Document):
def throw_negative_batch(self, batch_no, available_qty, precision):
from erpnext.stock.stock_ledger import NegativeStockError
if frappe.db.get_single_value("Stock Settings", "allow_negative_stock_for_batch"):
return
frappe.throw(
_(
"""
@@ -2690,7 +2693,10 @@ def get_auto_batch_nos(kwargs):
available_batches = get_available_batches(kwargs)
stock_ledgers_batches = get_stock_ledgers_batches(kwargs)
pos_invoice_batches = get_reserved_batches_for_pos(kwargs)
pos_invoice_batches = frappe._dict()
if not kwargs.for_stock_levels:
pos_invoice_batches = get_reserved_batches_for_pos(kwargs)
sre_reserved_batches = frappe._dict()
if not kwargs.ignore_reserved_stock:

View File

@@ -449,12 +449,12 @@ class StockEntry(StockController, SubcontractingInwardController):
def set_job_card_data(self):
if self.job_card and not self.work_order:
data = frappe.db.get_value(
"Job Card", self.job_card, ["for_quantity", "work_order", "bom_no"], as_dict=1
"Job Card", self.job_card, ["for_quantity", "work_order", "bom_no", "semi_fg_bom"], as_dict=1
)
self.fg_completed_qty = data.for_quantity
self.work_order = data.work_order
self.from_bom = 1
self.bom_no = data.bom_no
self.bom_no = data.semi_fg_bom or data.bom_no
def validate_job_card_fg_item(self):
if not self.job_card:
@@ -971,6 +971,7 @@ class StockEntry(StockController, SubcontractingInwardController):
precision = frappe.get_precision("Stock Entry Detail", "qty")
for item_code, details in raw_materials.items():
item_code = item_code[0] if type(item_code) == tuple else item_code
if matched_item := self.get_matched_items(item_code):
if flt(details.get("qty"), precision) != flt(matched_item.qty, precision):
frappe.throw(
@@ -2005,6 +2006,7 @@ class StockEntry(StockController, SubcontractingInwardController):
else:
job_doc.set_consumed_qty_in_job_card_item(self)
job_doc.set_manufactured_qty()
job_doc.update_work_order()
if self.work_order:
pro_doc = frappe.get_doc("Work Order", self.work_order)

View File

@@ -2,12 +2,16 @@
# For license information, please see license.txt
from collections import defaultdict
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import flt
from frappe.utils import cint, flt
from erpnext.manufacturing.doctype.bom.bom import get_bom_items_as_dict
from erpnext.stock.serial_batch_bundle import SerialBatchCreation
from erpnext.stock.utils import get_combine_datetime
class StockEntryType(Document):
@@ -77,7 +81,6 @@ class ManufactureEntry:
self.stock_entry.fg_completed_qty = self.for_quantity
self.stock_entry.project = self.project
self.stock_entry.job_card = self.job_card
self.stock_entry.work_order = self.work_order
self.stock_entry.set_stock_entry_type()
self.prepare_source_warehouse()
@@ -107,11 +110,17 @@ class ManufactureEntry:
"Manufacturing Settings", "backflush_raw_materials_based_on"
)
available_serial_batches = frappe._dict({})
if backflush_based_on != "BOM":
available_serial_batches = self.get_transferred_serial_batches()
for item_code, _dict in item_dict.items():
_dict.from_warehouse = self.source_wh.get(item_code) or self.wip_warehouse
_dict.to_warehouse = ""
if backflush_based_on != "BOM":
if backflush_based_on != "BOM" and not frappe.db.get_value(
"Job Card", self.job_card, "skip_material_transfer"
):
calculated_qty = flt(_dict.transferred_qty) - flt(_dict.consumed_qty)
if calculated_qty < 0:
frappe.throw(
@@ -119,9 +128,131 @@ class ManufactureEntry:
)
_dict.qty = calculated_qty
self.update_available_serial_batches(_dict, available_serial_batches)
self.stock_entry.add_to_stock_entry_detail(item_dict)
def parse_available_serial_batches(self, item_dict, available_serial_batches):
key = (item_dict.item_code, item_dict.from_warehouse)
if key not in available_serial_batches:
return [], {}
_avl_dict = available_serial_batches[key]
qty = item_dict.qty
serial_nos = []
batches = frappe._dict()
if _avl_dict.serial_nos:
serial_nos = _avl_dict.serial_nos[: cint(qty)]
qty -= len(serial_nos)
for sn in serial_nos:
_avl_dict.serial_nos.remove(sn)
elif _avl_dict.batches:
batches = frappe._dict()
for batch_no, batch_qty in _avl_dict.batches.items():
if qty <= 0:
break
if batch_qty <= qty:
batches[batch_no] = batch_qty
qty -= batch_qty
else:
batches[batch_no] = qty
qty = 0
for _used_batch_no in batches:
_avl_dict.batches[_used_batch_no] -= batches[_used_batch_no]
if _avl_dict.batches[_used_batch_no] <= 0:
del _avl_dict.batches[_used_batch_no]
return serial_nos, batches
def update_available_serial_batches(self, item_dict, available_serial_batches):
serial_nos, batches = self.parse_available_serial_batches(item_dict, available_serial_batches)
if serial_nos or batches:
sabb = SerialBatchCreation(
{
"item_code": item_dict.item_code,
"warehouse": item_dict.from_warehouse,
"posting_datetime": get_combine_datetime(
self.stock_entry.posting_date, self.stock_entry.posting_time
),
"voucher_type": self.stock_entry.doctype,
"company": self.stock_entry.company,
"type_of_transaction": "Outward",
"qty": item_dict.qty,
"serial_nos": serial_nos,
"batches": batches,
"do_not_submit": True,
}
).make_serial_and_batch_bundle()
item_dict.serial_and_batch_bundle = sabb.name
def get_stock_entry_data(self):
stock_entry = frappe.qb.DocType("Stock Entry")
stock_entry_detail = frappe.qb.DocType("Stock Entry Detail")
return (
frappe.qb.from_(stock_entry)
.inner_join(stock_entry_detail)
.on(stock_entry.name == stock_entry_detail.parent)
.select(
stock_entry_detail.item_code,
stock_entry_detail.qty,
stock_entry_detail.serial_and_batch_bundle,
stock_entry_detail.s_warehouse,
stock_entry_detail.t_warehouse,
stock_entry.purpose,
)
.where(
(stock_entry.job_card == self.job_card)
& (stock_entry_detail.serial_and_batch_bundle.isnotnull())
& (stock_entry.docstatus == 1)
& (stock_entry.purpose.isin(["Material Transfer for Manufacture", "Manufacture"]))
)
.orderby(stock_entry.posting_date, stock_entry.posting_time)
).run(as_dict=True)
def get_transferred_serial_batches(self):
available_serial_batches = frappe._dict({})
stock_entry_data = self.get_stock_entry_data()
for row in stock_entry_data:
warehouse = (
row.t_warehouse if row.purpose == "Material Transfer for Manufacture" else row.s_warehouse
)
key = (row.item_code, warehouse)
if key not in available_serial_batches:
available_serial_batches[key] = frappe._dict(
{
"batches": defaultdict(float),
"serial_nos": [],
}
)
_avl_dict = available_serial_batches[key]
sabb_data = frappe.get_all(
"Serial and Batch Entry",
filters={"parent": row.serial_and_batch_bundle},
fields=["serial_no", "batch_no", "qty"],
)
for entry in sabb_data:
if entry.serial_no:
if entry.qty > 0:
_avl_dict.serial_nos.append(entry.serial_no)
else:
_avl_dict.serial_nos.remove(entry.serial_no)
if entry.batch_no:
_avl_dict.batches[entry.batch_no] += flt(entry.qty) * (
-1 if row.purpose == "Material Transfer for Manufacture" else 1
)
return available_serial_batches
def get_items_from_job_card(self):
item_dict = {}
items = frappe.get_all(

View File

@@ -523,6 +523,9 @@ class StockReconciliation(StockController):
if abs(difference_amount) > 0:
return True
float_precision = frappe.db.get_default("float_precision") or 3
item_dict["rate"] = flt(item_dict.get("rate"), float_precision)
item.valuation_rate = flt(item.valuation_rate, float_precision) if item.valuation_rate else None
if (
(item.qty is None or item.qty == item_dict.get("qty"))
and (item.valuation_rate is None or item.valuation_rate == item_dict.get("rate"))

View File

@@ -1713,6 +1713,101 @@ class TestStockReconciliation(IntegrationTestCase, StockTestMixin):
self.assertEqual(docstatus, 2)
def test_stock_reco_with_opening_stock_with_diff_inventory(self):
from erpnext.stock.doctype.inventory_dimension.test_inventory_dimension import (
create_inventory_dimension,
)
if frappe.db.exists("DocType", "Plant"):
return
doctype = frappe.get_doc(
{
"doctype": "DocType",
"name": "Plant",
"module": "Stock",
"custom": 1,
"fields": [
{
"fieldname": "plant_name",
"fieldtype": "Data",
"label": "Plant Name",
"reqd": 1,
}
],
"autoname": "field:plant_name",
}
)
doctype.insert(ignore_permissions=True)
create_inventory_dimension(dimension_name="ID-Plant", reference_document="Plant")
plant_a = frappe.get_doc(
{
"doctype": "Plant",
"plant_name": "Plant A",
}
).insert(ignore_permissions=True)
plant_b = frappe.get_doc(
{
"doctype": "Plant",
"plant_name": "Plant B",
}
).insert(ignore_permissions=True)
warehouse = "_Test Warehouse - _TC"
item_code = "Item-Test"
item = self.make_item(item_code, {"is_stock_item": 1})
sr = frappe.new_doc("Stock Reconciliation")
sr.purpose = "Opening Stock"
sr.posting_date = nowdate()
sr.posting_time = nowtime()
sr.company = "_Test Company"
sr.append(
"items",
{
"item_code": item.name,
"warehouse": warehouse,
"qty": 5,
"valuation_rate": 100,
"id_plant": plant_a.name,
},
)
sr.append(
"items",
{
"item_code": item.name,
"warehouse": warehouse,
"qty": 3,
"valuation_rate": 110,
"id_plant": plant_b.name,
},
)
sr.insert()
sr.submit()
self.assertEqual(len(sr.items), 2)
sle_count = frappe.db.count(
"Stock Ledger Entry",
{"voucher_type": "Stock Reconciliation", "voucher_no": sr.name, "is_cancelled": 0},
)
self.assertEqual(sle_count, 2)
sle = frappe.get_all(
"Stock Ledger Entry",
{"voucher_type": "Stock Reconciliation", "voucher_no": sr.name, "is_cancelled": 0},
["item_code", "id_plant", "actual_qty", "valuation_rate"],
)
for s in sle:
if s.id_plant == plant_a.name:
self.assertEqual(s.actual_qty, 5)
elif s.id_plant == plant_b.name:
self.assertEqual(s.actual_qty, 3)
def create_batch_item_with_batch(item_name, batch_id):
batch_item_doc = create_item(item_name, is_stock_item=1)

View File

@@ -47,6 +47,7 @@
"disable_serial_no_and_batch_selector",
"use_serial_batch_fields",
"do_not_update_serial_batch_on_creation_of_auto_bundle",
"allow_negative_stock_for_batch",
"serial_and_batch_bundle_section",
"set_serial_and_batch_bundle_naming_based_on_naming_series",
"section_break_gnhq",
@@ -546,6 +547,13 @@
"fieldname": "validate_material_transfer_warehouses",
"fieldtype": "Check",
"label": "Validate Material Transfer Warehouses"
},
{
"default": "0",
"description": "If enabled, the system will allow negative stock entries for the batch, but this could calculate the valuation rate incorrectly, so avoid using this option.",
"fieldname": "allow_negative_stock_for_batch",
"fieldtype": "Check",
"label": "Allow Negative Stock for Batch"
}
],
"hide_toolbar": 1,
@@ -554,7 +562,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-01-02 18:19:59.034785",
"modified": "2026-02-09 15:01:12.466175",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Settings",

View File

@@ -30,6 +30,7 @@ class StockSettings(Document):
allow_from_pr: DF.Check
allow_internal_transfer_at_arms_length_price: DF.Check
allow_negative_stock: DF.Check
allow_negative_stock_for_batch: DF.Check
allow_partial_reservation: DF.Check
allow_to_edit_stock_uom_qty_for_purchase: DF.Check
allow_to_edit_stock_uom_qty_for_sales: DF.Check

View File

@@ -314,7 +314,7 @@
"type": "Link"
}
],
"modified": "2026-01-26 21:23:15.665712",
"modified": "2026-01-27 21:23:15.665712",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Invoicing",