Compare commits

..

21 Commits

Author SHA1 Message Date
Frappe PR Bot
fb2a4e5f98 chore(release): Bumped to Version 15.117.0
# [15.117.0](https://github.com/frappe/erpnext/compare/v15.116.0...v15.117.0) (2026-07-15)

### Bug Fixes

* allow barcode scan to add and increment items in pick list ([bb5be10](bb5be10372))
* **asset:** allow asset repair creation for fully depreciated assets (backport [#55276](https://github.com/frappe/erpnext/issues/55276)) ([e4d2a96](e4d2a960d2))
* **journal-entry:** fetch outstanding on foreign currency ([ace51fc](ace51fccfa))
* **manufacturing:** preserve job card transferred quantity ([fc4ac7a](fc4ac7a441))
* **stock:** fix sqlparse token limit in get_bundle_wise_serial_nos ([cc45743](cc457433fe))
* **stock:** show qty (company) and qty (warehouse) in sales transactions ([8086542](8086542885))

### Features

* faster (synced) financial statements using duckdb (backport [#56304](https://github.com/frappe/erpnext/issues/56304)) ([#57093](https://github.com/frappe/erpnext/issues/57093)) ([a6a887f](a6a887f833))
2026-07-15 02:28:29 +00:00
ruthra kumar
0ce7917648 Merge pull request #57160 from frappe/version-15-hotfix
chore: release v15
2026-07-15 06:25:15 +05:30
Mihir Kandoi
82f2af3a0e Merge pull request #57128 from frappe/mergify/bp/version-15-hotfix/pr-57126
fix(manufacturing): preserve job card transferred quantity (backport #57126)
2026-07-14 13:59:48 +05:30
Sudharsanan11
1bbeec6bfa test(manufacturing): cover transferred quantity across job cards
(cherry picked from commit fd6c9a71cd)
2026-07-14 08:11:06 +00:00
Sudharsanan11
fc4ac7a441 fix(manufacturing): preserve job card transferred quantity
(cherry picked from commit d7e9321746)
2026-07-14 08:11:06 +00:00
Mihir Kandoi
3913b9631f Merge pull request #57104 from frappe/mergify/bp/version-15-hotfix/pr-57091
fix(stock): show qty (company) and qty (warehouse) in sales transactions  (backport #57091)
2026-07-14 12:43:29 +05:30
Mihir Kandoi
9de9228cb9 Merge pull request #57117 from frappe/mergify/bp/version-15-hotfix/pr-57116
fix(stock): fix sqlparse token limit in get_bundle_wise_serial_nos (backport #57116)
2026-07-14 12:33:51 +05:30
Afsal Syed
dcff9164c4 test(stock): add unit test for get_bundle_wise_serial_nos query
(cherry picked from commit e748bf512b)
2026-07-14 12:09:41 +05:30
Sudharsanan11
aa4cc4c97d test(stock): assert qty (company) and qty (warehouse) on item details 2026-07-14 11:51:54 +05:30
Sudharsanan11
8086542885 fix(stock): show qty (company) and qty (warehouse) in sales transactions 2026-07-14 11:51:54 +05:30
Afsal Syed
cc457433fe fix(stock): fix sqlparse token limit in get_bundle_wise_serial_nos
(cherry picked from commit 4544a6c935)
2026-07-14 05:59:56 +00:00
Khushi Rawat
687fed031b Merge pull request #57110 from khushi8112/backport-55276-to-v15-hotfix
fix(asset): allow asset repair creation for fully depreciated assets (backport #55276)
2026-07-14 00:48:06 +05:30
Khushi Rawat
e4d2a960d2 fix(asset): allow asset repair creation for fully depreciated assets (backport #55276) 2026-07-14 00:30:06 +05:30
Mihir Kandoi
ce62ef3e2e Merge pull request #57102 from frappe/mergify/bp/version-15-hotfix/pr-57089
fix: allow barcode scan to add and increment items in pick list (backport #57089)
2026-07-13 21:28:03 +05:30
pandiyan
bb5be10372 fix: allow barcode scan to add and increment items in pick list
- allow new rows on scan when pick manually is enabled, since only
  then are scanned rows not subject to being overridden by
  set_item_locations on save
- stop capping picked qty at the default demand qty (1) for rows
  added by the scanner itself, so repeat scans of the same barcode
  keep incrementing the row instead of failing with "maximum
  quantity scanned"
- ignore barcode uom when matching an existing row if new rows
  aren't allowed, since there's no alternate-uom row to fall back to

(cherry picked from commit 3ece4a615d)
2026-07-13 15:32:46 +00:00
ruthra kumar
fcd0350652 refactor: rename synced to snapshot report (backport #56706) (#57094)
* refactor: rename execute_synced_report to execute_snapshot_report

Match the framework rename of the standard report entry point in the
trial balance, P&L, balance sheet, and general ledger reports.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit ba7b6a47c5)

* refactor: rename feature toggle in report master

(cherry picked from commit 981e90e4da)

# Conflicts:
#	erpnext/accounts/report/accounts_payable/accounts_payable.json
#	erpnext/accounts/report/accounts_receivable/accounts_receivable.json
#	erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.json
#	erpnext/accounts/report/trial_balance/trial_balance.json

---------

Co-authored-by: ruthra kumar <ruthra@erpnext.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:36:35 +05:30
ruthra kumar
1489258ea0 refactor: rename feature toggle in report master
(cherry picked from commit 981e90e4da)

# Conflicts:
#	erpnext/accounts/report/accounts_payable/accounts_payable.json
#	erpnext/accounts/report/accounts_receivable/accounts_receivable.json
#	erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.json
#	erpnext/accounts/report/trial_balance/trial_balance.json
2026-07-13 16:08:50 +05:30
ruthra kumar
674704cbfd refactor: rename execute_synced_report to execute_snapshot_report
Match the framework rename of the standard report entry point in the
trial balance, P&L, balance sheet, and general ledger reports.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit ba7b6a47c5)
2026-07-13 10:35:28 +00:00
mergify[bot]
a6a887f833 feat: faster (synced) financial statements using duckdb (backport #56304) (#57093)
* refactor: reports on duckdb

(cherry picked from commit adb768505a)

* feat(trial-balance): implement execute_duckdb with full parity to normal report

Replaces the placeholder stub with 8 focused functions that mirror the
normal execute() flow using parameterized DuckDB SQL queries: account
fetch, period GL entries, opening balances (with Period Closing Voucher
path), and all filters (cost center, project, finance book, accounting
dimensions). Reuses existing pure-Python processing functions unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
(cherry picked from commit b1c8e2cb5c)

* refactor(trial-balance): execute_duckdb only reads GL Entry from duckdb

Replaces the previous over-engineered stub with 7 short functions.
Account data, Account Closing Balance, and all metadata come from
frappe.db as normal; only tabGL Entry is read from the duckdb_conn.

Reuses get_opening_balance() for Account Closing Balance unchanged,
reuses all downstream compute helpers (calculate_values, prepare_data,
etc.) unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
(cherry picked from commit 55862f98f4)

* refactor: maintain sync dependency in report master

(cherry picked from commit 5c536b8ad1)

# Conflicts:
#	erpnext/accounts/report/accounts_payable/accounts_payable.json
#	erpnext/accounts/report/accounts_receivable/accounts_receivable.json
#	erpnext/accounts/report/general_ledger/general_ledger.json
#	erpnext/accounts/report/trial_balance/trial_balance.json

* refactor: DB agnostic method names

(cherry picked from commit f40cd41801)

* feat(general-ledger): implement execute_synced_report with full parity to normal report

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
(cherry picked from commit 6b4895bcc9)

# Conflicts:
#	erpnext/accounts/report/general_ledger/general_ledger.json

* feat(balance-sheet): implement execute_synced_report with full parity to normal report

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
(cherry picked from commit bb19540816)

# Conflicts:
#	erpnext/accounts/report/balance_sheet/balance_sheet.json

* feat(profit-and-loss): implement execute_synced_report with full parity to normal report

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
(cherry picked from commit 6a93baacf0)

# Conflicts:
#	erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.json

* refactor: synced reports should be enabled on sites based on requirements

(cherry picked from commit 963bbc8729)

# Conflicts:
#	erpnext/accounts/report/general_ledger/general_ledger.json

* chore: resolve conflicts

---------

Co-authored-by: ruthra kumar <ruthra@erpnext.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 16:03:26 +05:30
ruthra kumar
e8cdad1c6b Merge pull request #57082 from frappe/mergify/bp/version-15-hotfix/pr-56628
fix(journal-entry): fetch outstanding on foreign currency (backport #56628)
2026-07-13 13:05:57 +05:30
SowmyaArunachalam
ace51fccfa fix(journal-entry): fetch outstanding on foreign currency
(cherry picked from commit 07f641c48c)
2026-07-13 07:07:36 +00:00
24 changed files with 1149 additions and 50 deletions

View File

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

View File

@@ -2811,9 +2811,7 @@ def get_reference_details(
exchange_rate = get_exchange_rate(party_account_currency, company_currency, ref_doc.posting_date)
else:
exchange_rate = 1
outstanding_amount, total_amount = get_outstanding_on_journal_entry(
reference_name, party_type, party
)
outstanding_amount, total_amount = get_outstanding_on_journal_entry(reference_name, party_type, party)
elif reference_doctype == "Payment Entry":
if reverse_payment_details := frappe.db.get_all(

View File

@@ -7,7 +7,7 @@
"doctype": "Report",
"idx": 3,
"is_standard": "Yes",
"modified": "2017-02-24 20:09:46.150861",
"modified": "2026-07-01 13:37:41.185347",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Payable",
@@ -15,6 +15,11 @@
"ref_doctype": "Purchase Invoice",
"report_name": "Accounts Payable",
"report_type": "Script Report",
"doctype_to_sync": [
{
"doc_type": "Payment Ledger Entry"
}
],
"roles": [
{
"role": "Accounts User"
@@ -28,5 +33,7 @@
{
"role": "Auditor"
}
]
}
],
"snapshot_report": 0,
"timeout": 0
}

View File

@@ -7,7 +7,7 @@
"doctype": "Report",
"idx": 3,
"is_standard": "Yes",
"modified": "2017-03-06 05:52:06.235584",
"modified": "2026-07-01 13:37:44.167999",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Receivable",
@@ -15,6 +15,11 @@
"ref_doctype": "Sales Invoice",
"report_name": "Accounts Receivable",
"report_type": "Script Report",
"doctype_to_sync": [
{
"doc_type": "Payment Ledger Entry"
}
],
"roles": [
{
"role": "Accounts Manager"
@@ -22,5 +27,7 @@
{
"role": "Accounts User"
}
]
}
],
"snapshot_report": 0,
"timeout": 0
}

View File

@@ -6,7 +6,7 @@
"doctype": "Report",
"idx": 2,
"is_standard": "Yes",
"modified": "2018-09-07 12:18:21.850851",
"modified": "2026-06-22 13:38:25.236839",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Balance Sheet",
@@ -15,6 +15,11 @@
"ref_doctype": "GL Entry",
"report_name": "Balance Sheet",
"report_type": "Script Report",
"doctype_to_sync": [
{
"doc_type": "GL Entry"
}
],
"roles": [
{
"role": "Accounts User"
@@ -25,5 +30,7 @@
{
"role": "Auditor"
}
]
}
],
"synced_report": 0,
"timeout": 0
}

View File

@@ -4,14 +4,23 @@
import frappe
from frappe import _
from frappe.utils import cint, flt
from frappe.utils import add_days, cint, flt
from erpnext.accounts.report.financial_statements import (
accumulate_values_into_parents,
add_total_row,
calculate_values,
compute_growth_view_data,
filter_accounts,
filter_out_zero_value_rows,
get_accounting_entries,
get_accounts,
get_appropriate_currency,
get_columns,
get_data,
get_filtered_list_for_consolidated_report,
get_period_list,
prepare_data,
)
@@ -259,3 +268,196 @@ def get_chart_data(filters, chart_columns, asset, liability, equity, currency):
chart["currency"] = currency
return chart
def execute_snapshot_report(filters):
from frappe.database.duckdb.database import get_latest_sync
if not (conn := get_latest_sync("GL Entry")):
frappe.throw(_("Balance Sheet requires {0} to be synced to DuckDB").format(frappe.bold("GL Entry")))
period_list = get_period_list(
filters.from_fiscal_year,
filters.to_fiscal_year,
filters.period_start_date,
filters.period_end_date,
filters.filter_based_on,
filters.periodicity,
company=filters.company,
)
filters.period_start_date = period_list[0]["year_start_date"]
currency = filters.presentation_currency or frappe.get_cached_value(
"Company", filters.company, "default_currency"
)
asset = _get_data_duckdb(conn, filters, "Asset", "Debit", period_list)
liability = _get_data_duckdb(conn, filters, "Liability", "Credit", period_list)
equity = _get_data_duckdb(conn, filters, "Equity", "Credit", period_list)
provisional_profit_loss, total_credit = get_provisional_profit_loss(
asset, liability, equity, period_list, filters.company, currency
)
message, opening_balance = check_opening_balance(asset, liability, equity)
data = []
data.extend(asset or [])
data.extend(liability or [])
data.extend(equity or [])
if opening_balance and round(opening_balance, 2) != 0:
unclosed = {
"account_name": "'" + _("Unclosed Fiscal Years Profit / Loss (Credit)") + "'",
"account": "'" + _("Unclosed Fiscal Years Profit / Loss (Credit)") + "'",
"warn_if_negative": True,
"currency": currency,
}
for period in period_list:
unclosed[period.key] = opening_balance
if provisional_profit_loss:
provisional_profit_loss[period.key] = provisional_profit_loss[period.key] - opening_balance
unclosed["total"] = opening_balance
data.append(unclosed)
if provisional_profit_loss:
data.append(provisional_profit_loss)
if total_credit:
data.append(total_credit)
columns = get_columns(
filters.periodicity, period_list, filters.accumulated_values, company=filters.company
)
chart = get_chart_data(filters, period_list, asset, liability, equity, currency)
report_summary, primitive_summary = get_report_summary(
period_list, asset, liability, equity, provisional_profit_loss, currency, filters
)
if filters.get("selected_view") == "Growth":
compute_growth_view_data(data, period_list)
return columns, data, message, chart, report_summary, primitive_summary
def _get_data_duckdb(conn, filters, root_type, balance_must_be, period_list):
accounts = get_accounts(filters.company, root_type)
if not accounts:
return None
accounts, accounts_by_name, parent_children_map = filter_accounts(accounts)
company_currency = get_appropriate_currency(filters.company, filters)
gl_entries_by_account = {}
_load_gl_entries_duckdb(conn, filters, period_list, accounts, gl_entries_by_account, root_type)
calculate_values(
accounts_by_name,
gl_entries_by_account,
period_list,
filters.accumulated_values,
False,
)
accumulate_values_into_parents(accounts, accounts_by_name, period_list)
out = prepare_data(
accounts,
balance_must_be,
period_list,
company_currency,
accumulated_values=filters.accumulated_values,
)
out = filter_out_zero_value_rows(out, parent_children_map, filters.show_zero_values)
if out:
add_total_row(out, root_type, balance_must_be, period_list, company_currency)
return out
def _load_gl_entries_duckdb(conn, filters, period_list, accounts, gl_entries_by_account, root_type):
from erpnext.accounts.report.trial_balance.trial_balance import (
_extra_gl_conditions,
_fetch_gl_rows_duckdb,
)
from erpnext.accounts.report.utils import convert_to_presentation_currency, get_currency
company = filters.company
year_start_date = period_list[0]["year_start_date"]
last_to_date = period_list[-1]["to_date"]
ignore_is_opening = frappe.get_single_value("Accounts Settings", "ignore_is_opening_check_for_reporting")
leaf_accounts = [acc.name for acc in accounts if not acc.is_group]
if not leaf_accounts:
return
opening_from_date = None
ignore_opening_entries = False
ignore_closing_balances = frappe.get_single_value("Accounts Settings", "ignore_account_closing_balance")
if not ignore_closing_balances:
last_pcv_list = frappe.db.get_all(
"Period Closing Voucher",
filters={
"docstatus": 1,
"company": company,
"period_end_date": ("<", filters.get("period_start_date") or year_start_date),
},
fields=["period_end_date", "name"],
order_by="period_end_date desc",
limit=1,
)
if last_pcv_list:
last_pcv = last_pcv_list[0]
pcv_entries = get_accounting_entries(
"Account Closing Balance",
None,
last_to_date,
filters,
root_type=root_type,
ignore_closing_entries=False,
period_closing_voucher=last_pcv.name,
)
if filters.get("presentation_currency"):
convert_to_presentation_currency(pcv_entries, get_currency(filters))
for entry in pcv_entries:
gl_entries_by_account.setdefault(entry.account, []).append(entry)
opening_from_date = add_days(last_pcv.period_end_date, 1)
ignore_opening_entries = True
extra_cond, extra_params = _extra_gl_conditions(filters)
account_placeholders = ", ".join(["?"] * len(leaf_accounts))
base_conds = [
"company = ?",
"is_cancelled = 0",
f"account IN ({account_placeholders})",
]
base_params = [company, *leaf_accounts]
if ignore_opening_entries and not ignore_is_opening:
base_conds.append("is_opening = 'No'")
base_conds.extend(extra_cond)
base_params.extend(extra_params)
# Opening GL entries from DuckDB (entries before year_start_date)
open_conds = [*base_conds, "posting_date < ?"]
open_params = [*base_params, year_start_date]
if opening_from_date:
open_conds = [*open_conds, "posting_date >= ?"]
open_params = [*open_params, opening_from_date]
opening_entries = _fetch_gl_rows_duckdb(conn, open_conds, open_params)
if filters.get("presentation_currency"):
convert_to_presentation_currency(opening_entries, get_currency(filters))
synthetic_open_date = add_days(year_start_date, -1)
for entry in opening_entries:
entry.posting_date = synthetic_open_date
gl_entries_by_account.setdefault(entry.account, []).append(entry)
# Period GL entries from DuckDB (one aggregated query per period)
for period in period_list:
period_conds = [*base_conds, "posting_date >= ?", "posting_date <= ?"]
period_params = [*base_params, period.from_date, period.to_date]
period_entries = _fetch_gl_rows_duckdb(conn, period_conds, period_params)
if filters.get("presentation_currency"):
convert_to_presentation_currency(period_entries, get_currency(filters))
for entry in period_entries:
entry.posting_date = period.to_date
gl_entries_by_account.setdefault(entry.account, []).append(entry)

View File

@@ -6,11 +6,15 @@
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"doctype_to_sync": [
{
"doc_type": "GL Entry"
}
],
"filters": [],
"idx": 3,
"is_standard": "Yes",
"letterhead": null,
"modified": "2025-11-05 15:47:59.597853",
"modified": "2026-07-01 13:36:06.682661",
"modified_by": "Administrator",
"module": "Accounts",
"name": "General Ledger",
@@ -30,5 +34,6 @@
"role": "Auditor"
}
],
"snapshot_report": 0,
"timeout": 0
}

View File

@@ -792,3 +792,288 @@ def get_columns(filters):
columns.extend([{"label": _("Remarks"), "fieldname": "remarks", "width": 400}])
return columns
def execute_snapshot_report(filters):
from frappe.database.duckdb.database import get_latest_sync
if conn := get_latest_sync("GL Entry"):
return _execute_with_duckdb_conn(filters, conn)
frappe.throw(_("General Ledger requires {0} to be synced to DuckDB").format(frappe.bold("GL Entry")))
def _execute_with_duckdb_conn(filters, conn):
if not filters:
return [], []
account_details = {}
if filters.get("print_in_account_currency") and not filters.get("account"):
frappe.throw(_("Select an account to print in account currency"))
for acc in frappe.get_all("Account", fields=["name", "is_group"]):
account_details.setdefault(acc.name, acc)
if filters.get("party"):
filters.party = frappe.parse_json(filters.get("party"))
validate_filters(filters, account_details)
validate_party(filters)
filters = set_account_currency(filters)
columns = get_columns(filters)
res = get_result_duckdb(filters, account_details, conn)
return columns, res
def get_result_duckdb(filters, account_details, conn):
accounting_dimensions = []
if filters.get("include_dimensions"):
accounting_dimensions = get_accounting_dimensions()
gl_entries = get_gl_entries_duckdb(filters, accounting_dimensions, conn)
data = get_data_with_opening_closing(filters, account_details, accounting_dimensions, gl_entries)
return get_result_as_list(data, filters)
def get_gl_entries_duckdb(filters, accounting_dimensions, conn):
currency_map = get_currency(filters)
col_names = [
"gl_entry",
"posting_date",
"account",
"party_type",
"party",
"voucher_type",
"voucher_subtype",
"voucher_no",
"cost_center",
"project",
"against_voucher_type",
"against_voucher",
"account_currency",
"against",
"is_opening",
"creation",
"debit",
"credit",
"debit_in_account_currency",
"credit_in_account_currency",
]
select_exprs = [
"name",
"posting_date",
"account",
"party_type",
"party",
"voucher_type",
"voucher_subtype",
"voucher_no",
"cost_center",
"project",
"against_voucher_type",
"against_voucher",
"account_currency",
"against",
"is_opening",
"creation",
"debit",
"credit",
"debit_in_account_currency",
"credit_in_account_currency",
]
if filters.get("show_remarks"):
remarks_length = frappe.get_single_value("Accounts Settings", "general_ledger_remarks_length")
if remarks_length:
select_exprs.append(f"substr(remarks, 1, {int(remarks_length)})")
else:
select_exprs.append("remarks")
col_names.append("remarks")
if filters.get("add_values_in_transaction_currency"):
select_exprs += [
"debit_in_transaction_currency",
"credit_in_transaction_currency",
"transaction_currency",
]
col_names += [
"debit_in_transaction_currency",
"credit_in_transaction_currency",
"transaction_currency",
]
if accounting_dimensions:
select_exprs += accounting_dimensions
col_names += accounting_dimensions
order_by = "posting_date, account, creation"
if filters.get("include_dimensions"):
order_by = "posting_date, creation"
if filters.get("categorize_by") == "Categorize by Voucher":
order_by = "posting_date, voucher_type, voucher_no"
if filters.get("categorize_by") == "Categorize by Account":
order_by = "account, posting_date, creation"
if filters.get("include_default_book_entries"):
filters["company_fb"] = frappe.get_cached_value(
"Company", filters.get("company"), "default_finance_book"
)
conditions, params = _build_gl_conditions_duckdb(filters)
select_clause = ", ".join(select_exprs)
sql = f'SELECT {select_clause} FROM "tabGL Entry" WHERE {" AND ".join(conditions)} ORDER BY {order_by}'
rows = conn.execute(sql, params).fetchall()
gl_entries = [frappe._dict(zip(col_names, row, strict=False)) for row in rows]
party_name_map = get_party_name_map()
for gl_entry in gl_entries:
if gl_entry.party_type and gl_entry.party:
gl_entry.party_name = party_name_map.get(gl_entry.party_type, {}).get(gl_entry.party)
if filters.get("presentation_currency"):
return convert_to_presentation_currency(gl_entries, currency_map, filters)
return gl_entries
def _build_gl_conditions_duckdb(filters):
ignore_is_opening = frappe.get_single_value("Accounts Settings", "ignore_is_opening_check_for_reporting")
conditions = ["company = ?"]
params = [filters.company]
if filters.get("account"):
filters.account = get_accounts_with_children(filters.account)
if filters.account:
conditions.append(f"account IN ({', '.join(['?'] * len(filters.account))})")
params.extend(filters.account)
if filters.get("cost_center"):
filters.cost_center = get_cost_centers_with_children(filters.cost_center)
conditions.append(f"cost_center IN ({', '.join(['?'] * len(filters.cost_center))})")
params.extend(filters.cost_center)
if filters.get("voucher_no"):
conditions.append("voucher_no = ?")
params.append(filters.voucher_no)
if filters.get("against_voucher_no"):
conditions.append("against_voucher = ?")
params.append(filters.against_voucher_no)
if filters.get("ignore_err"):
err_journals = frappe.db.get_all(
"Journal Entry",
filters={
"company": filters.get("company"),
"docstatus": 1,
"voucher_type": ("in", ["Exchange Rate Revaluation", "Exchange Gain Or Loss"]),
},
pluck="name",
)
if err_journals:
filters.update({"voucher_no_not_in": err_journals})
if filters.get("ignore_cr_dr_notes"):
system_generated = frappe.db.get_all(
"Journal Entry",
filters={
"company": filters.get("company"),
"docstatus": 1,
"voucher_type": ("in", ["Credit Note", "Debit Note"]),
"is_system_generated": 1,
},
pluck="name",
)
if system_generated:
vouchers_to_ignore = (filters.get("voucher_no_not_in") or []) + system_generated
filters.update({"voucher_no_not_in": vouchers_to_ignore})
if filters.get("voucher_no_not_in"):
vouchers = filters.voucher_no_not_in
conditions.append(f"voucher_no NOT IN ({', '.join(['?'] * len(vouchers))})")
params.extend(vouchers)
if filters.get("categorize_by") == "Categorize by Party" and not filters.get("party_type"):
conditions.append("party_type IN ('Customer', 'Supplier')")
if filters.get("party_type"):
conditions.append("party_type = ?")
params.append(filters.party_type)
if filters.get("party"):
conditions.append(f"party IN ({', '.join(['?'] * len(filters.party))})")
params.extend(filters.party)
# from_date: skip when filtering by account/party to allow opening balance calc in Python
if filters.get("disable_opening_balance_calculation"):
if not ignore_is_opening:
conditions.append("(posting_date >= ? OR is_opening = 'Yes')")
else:
conditions.append("posting_date >= ?")
params.append(filters.from_date)
elif not (
filters.get("account")
or filters.get("party")
or filters.get("categorize_by") in ["Categorize by Account", "Categorize by Party"]
):
if not ignore_is_opening:
conditions.append("(posting_date >= ? OR is_opening = 'Yes')")
else:
conditions.append("posting_date >= ?")
params.append(filters.from_date)
if not ignore_is_opening:
conditions.append("(posting_date <= ? OR is_opening = 'Yes')")
else:
conditions.append("posting_date <= ?")
params.append(filters.to_date)
if filters.get("project"):
conditions.append(f"project IN ({', '.join(['?'] * len(filters.project))})")
params.extend(filters.project)
company_fb = filters.get("company_fb") or frappe.get_cached_value(
"Company", filters.company, "default_finance_book"
)
if filters.get("include_default_book_entries"):
if filters.get("finance_book"):
if company_fb and cstr(filters.finance_book) != cstr(company_fb):
frappe.throw(
_("To use a different finance book, please uncheck 'Include Default FB Entries'")
)
fb_vals = [cstr(filters.finance_book), ""]
else:
fb_vals = [cstr(company_fb), ""]
conditions.append(f"(finance_book IN ({', '.join(['?'] * len(fb_vals))}) OR finance_book IS NULL)")
params.extend(fb_vals)
else:
if filters.get("finance_book"):
conditions.append("(finance_book IN (?, '') OR finance_book IS NULL)")
params.append(cstr(filters.finance_book))
else:
conditions.append("(finance_book IN ('') OR finance_book IS NULL)")
if not filters.get("show_cancelled_entries"):
conditions.append("is_cancelled = 0")
accounting_dimensions_list = get_accounting_dimensions(as_list=False)
if accounting_dimensions_list:
for dimension in accounting_dimensions_list:
if not dimension.disabled and dimension.document_type != "Finance Book":
if filters.get(dimension.fieldname):
if frappe.get_cached_value("DocType", dimension.document_type, "is_tree"):
filters[dimension.fieldname] = get_dimension_with_children(
dimension.document_type, filters.get(dimension.fieldname)
)
vals = (
filters[dimension.fieldname]
if isinstance(filters[dimension.fieldname], list)
else [filters[dimension.fieldname]]
)
conditions.append(f"{dimension.fieldname} IN ({', '.join(['?'] * len(vals))})")
params.extend(vals)
return conditions, params

View File

@@ -7,7 +7,7 @@
"doctype": "Report",
"idx": 2,
"is_standard": "Yes",
"modified": "2017-02-24 20:12:40.282376",
"modified": "2026-07-01 13:36:14.934965",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Profit and Loss Statement",
@@ -15,6 +15,12 @@
"ref_doctype": "GL Entry",
"report_name": "Profit and Loss Statement",
"report_type": "Script Report",
"doctype_to_sync": [
{
"doc_type": "GL Entry"
}
],
"filters": [],
"roles": [
{
"role": "Accounts User"
@@ -25,5 +31,7 @@
{
"role": "Auditor"
}
]
}
],
"snapshot_report": 0,
"timeout": 0
}

View File

@@ -7,12 +7,20 @@ from frappe import _
from frappe.utils import flt
from erpnext.accounts.report.financial_statements import (
accumulate_values_into_parents,
add_total_row,
calculate_values,
compute_growth_view_data,
compute_margin_view_data,
filter_accounts,
filter_out_zero_value_rows,
get_accounts,
get_appropriate_currency,
get_columns,
get_data,
get_filtered_list_for_consolidated_report,
get_period_list,
prepare_data,
)
@@ -193,3 +201,125 @@ def get_chart_data(filters, chart_columns, income, expense, net_profit_loss, cur
chart["currency"] = currency
return chart
def execute_snapshot_report(filters):
from frappe.database.duckdb.database import get_latest_sync
if not (conn := get_latest_sync("GL Entry")):
frappe.throw(
_("Profit and Loss Statement requires {0} to be synced to DuckDB").format(frappe.bold("GL Entry"))
)
period_list = get_period_list(
filters.from_fiscal_year,
filters.to_fiscal_year,
filters.period_start_date,
filters.period_end_date,
filters.filter_based_on,
filters.periodicity,
company=filters.company,
)
income = _get_data_duckdb(conn, filters, "Income", "Credit", period_list)
expense = _get_data_duckdb(conn, filters, "Expense", "Debit", period_list)
net_profit_loss = get_net_profit_loss(
income, expense, period_list, filters.company, filters.presentation_currency
)
data = []
data.extend(income or [])
data.extend(expense or [])
if net_profit_loss:
data.append(net_profit_loss)
columns = get_columns(filters.periodicity, period_list, filters.accumulated_values, filters.company)
currency = filters.presentation_currency or frappe.get_cached_value(
"Company", filters.company, "default_currency"
)
chart = get_chart_data(filters, period_list, income, expense, net_profit_loss, currency)
report_summary, primitive_summary = get_report_summary(
period_list, filters.periodicity, income, expense, net_profit_loss, currency, filters
)
if filters.get("selected_view") == "Growth":
compute_growth_view_data(data, period_list)
if filters.get("selected_view") == "Margin":
compute_margin_view_data(data, period_list, filters.accumulated_values)
return columns, data, None, chart, report_summary, primitive_summary
def _get_data_duckdb(conn, filters, root_type, balance_must_be, period_list):
accounts = get_accounts(filters.company, root_type)
if not accounts:
return None
accounts, accounts_by_name, parent_children_map = filter_accounts(accounts)
company_currency = get_appropriate_currency(filters.company, filters)
gl_entries_by_account = {}
_load_gl_entries_duckdb(conn, filters, period_list, accounts, gl_entries_by_account)
calculate_values(
accounts_by_name,
gl_entries_by_account,
period_list,
filters.accumulated_values,
False,
)
accumulate_values_into_parents(accounts, accounts_by_name, period_list)
out = prepare_data(
accounts,
balance_must_be,
period_list,
company_currency,
accumulated_values=filters.accumulated_values,
)
out = filter_out_zero_value_rows(out, parent_children_map, filters.show_zero_values)
if out:
add_total_row(out, root_type, balance_must_be, period_list, company_currency)
return out
def _load_gl_entries_duckdb(conn, filters, period_list, accounts, gl_entries_by_account):
from erpnext.accounts.report.trial_balance.trial_balance import (
_extra_gl_conditions,
_fetch_gl_rows_duckdb,
)
from erpnext.accounts.report.utils import convert_to_presentation_currency, get_currency
company = filters.company
leaf_accounts = [acc.name for acc in accounts if not acc.is_group]
if not leaf_accounts:
return
extra_cond, extra_params = _extra_gl_conditions(filters)
account_placeholders = ", ".join(["?"] * len(leaf_accounts))
base_conds = [
"company = ?",
"is_cancelled = 0",
f"account IN ({account_placeholders})",
"voucher_type != 'Period Closing Voucher'",
]
base_params = [company, *leaf_accounts]
base_conds.extend(extra_cond)
base_params.extend(extra_params)
for period in period_list:
period_conds = [*base_conds, "posting_date >= ?", "posting_date <= ?"]
period_params = [*base_params, period.from_date, period.to_date]
period_entries = _fetch_gl_rows_duckdb(conn, period_conds, period_params)
if filters.get("presentation_currency"):
convert_to_presentation_currency(period_entries, get_currency(filters))
for entry in period_entries:
entry.posting_date = period.to_date
gl_entries_by_account.setdefault(entry.account, []).append(entry)

View File

@@ -7,13 +7,18 @@
"doctype": "Report",
"idx": 2,
"is_standard": "Yes",
"modified": "2017-02-24 20:12:33.520866",
"modified": "2026-07-01 17:32:21.801141",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Trial Balance",
"owner": "Administrator",
"ref_doctype": "GL Entry",
"report_name": "Trial Balance",
"doctype_to_sync": [
{
"doc_type": "GL Entry"
}
],
"report_type": "Script Report",
"roles": [
{
@@ -25,5 +30,7 @@
{
"role": "Auditor"
}
]
}
],
"snapshot_report": 0,
"timeout": 0
}

View File

@@ -510,3 +510,215 @@ def hide_group_accounts(data):
d.update(indent=0)
non_group_accounts_data.append(d)
return non_group_accounts_data
def execute_snapshot_report(filters):
from frappe.database.duckdb.database import get_latest_sync
if conn := get_latest_sync("GL Entry"):
validate_filters(filters)
columns = get_columns()
data = get_data_duckdb(filters, conn)
return columns, data
else:
frappe.throw(_("Trial Balance requires {0} to be synced to DuckDB").format(frappe.bold("GL Entry")))
def get_data_duckdb(filters, conn):
# accounts and all metadata via frappe.db — only GL Entry comes from DuckDB
accounts = frappe.db.sql(
"""select name, account_number, parent_account, account_name, root_type, report_type, is_group, lft, rgt
from `tabAccount` where company=%s order by lft""",
filters.company,
as_dict=True,
)
if not accounts:
return None
company_currency = filters.presentation_currency or erpnext.get_company_currency(filters.company)
ignore_is_opening = frappe.get_single_value("Accounts Settings", "ignore_is_opening_check_for_reporting")
accounts, accounts_by_name, parent_children_map = filter_accounts(accounts)
gl_entries_by_account = get_period_gl_entries_duckdb(conn, filters, ignore_is_opening)
opening_balances = get_opening_balances_duckdb(conn, filters, ignore_is_opening)
calculate_values(
accounts,
gl_entries_by_account,
opening_balances,
filters.get("show_net_values"),
ignore_is_opening=ignore_is_opening,
)
accumulate_values_into_parents(accounts, accounts_by_name)
data = prepare_data(accounts, filters, parent_children_map, company_currency)
return filter_out_zero_value_rows(
data, parent_children_map, show_zero_values=filters.get("show_zero_values")
)
def _extra_gl_conditions(filters):
"""Returns (conditions, params) for optional shared GL Entry filters."""
conditions, params = [], []
if filters.get("cost_center"):
cc = get_cost_centers_with_children(filters.get("cost_center"))
conditions.append(f"cost_center IN ({', '.join(['?'] * len(cc))})")
params.extend(cc)
if filters.get("project"):
proj = filters.project if isinstance(filters.project, list) else [filters.project]
conditions.append(f"project IN ({', '.join(['?'] * len(proj))})")
params.extend(proj)
if frappe.db.count("Finance Book"):
company_fb = frappe.get_cached_value("Company", filters.company, "default_finance_book")
if filters.get("include_default_book_entries"):
if filters.get("finance_book") and company_fb and cstr(filters.finance_book) != cstr(company_fb):
frappe.throw(
_("To use a different finance book, please uncheck 'Include Default FB Entries'")
)
fb_list = [cstr(filters.get("finance_book")), cstr(company_fb), ""]
else:
fb_list = [cstr(filters.get("finance_book")), ""]
conditions.append(f"(finance_book IN ({', '.join(['?'] * len(fb_list))}) OR finance_book IS NULL)")
params.extend(fb_list)
for dim in get_accounting_dimensions(as_list=False):
if filters.get(dim.fieldname):
if frappe.get_cached_value("DocType", dim.document_type, "is_tree"):
filters[dim.fieldname] = get_dimension_with_children(
dim.document_type, filters.get(dim.fieldname)
)
vals = (
filters[dim.fieldname]
if isinstance(filters[dim.fieldname], list)
else [filters[dim.fieldname]]
)
conditions.append(f"{dim.fieldname} IN ({', '.join(['?'] * len(vals))})")
params.extend(vals)
return conditions, params
def _fetch_gl_rows_duckdb(conn, conditions, params):
cols = [
"account",
"debit",
"credit",
"debit_in_account_currency",
"credit_in_account_currency",
"account_currency",
]
sql = f"""SELECT account, SUM(debit), SUM(credit),
SUM(debit_in_account_currency), SUM(credit_in_account_currency), account_currency
FROM "tabGL Entry" WHERE {" AND ".join(conditions)}
GROUP BY account, account_currency"""
return [frappe._dict(zip(cols, row, strict=False)) for row in conn.execute(sql, params).fetchall()]
def get_period_gl_entries_duckdb(conn, filters, ignore_is_opening):
conditions = ["company = ?", "is_cancelled = 0", "posting_date >= ?", "posting_date <= ?"]
params = [filters.company, filters.from_date, filters.to_date]
if not ignore_is_opening:
conditions.append("is_opening = 'No'")
if not flt(filters.get("with_period_closing_entry_for_current_period")):
conditions.append("voucher_type != 'Period Closing Voucher'")
extra_cond, extra_params = _extra_gl_conditions(filters)
conditions.extend(extra_cond)
params.extend(extra_params)
entries = _fetch_gl_rows_duckdb(conn, conditions, params)
if filters.get("presentation_currency"):
convert_to_presentation_currency(entries, get_currency(filters))
gl_entries_by_account = {}
for entry in entries:
gl_entries_by_account.setdefault(entry.account, []).append(entry)
return gl_entries_by_account
def get_opening_balances_duckdb(conn, filters, ignore_is_opening):
bs = _get_rootwise_opening_duckdb(conn, filters, "Balance Sheet", ignore_is_opening)
pl = _get_rootwise_opening_duckdb(conn, filters, "Profit and Loss", ignore_is_opening)
bs.update(pl)
return bs
def _get_rootwise_opening_duckdb(conn, filters, report_type, ignore_is_opening):
accounting_dimensions = get_accounting_dimensions(as_list=False)
ignore_closing_balances = frappe.get_single_value("Accounts Settings", "ignore_account_closing_balance")
last_pcv = ""
if not ignore_closing_balances:
last_pcv = frappe.db.get_all(
"Period Closing Voucher",
filters={"docstatus": 1, "company": filters.company, "period_end_date": ("<", filters.from_date)},
fields=["period_end_date", "name"],
order_by="period_end_date desc",
limit=1,
)
if last_pcv:
# Account Closing Balance fetched via frappe (not GL Entry)
gle = get_opening_balance(
"Account Closing Balance",
filters,
report_type,
accounting_dimensions,
period_closing_voucher=last_pcv[0].name,
ignore_is_opening=ignore_is_opening,
)
if getdate(last_pcv[0].period_end_date) < getdate(add_days(filters.from_date, -1)):
start_date = add_days(last_pcv[0].period_end_date, 1)
gle += _get_gl_entry_opening_duckdb(
conn, filters, report_type, ignore_is_opening, start_date=start_date
)
else:
gle = _get_gl_entry_opening_duckdb(conn, filters, report_type, ignore_is_opening)
opening = frappe._dict()
for d in gle:
opening.setdefault(d.account, {"account": d.account, "opening_debit": 0.0, "opening_credit": 0.0})
opening[d.account]["opening_debit"] += flt(d.debit)
opening[d.account]["opening_credit"] += flt(d.credit)
return opening
def _get_gl_entry_opening_duckdb(conn, filters, report_type, ignore_is_opening, start_date=None):
accounts = frappe.db.get_all("Account", filters={"report_type": report_type}, pluck="name")
if not accounts:
return []
conditions = ["company = ?", f"account IN ({', '.join(['?'] * len(accounts))})", "is_cancelled = 0"]
params = [filters.company, *accounts]
if start_date:
conditions.append("posting_date >= ? AND posting_date < ?")
params.extend([start_date, filters.from_date])
if not ignore_is_opening:
conditions.append("is_opening = 'No'")
elif not ignore_is_opening:
conditions.append("(posting_date < ? OR is_opening = 'Yes')")
params.append(filters.from_date)
else:
conditions.append("posting_date < ?")
params.append(filters.from_date)
if not filters.get("show_unclosed_fy_pl_balances") and report_type == "Profit and Loss":
conditions.append("posting_date >= ?")
params.append(filters.year_start_date)
if not flt(filters.get("with_period_closing_entry_for_opening")):
conditions.append("voucher_type != 'Period Closing Voucher'")
extra_cond, extra_params = _extra_gl_conditions(filters)
conditions.extend(extra_cond)
params.extend(extra_params)
gle = _fetch_gl_rows_duckdb(conn, conditions, params)
if filters.get("presentation_currency"):
convert_to_presentation_currency(gle, get_currency(filters))
return gle

View File

@@ -74,6 +74,15 @@ frappe.ui.form.on("Asset Repair", {
};
};
}
if (frm.doc.asset) {
frappe.db.get_value("Asset", frm.doc.asset, "status").then(({ message }) => {
frm.set_df_property(
"capitalize_repair_cost",
"read_only",
message && message.status === "Fully Depreciated"
);
});
}
},
repair_status: (frm) => {

View File

@@ -140,7 +140,7 @@
"fieldtype": "Link",
"in_list_view": 1,
"label": "Asset",
"link_filters": "[[\"Asset\",\"status\",\"not in\",[\"Work In Progress\",\"Capitalized\",\"Fully Depreciated\",\"Sold\",\"Scrapped\",\"Cancelled\",null]]]",
"link_filters": "[[\"Asset\",\"status\",\"not in\",[\"Work In Progress\",\"Capitalized\",\"Sold\",\"Scrapped\",\"Cancelled\",null]]]",
"options": "Asset",
"reqd": 1
},
@@ -251,7 +251,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2026-01-06 15:48:13.862505",
"modified": "2026-07-14 10:00:00.000000",
"modified_by": "Administrator",
"module": "Assets",
"name": "Asset Repair",

View File

@@ -77,12 +77,15 @@ class AssetRepair(AccountsController):
)
def validate_asset(self):
if self.asset_doc.status in ("Sold", "Fully Depreciated", "Scrapped"):
if self.asset_doc.status in ("Sold", "Scrapped"):
frappe.throw(
_("Asset {0} is in {1} status and cannot be repaired.").format(
get_link_to_form("Asset", self.asset), self.asset_doc.status
)
)
if self.asset_doc.get_status() == "Fully Depreciated":
self.capitalize_repair_cost = 0
self.increase_in_asset_life = 0
def validate_dates(self):
if self.completion_date and (getdate(self.failure_date) > getdate(self.completion_date)):

View File

@@ -347,6 +347,49 @@ class TestJobCard(FrappeTestCase):
job_card.reload()
self.assertEqual(job_card.transferred_qty, 0.0)
def test_work_order_transferred_qty_with_multiple_job_cards(self):
create_bom_with_multiple_operations()
work_order = make_wo_with_transfer_against_jc()
self.generate_required_stock(work_order)
job_cards = frappe.get_all(
"Job Card",
filters={"work_order": work_order.name},
pluck="name",
order_by="sequence_id",
)
completed_qty = (4, 3)
for job_card_name, qty in zip(job_cards, completed_qty, strict=True):
job_card = frappe.get_doc("Job Card", job_card_name)
job_card.for_quantity = qty
job_card.save()
transfer_entry = make_stock_entry_from_jc(job_card.name)
transfer_entry.fg_completed_qty = qty
transfer_entry.get_items()
transfer_entry.submit()
job_card.reload()
job_card.append(
"time_logs",
{
"from_time": now(),
"to_time": add_to_date(now(), hours=1),
"completed_qty": qty,
},
)
job_card.submit()
work_order.reload()
self.assertEqual(work_order.material_transferred_for_manufacturing, min(completed_qty))
# Refreshing required items must not replace the Job Card roll-up with the sum
# of FG quantities from Material Transfer Stock Entries (4 + 3).
work_order.update_required_items()
work_order.reload()
self.assertEqual(work_order.material_transferred_for_manufacturing, min(completed_qty))
def test_job_card_material_transfer_correctness(self):
"""
1. Test if only current Job Card Items are pulled in a Stock Entry against a Job Card

View File

@@ -1275,6 +1275,10 @@ class WorkOrder(Document):
def recompute_material_transferred_for_manufacturing(self, transferred_items):
"""Set material_transferred_for_manufacturing based on actual item-level transfers, not fg_completed_qty."""
# Job Card transfers use the minimum completed quantity across operations.
if self.operations and self.transfer_material_against == "Job Card":
return
# When fg_completed_qty > 0 (direct stock entries, excess transfer), preserve the
# SUM(fg_completed_qty) approach so excess-transfer tracking works correctly.
sum_fg_completed_qty = self.get_transferred_or_manufactured_qty("Material Transfer for Manufacture")

View File

@@ -15,6 +15,11 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
this.warehouse_field = opts.warehouse_field || "warehouse";
// field name on row which defines max quantity to be scanned e.g. picklist
this.max_qty_field = opts.max_qty_field;
// row fields that, if set, mean max_qty_field is a real demand qty (e.g. from a
// linked Sales Order) that scanning must not exceed. Rows with none of these set
// have no real demand qty, so max_qty_field is just an arbitrary default and
// shouldn't cap further scans.
this.demand_ref_fields = opts.demand_ref_fields || [];
// scanner won't add a new row if this flag is set.
this.dont_allow_new_row = opts.dont_allow_new_row;
// scanner will ask user to type the quantity instead of incrementing by 1
@@ -390,6 +395,9 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
}
async set_barcode_uom(row, uom) {
// e.g. Pick List: picked_qty is always tracked in stock UOM, so an incidental
// barcode uom must not overwrite the row's own uom.
if (this.max_qty_field) return;
if (uom && frappe.meta.has_field(row.doctype, this.uom_field)) {
await frappe.model.set_value(row.doctype, row.name, this.uom_field, uom);
}
@@ -456,8 +464,9 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
const matching_row = (row) => {
const item_match = row.item_code == item_code;
const batch_match = !row[this.batch_no_field] || row[this.batch_no_field] == batch_no;
const uom_match = !uom || row[this.uom_field] == uom;
const qty_in_limit = flt(row[this.qty_field]) < flt(row[this.max_qty_field]);
const uom_match = !uom || this.max_qty_field || row[this.uom_field] == uom;
const has_demand_qty = this.demand_ref_fields.some((fieldname) => row[fieldname]);
const qty_in_limit = !has_demand_qty || flt(row[this.qty_field]) < flt(row[this.max_qty_field]);
const item_scanned = row.has_item_scanned;
let warehouse_match = true;

View File

@@ -280,19 +280,17 @@ erpnext.sales_common = {
set_actual_qty(doc, cdt, cdn) {
let row = locals[cdt][cdn];
let sales_doctypes = ["Sales Invoice", "Delivery Note", "Sales Order"];
let sales_doctypes = ["Sales Invoice", "Delivery Note", "Sales Order", "Quotation"];
if (row.item_code && row.warehouse && sales_doctypes.includes(doc.doctype)) {
frappe.call({
return this.frm.call({
method: "erpnext.stock.get_item_details.get_bin_details",
child: row,
args: {
item_code: row.item_code,
warehouse: row.warehouse,
},
callback(r) {
if (r.message) {
frappe.model.set_value(cdt, cdn, "actual_qty", r.message.actual_qty);
}
company: doc.company,
include_child_warehouses: true,
},
});
}

View File

@@ -297,7 +297,8 @@ frappe.ui.form.on("Pick List", {
items_table_name: "locations",
qty_field: "picked_qty",
max_qty_field: "qty",
dont_allow_new_row: true,
demand_ref_fields: ["sales_order_item", "material_request_item", "product_bundle_item"],
dont_allow_new_row: !frm.doc.pick_manually,
prompt_qty: frm.doc.prompt_qty,
serial_no_field: "serial_no",
};

View File

@@ -2477,22 +2477,24 @@ def get_serial_nos_based_on_posting_date(kwargs, ignore_serial_nos):
def get_bundle_wise_serial_nos(data, kwargs):
bundle_wise_serial_nos = defaultdict(list)
bundles = [d.serial_and_batch_bundle for d in data if d.serial_and_batch_bundle]
bundles = list({d.serial_and_batch_bundle for d in data if d.serial_and_batch_bundle})
if not bundles:
return bundle_wise_serial_nos
filters = {"parent": ("in", bundles), "docstatus": 1, "serial_no": ("is", "set")}
if kwargs.get("check_serial_nos") and kwargs.get("serial_nos"):
filters["serial_no"] = ("in", kwargs.get("serial_nos"))
bundle_data = frappe.get_all(
"Serial and Batch Entry",
fields=["serial_no", "parent"],
filters=filters,
sabe = frappe.qb.DocType("Serial and Batch Entry")
query = (
frappe.qb.from_(sabe)
.select(sabe.serial_no, sabe.parent)
.where(sabe.parent.isin(bundles))
.where(sabe.docstatus == 1)
.where(sabe.serial_no.isnotnull())
.where(sabe.serial_no != "")
)
for d in bundle_data:
if kwargs.get("check_serial_nos") and kwargs.get("serial_nos"):
query = query.where(sabe.serial_no.isin(kwargs.get("serial_nos")))
for d in query.run(as_dict=True):
if d.parent:
bundle_wise_serial_nos[d.parent].append(d.serial_no)

View File

@@ -11,8 +11,12 @@ from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
add_serial_batch_ledgers,
combine_datetime,
get_available_batches_qty,
get_qty_based_available_batches,
get_type_of_transaction,
make_batch_nos,
make_serial_nos,
parse_serial_nos,
)
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
@@ -1383,3 +1387,128 @@ def make_serial_batch_bundle(kwargs):
return sb.make_serial_and_batch_bundle()
return sb
class TestSerialandBatchBundleLogic(FrappeTestCase):
"""Pure helpers and in-memory document validations, covering branches the
integration suite doesn't reach (no stock-ledger / serial / batch fixtures)."""
def test_parse_serial_nos_splits_and_trims(self):
self.assertEqual(parse_serial_nos("SN1\nSN2"), ["SN1", "SN2"])
self.assertEqual(parse_serial_nos("SN1, SN2 , SN3"), ["SN1", "SN2", "SN3"])
# blanks are dropped and an existing list is returned unchanged
self.assertEqual(parse_serial_nos("SN1,,\n , SN2"), ["SN1", "SN2"])
self.assertEqual(parse_serial_nos(["SN1", "SN2"]), ["SN1", "SN2"])
def test_get_qty_based_available_batches_allocates_across_batches(self):
batches = [
frappe._dict(batch_no="B1", qty=10, warehouse="W"),
frappe._dict(batch_no="B2", qty=5, warehouse="W"),
]
# 12 consumes B1 fully then 2 from B2
result = get_qty_based_available_batches(batches, 12)
self.assertEqual([(b.batch_no, b.qty) for b in result], [("B1", 10), ("B2", 2)])
# 8 is satisfied by B1 alone; B2 is not touched
result = get_qty_based_available_batches(batches, 8)
self.assertEqual([(b.batch_no, b.qty) for b in result], [("B1", 8)])
def test_get_available_batches_qty_aggregates_by_batch(self):
batches = [
frappe._dict(batch_no="B1", qty=10),
frappe._dict(batch_no="B2", qty=5),
frappe._dict(batch_no="B1", qty=3),
]
agg = get_available_batches_qty(batches)
self.assertEqual(agg["B1"], 13)
self.assertEqual(agg["B2"], 5)
def test_get_type_of_transaction_derives_direction(self):
def se(**kw):
return get_type_of_transaction(frappe._dict(doctype="Stock Entry"), frappe._dict(**kw))
self.assertEqual(se(s_warehouse="W"), "Outward") # issuing from a source warehouse
self.assertEqual(se(), "Inward") # only a target warehouse
self.assertEqual(
get_type_of_transaction(frappe._dict(doctype="Purchase Receipt"), frappe._dict()), "Inward"
)
self.assertEqual(
get_type_of_transaction(frappe._dict(doctype="Stock Reconciliation"), frappe._dict()), "Inward"
)
# a purchase return reverses the direction to Outward
self.assertEqual(
get_type_of_transaction(frappe._dict(doctype="Purchase Receipt", is_return=1), frappe._dict()),
"Outward",
)
def test_duplicate_serial_no_in_entries_is_rejected(self):
doc = frappe.new_doc("Serial and Batch Bundle")
doc.append("entries", {"serial_no": "SN1"})
doc.append("entries", {"serial_no": "SN1"})
self.assertRaises(frappe.ValidationError, doc.validate_duplicate_serial_and_batch_no)
def test_duplicate_batch_no_in_entries_is_rejected(self):
doc = frappe.new_doc("Serial and Batch Bundle")
doc.append("entries", {"batch_no": "B1"})
doc.append("entries", {"batch_no": "B1"})
self.assertRaises(frappe.ValidationError, doc.validate_duplicate_serial_and_batch_no)
def test_voucher_no_is_mandatory(self):
doc = frappe.new_doc("Serial and Batch Bundle")
self.assertRaises(frappe.ValidationError, doc.validate_serial_and_batch_data)
def test_calculate_total_qty_normalizes_and_signs(self):
inward = frappe.new_doc("Serial and Batch Bundle")
inward.type_of_transaction = "Inward"
inward.append("entries", {"qty": 5})
inward.append("entries", {"qty": 3})
inward.calculate_total_qty(save=False)
self.assertEqual(inward.total_qty, 8)
# Outward flips the sign
outward = frappe.new_doc("Serial and Batch Bundle")
outward.type_of_transaction = "Outward"
outward.append("entries", {"qty": 5})
outward.calculate_total_qty(save=False)
self.assertEqual(outward.total_qty, -5)
# a serialized bundle normalizes each row qty to 1
serialized = frappe.new_doc("Serial and Batch Bundle")
serialized.has_serial_no = 1
serialized.type_of_transaction = "Inward"
serialized.append("entries", {"qty": 5})
serialized.calculate_total_qty(save=False)
self.assertEqual(serialized.total_qty, 1)
def test_get_bundle_wise_serial_nos(self):
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
get_bundle_wise_serial_nos,
)
item_code = make_item(properties={"has_serial_no": 1, "serial_no_series": "TEST-BWSN-.#####"}).name
bundles = []
for _ in range(2):
se = make_stock_entry(
item_code=item_code,
target="_Test Warehouse - _TC",
qty=3,
rate=100,
)
bundles.append(se.items[0].serial_and_batch_bundle)
data = [frappe._dict(serial_and_batch_bundle=bundle) for bundle in bundles]
self.assertEqual(get_bundle_wise_serial_nos([], {}), {})
bundle_wise_serial_nos = get_bundle_wise_serial_nos(data, {})
for bundle in bundles:
self.assertEqual(sorted(bundle_wise_serial_nos[bundle]), get_serial_nos_from_bundle(bundle))
# check_serial_nos must restrict the result to the requested serial nos
serial_no = get_serial_nos_from_bundle(bundles[0])[0]
bundle_wise_serial_nos = get_bundle_wise_serial_nos(
data, {"check_serial_nos": True, "serial_nos": [serial_no]}
)
self.assertNotIn(bundles[1], bundle_wise_serial_nos)
self.assertEqual(bundle_wise_serial_nos[bundles[0]], [serial_no])

View File

@@ -177,10 +177,9 @@ def update_bin_details(args, out, doc):
out.update(get_bin_details(args.item_code, args.get("from_warehouse")))
elif out.get("warehouse"):
company = args.company if (doc and doc.get("doctype") == "Purchase Order") else None
# calculate company_total_stock only for po
bin_details = get_bin_details(args.item_code, out.warehouse, company, include_child_warehouses=True)
bin_details = get_bin_details(
args.item_code, out.warehouse, args.company, include_child_warehouses=True
)
out.update(bin_details)

View File

@@ -35,6 +35,40 @@ class TestGetItemDetail(FrappeTestCase):
details = get_item_details(args)
self.assertEqual(details.get("price_list_rate"), 100)
def test_bin_details_for_selling_doctypes(self):
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
item_code = make_item(properties={"is_stock_item": 1}).name
make_purchase_receipt(item_code=item_code, warehouse="_Test Warehouse - _TC", qty=100, rate=100)
make_purchase_receipt(item_code=item_code, warehouse="_Test Warehouse 1 - _TC", qty=50, rate=100)
args = frappe._dict(
{
"item_code": item_code,
"warehouse": "_Test Warehouse - _TC",
"company": "_Test Company",
"customer": "_Test Customer",
"currency": "INR",
"conversion_rate": 1.0,
"price_list": "_Test Price List",
"price_list_currency": "INR",
"plc_conversion_rate": 1.0,
"transaction_date": None,
"name": None,
"ignore_pricing_rule": 1,
"qty": 1,
}
)
for doctype in ("Sales Order", "Quotation", "Sales Invoice", "Delivery Note", "Purchase Order"):
with self.subTest(doctype=doctype):
details = get_item_details(args.copy().update({"doctype": doctype}))
self.assertEqual(details.get("actual_qty"), 100)
self.assertEqual(details.get("company_total_stock"), 150)
def test_fetch_asset_category_expense_account_on_purchase_receipt(self):
from erpnext.stock.doctype.item.test_item import make_item