Compare commits

...

66 Commits

Author SHA1 Message Date
mergify[bot]
0817d1064c fix: permission issue (backport #57112) (backport #57142) (#57184)
* fix: permission issue (backport #57112) (#57142)

* fix: permission issue (#57112)

(cherry picked from commit 1fd2faa68d)

# Conflicts:
#	erpnext/controllers/stock_controller.py

* chore: fix conflicts

---------

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
(cherry picked from commit 6b23b007a4)

# Conflicts:
#	erpnext/controllers/stock_controller.py

* chore: fix conflicts

* chore: fix conflicts

---------

Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-07-15 15:05:17 +05:30
mergify[bot]
b1adec7e9e fix(project): improved access control for project users (backport #56675) (#57181)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-07-15 14:19:07 +05:30
mergify[bot]
caea21208e Revert "chore: remove unused whitelisted method from project" (backport #56660) (#57178)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-07-15 07:09:36 +00:00
Mihir Kandoi
2808b34d42 Merge pull request #57170 from mihir-kandoi/backport-57169-version-16-hotfix
fix: allow delivery when a batch is reserved across multiple sales orders (backport #57169)
2026-07-15 12:31:39 +05:30
Mihir Kandoi
7ccb2584c4 test: set delivered batch on the row explicitly on v16
The v16 reserved-stock mapper attaches neither a bundle nor row
serial/batch fields when use_serial_batch_fields is enabled, and
update_stock_reservation_entries crashes on the missing bundle (fixed
on develop by 9c5f9218b5, not backported). Deliver via an explicit row
batch_no like the guard test so the bundle is built from row fields
before the reservation update runs.
2026-07-15 12:19:21 +05:30
Mihir Kandoi
f3e1b3fca7 fix: round outstanding qty guard consistently with the conflict gate 2026-07-15 12:00:02 +05:30
Mihir Kandoi
555c607f2f fix: exclude fully-delivered reservations from the conflict message 2026-07-15 12:00:02 +05:30
Mihir Kandoi
7cd7e4ab0f test: deliver reserved batches via bundles on v16
use_serial_batch_fields delivery of reserved stock crashes on v16 with
'Serial and Batch Bundle None not found' (fixed on develop only), so
deliver through auto-created bundles like test_auto_reserve_serial_and_batch.
2026-07-15 12:00:02 +05:30
Mihir Kandoi
839fd5e3b6 fix: name every conflicting voucher in the reserved batch error 2026-07-15 12:00:02 +05:30
Mihir Kandoi
56bbca0203 fix: allow delivery when a batch is reserved across multiple sales orders (backport #57169)
validate_reserved_batches compared the voucher's own qty against the
remaining batch qty, so delivering one order's reserved unit threw
Reserved Batch Conflict whenever the remainder exactly matched another
order's reservation. Compare the remaining batch qty against the
aggregated outstanding reserved qty (qty - delivered_qty) of other
vouchers instead, excluding reservations the voucher itself delivers.
2026-07-15 12:00:02 +05:30
Mihir Kandoi
478719b828 Merge pull request #57173 from frappe/mergify/bp/version-16-hotfix/pr-57137
feat: add on hold status to project (backport #57137)
2026-07-15 11:29:14 +05:30
Poovitha Palanivelu
57a49ce168 feat: add on hold status to project
(cherry picked from commit 672fadaa78)
2026-07-15 05:47:16 +00:00
Mihir Kandoi
6d47c51c5b Merge pull request #57166 from frappe/mergify/bp/version-16-hotfix/pr-57163
fix: hide job card field in purchase order item (backport #57163)
2026-07-15 11:03:06 +05:30
Mihir Kandoi
6ab1668b37 Merge pull request #57168 from frappe/mergify/bp/version-16-hotfix/pr-57164
fix: set correct currency in supplier quotation net rate field (backport #57164)
2026-07-15 10:58:18 +05:30
Mihir Kandoi
b256c76c3b chore: resolve conflicts 2026-07-15 10:48:51 +05:30
Mihir Kandoi
e24ef847dc fix: set correct currency in supplier quotation net rate field
(cherry picked from commit 27672851cd)
2026-07-15 05:17:11 +00:00
Mihir Kandoi
20f6ac81b9 fix: hide job card field in purchase order item
(cherry picked from commit f44bcae47d)

# Conflicts:
#	erpnext/buying/doctype/purchase_order_item/purchase_order_item.json
2026-07-15 05:13:39 +00:00
Mihir Kandoi
b1ca9c26cb Merge pull request #57136 from frappe/mergify/bp/version-16-hotfix/pr-57129
fix: prevent duplicate material request items in purchase order (backport #57129)
2026-07-14 16:43:09 +05:30
Mihir Kandoi
4d931a7108 test: remove test
(cherry picked from commit b2ec906ff3)
2026-07-14 11:01:57 +00:00
SandraFrappe
4d951c1cf8 fix: prevent duplicate material request items in purchase order
(cherry picked from commit 2d6f89a7f5)
2026-07-14 11:01:57 +00:00
Mihir Kandoi
3a38316c26 Merge pull request #57126 from aerele/fix/job-card-work-order-transferred-qty-v16
fix(manufacturing): preserve job card transferred quantity
2026-07-14 13:40:29 +05:30
Sudharsanan11
fd6c9a71cd test(manufacturing): cover transferred quantity across job cards 2026-07-14 13:23:49 +05:30
Sudharsanan11
d7e9321746 fix(manufacturing): preserve job card transferred quantity 2026-07-14 13:23:28 +05:30
mergify[bot]
e8a532587d fix(accounts): added permission checks on get_account_balances_coa (backport #57107) (#57122)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-07-14 13:07:12 +05:30
Mihir Kandoi
8d7fe755e3 Merge pull request #57118 from frappe/mergify/bp/version-16-hotfix/pr-57116
fix(stock): fix sqlparse token limit in get_bundle_wise_serial_nos (backport #57116)
2026-07-14 12:33:43 +05:30
Khushi Rawat
2947f030e8 Merge pull request #57119 from frappe/mergify/bp/version-16-hotfix/pr-57111
chore: remove dead assets dashboard_fixtures with broken imports (backport #57111)
2026-07-14 12:14:47 +05:30
Afsal Syed
cec1e87c90 test(stock): add unit test for get_bundle_wise_serial_nos query
(cherry picked from commit e748bf512b)
2026-07-14 12:13:42 +05:30
khushi8112
345c508be7 chore: remove dead assets dashboard_fixtures with broken imports (#57079)
erpnext.accounts.dashboard_fixtures and erpnext.buying.dashboard_fixtures
were removed in 2020 when dashboards were exported to JSON fixtures.
The assets module's dashboard_fixtures.py was left behind unreferenced;
its dashboard, charts and number cards already exist as exported JSON.

(cherry picked from commit 14a15cc6f9)
2026-07-14 06:23:40 +00:00
Afsal Syed
5a99dd6016 fix(stock): fix sqlparse token limit in get_bundle_wise_serial_nos
(cherry picked from commit 4544a6c935)
2026-07-14 06:00:14 +00:00
Mihir Kandoi
3b7467daab Merge pull request #57113 from PranavDarade/fix/transfer-reservation-stock-uom
fix(stock): set stock_uom on transferred Stock Reservation Entries
2026-07-14 10:14:59 +05:30
Khushi Rawat
db737da22e Merge pull request #57077 from frappe/mergify/bp/version-16-hotfix/pr-55276
fix(asset): allow asset repair creation for fully depreciated assets (backport #55276)
2026-07-14 00:24:20 +05:30
Khushi Rawat
138a44e450 Merge pull request #56966 from frappe/mergify/bp/version-16-hotfix/pr-56964
fix: match depreciation schedule rows at currency precision to avoid duplicate JEs (backport #56964)
2026-07-14 00:09:32 +05:30
Diptanil Saha
40c85a0087 fix(tnc): get_terms_and_conditions render_template with safe_exec (backport #56944) (#56977) 2026-07-13 23:22:55 +05:30
Mihir Kandoi
4f59e57151 Merge pull request #57105 from frappe/mergify/bp/version-16-hotfix/pr-57091
fix(stock): show qty (company) and qty (warehouse) in sales transactions  (backport #57091)
2026-07-13 21:27:54 +05:30
Mihir Kandoi
0447a18217 Merge pull request #57103 from frappe/mergify/bp/version-16-hotfix/pr-57089
fix: allow barcode scan to add and increment items in pick list (backport #57089)
2026-07-13 21:27:40 +05:30
PranavDarade
5991ecfa3d fix(stock): set stock_uom on transferred Stock Reservation Entries
StockReservation.transfer_reservation_entries_to() created the transferred SREs without copying stock_uom, in both the entries_to_reserve dict and the extra-items fallback. get_items_to_reserve() already selects the item's stock_uom, so entry.stock_uom is used.

On sites with a global default stock_uom (e.g. "Nos"), frappe's _set_defaults() backfilled the blank field, so the transfer silently stored the wrong UOM for any item whose stock UOM is not the default. On sites without that default the SRE's validate_mandatory() raised "Stock UOM is required", aborting Work Order submission for the Subcontracting Inward Order / Production Plan flows.
2026-07-13 21:21:03 +05:30
Sudharsanan11
77cca4464d test(stock): assert qty (company) and qty (warehouse) on item details
covers sales order, quotation, sales invoice, delivery note and
purchase order, asserting actual_qty from the row warehouse and
company_total_stock across all warehouses of the company.

(cherry picked from commit 4e5e1f6596)
2026-07-13 15:34:10 +00:00
Sudharsanan11
0d9ace7ab8 fix(stock): show qty (company) and qty (warehouse) in sales transactions
company was passed to get_bin_details only for purchase order, so
company_total_stock was never returned for sales order, quotation,
sales invoice and delivery note and the qty (company) column always
read zero. pass ctx.company for every doctype, which also drops the
dependency on doc being supplied.

on the client, set_actual_qty copied only actual_qty out of the
response, so qty (company) never refreshed on a warehouse change. use
frm.call with child so every bin field is applied, pass
include_child_warehouses to match the server, and include quotation.

(cherry picked from commit ab30bab6cb)
2026-07-13 15:34:09 +00:00
pandiyan
00a646ac25 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:33:11 +00:00
ruthra kumar
aa1d50a914 Merge pull request #57100 from frappe/mergify/bp/version-16-hotfix/pr-56706
refactor: rename synced to snapshot report (backport #56706)
2026-07-13 20:02:49 +05:30
ruthra kumar
c03d115999 refactor: rename feature toggle in report master
(cherry picked from commit 981e90e4da)
2026-07-13 13:17:01 +00:00
ruthra kumar
97a7a2d6bc 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 13:17:01 +00:00
ruthra kumar
d784b9a7be Merge pull request #57098 from frappe/mergify/bp/version-16-hotfix/pr-56304
feat: faster (synced) financial statements using duckdb (backport #56304)
2026-07-13 18:08:15 +05:30
ruthra kumar
88b7a38be4 refactor: synced reports should be enabled on sites based on requirements
(cherry picked from commit 963bbc8729)
2026-07-13 12:16:28 +00:00
ruthra kumar
21ddb00e20 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)
2026-07-13 12:16:28 +00:00
ruthra kumar
636bcbedc0 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)
2026-07-13 12:16:27 +00:00
ruthra kumar
19ec095ff8 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)
2026-07-13 12:16:27 +00:00
ruthra kumar
2f6ef7b2ec refactor: DB agnostic method names
(cherry picked from commit f40cd41801)
2026-07-13 12:16:26 +00:00
ruthra kumar
d41b9f11ff refactor: maintain sync dependency in report master
(cherry picked from commit 5c536b8ad1)
2026-07-13 12:16:25 +00:00
ruthra kumar
98a65f7529 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)
2026-07-13 12:16:24 +00:00
ruthra kumar
228418b05f 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)
2026-07-13 12:16:24 +00:00
ruthra kumar
08e267271e refactor: reports on duckdb
(cherry picked from commit adb768505a)
2026-07-13 12:16:23 +00:00
mergify[bot]
bf1b7f2bea feat: weekly auto-repost of incorrect stock valuation entries (backport #56637) (#56700)
* feat: weekly auto-repost of incorrect stock valuation entries (#56637)

(cherry picked from commit adae0bd732)

# Conflicts:
#	erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.py

* chore: fix conflicts

Removed merge conflict markers and cleaned up code.

---------

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-07-13 10:11:12 +00:00
ruthra kumar
6bd903e45a Merge pull request #57085 from frappe/mergify/bp/version-16-hotfix/pr-56817
feat: restructure Philippines chart of accounts with amortization sup… (backport #56817)
2026-07-13 13:08:42 +05:30
ruthra kumar
5281d538ce Merge pull request #56817 from Soham-ambibuzz/philipinnes_localization_coa_v3
feat: restructure Philippines chart of accounts with amortization sup…
(cherry picked from commit 33abc53d7a)
2026-07-13 07:28:54 +00:00
ruthra kumar
5261566b41 Merge pull request #57081 from frappe/mergify/bp/version-16-hotfix/pr-56902
fix(budget-variance): correct month shift in comparison chart (backport #56902)
2026-07-13 12:55:57 +05:30
ruthra kumar
5bbb71ef76 Merge pull request #57083 from frappe/mergify/bp/version-16-hotfix/pr-56628
fix(journal-entry): fetch outstanding on foreign currency (backport #56628)
2026-07-13 12:50:49 +05:30
Ejaaz Khan
d453781374 Merge pull request #57080 from frappe/mergify/bp/version-16-hotfix/pr-57071
fix: guard company logo lookup in default letterheads (backport #57071)
2026-07-13 12:40:06 +05:30
SowmyaArunachalam
ec782ee20d fix(journal-entry): fetch outstanding on foreign currency
(cherry picked from commit 07f641c48c)
2026-07-13 07:07:59 +00:00
Mohd Haris
703e9a728c fix(budget-variance): correct month shift in comparison chart
The Budget Variance Report chart plotted the actual expense one month
earlier than the table (e.g. July actual shown under June).

build_comparison_chart_data() collected budget columns using
fieldname.startswith("budget_"). The dimension column "budget_against"
also matches that prefix, so it was added as an extra leading entry to
budget_fields and labels, while actual_fields had no such leading entry.
This shifted every actual value one position ahead of its label.

Skip the "budget_against" dimension column so budget/actual values and
labels stay aligned per month.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 48418eadb0)
2026-07-13 07:02:18 +00:00
Ejaaz Khan
0d80fef3bf fix: set explicit table and logo widths in grey letterhead
(cherry picked from commit e39ca72997)
2026-07-13 06:29:01 +00:00
Ejaaz Khan
7b543142a2 fix: guard company logo lookup in default letterheads
(cherry picked from commit 23c09fe0f3)
2026-07-13 06:29:01 +00:00
Khushi Rawat
9d6f15acd4 Merge branch 'version-16-hotfix' into mergify/bp/version-16-hotfix/pr-56964 2026-07-13 02:10:42 +05:30
S Sakthivel Murugan
8658039e9a fix(asset): allow asset repair creation for fully depreciated assets
(cherry picked from commit c7774a95e5)
2026-07-12 20:40:16 +00:00
khushi8112
d3a8e91cda fix: apply precision fix inline for v16-hotfix, drop develop-only asset_service refactor 2026-07-09 15:25:01 +05:30
khushi8112
a1fae959ed fix: match depreciation schedule rows at currency precision to avoid duplicate JEs
(cherry picked from commit 947ed5dfe1)

# Conflicts:
#	erpnext/accounts/doctype/journal_entry/services/asset_service.py
2026-07-09 09:14:03 +00:00
53 changed files with 2097 additions and 364 deletions

View File

@@ -22,12 +22,12 @@
"account_type": "Cash"
},
"Petty Cash Fund": {
"account_number": "1200",
"account_number": "1110",
"is_group": 1,
"root_type": "Asset",
"account_type": "Cash",
"Petty Cash Fund": {
"account_number": "1201",
"account_number": "1111",
"is_group": 0,
"root_type": "Asset",
"account_type": "Cash"
@@ -35,10 +35,16 @@
}
},
"Bank Accounts": {
"account_number": "1102",
"account_number": "1200",
"is_group": 1,
"root_type": "Asset",
"account_type": "Bank"
"account_type": "Bank",
"Cash in Bank - Checking Account": {
"account_number": "1201",
"is_group": 0,
"root_type": "Asset",
"account_type": "Bank"
}
},
"Advances to Officers & Employees": {
"account_number": "1290",
@@ -104,25 +110,20 @@
"account_number": "1511",
"is_group": 0,
"root_type": "Asset"
},
"Factory Overhead Variance": {
"account_number": "1512",
"is_group": 0,
"root_type": "Asset"
}
},
"Finished Goods": {
"account_number": "1520",
"account_number": "1540",
"is_group": 1,
"root_type": "Asset",
"Finished Goods Inventory": {
"account_number": "1531",
"account_number": "1541",
"is_group": 0,
"root_type": "Asset",
"account_type": "Stock"
},
"Inventory in Transit": {
"account_number": "1532",
"account_number": "1542",
"is_group": 0,
"root_type": "Asset",
"account_type": "Stock Adjustment"
@@ -268,7 +269,7 @@
"root_type": "Asset"
}
},
"System Development": {
"Intangible Assets": {
"account_number": "1940",
"is_group": 1,
"root_type": "Asset",
@@ -277,6 +278,17 @@
"is_group": 0,
"root_type": "Asset"
}
},
"Accumulated Amortization - Intangible Assets": {
"account_number": "1950",
"is_group": 1,
"root_type": "Asset",
"Accum Amortization - System Development": {
"account_number": "1951",
"is_group": 0,
"root_type": "Asset",
"account_type": "Accumulated Depreciation"
}
}
}
},
@@ -562,6 +574,28 @@
"is_group": 0,
"root_type": "Income"
}
},
"Exchange Gain": {
"account_number": "6030",
"is_group": 1,
"root_type": "Income",
"Exchange Gain - Detail": {
"account_number": "6031",
"is_group": 0,
"root_type": "Income",
"account_type": "Indirect Income"
}
},
"Gain on Asset Disposal": {
"account_number": "6040",
"is_group": 1,
"root_type": "Income",
"Gain on Asset Disposal - Detail": {
"account_number": "6041",
"is_group": 0,
"root_type": "Income",
"account_type": "Indirect Income"
}
}
}
},
@@ -574,7 +608,7 @@
"is_group": 1,
"root_type": "Expense",
"Cost of Goods Sold": {
"account_number": "5010",
"account_number": "5002",
"is_group": 0,
"root_type": "Expense",
"account_type": "Cost of Goods Sold"
@@ -827,20 +861,61 @@
"root_type": "Expense"
}
},
"Stock Adjustment": {
"Other Expenses": {
"account_number": "5200",
"is_group": 1,
"root_type": "Expense",
"Bank Charges": {
"account_number": "5201",
"is_group": 0,
"root_type": "Expense",
"account_type": "Indirect Expense"
},
"Interest Expenses Bank": {
"account_number": "5202",
"is_group": 0,
"root_type": "Expense",
"account_type": "Indirect Expense"
},
"Write Off": {
"account_number": "5203",
"is_group": 0,
"root_type": "Expense",
"account_type": "Indirect Expense"
},
"Exchange Loss": {
"account_number": "5204",
"is_group": 0,
"root_type": "Expense",
"account_type": "Indirect Expense"
},
"Loss on Asset Disposal": {
"account_number": "5205",
"is_group": 0,
"root_type": "Expense",
"account_type": "Indirect Expense"
}
},
"Provision For Income Tax": {
"account_number": "5300",
"is_group": 0,
"root_type": "Expense",
"account_type": "Tax"
},
"Stock Adjustment": {
"account_number": "5400",
"is_group": 0,
"root_type": "Expense",
"account_type": "Stock Adjustment"
},
"Round Off": {
"account_number": "5300",
"account_number": "5500",
"is_group": 0,
"root_type": "Expense",
"account_type": "Round Off"
},
"Expenses Included In Valuation": {
"account_number": "5400",
"account_number": "5600",
"is_group": 0,
"root_type": "Expense",
"account_type": "Expenses Included In Valuation"

View File

@@ -417,11 +417,12 @@ class JournalEntry(AccountsController):
def update_journal_entry_link_on_depr_schedule(self, asset, je_row):
depr_schedule = get_depr_schedule(asset.name, "Active", self.finance_book)
precision = je_row.precision("debit")
for d in depr_schedule or []:
if (
d.schedule_date == self.posting_date
and not d.journal_entry
and d.depreciation_amount == flt(je_row.debit)
and flt(d.depreciation_amount, precision) == flt(je_row.debit, precision)
):
frappe.db.set_value("Depreciation Schedule", d.name, "journal_entry", self.name)

View File

@@ -2814,9 +2814,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

@@ -1,6 +1,6 @@
{
"align": "Left",
"content": "<table class=\"invoice-header\">\n\t<tbody>\n\t\t<tr>\n\t\t\t<td class=\"logo-cell\" style=\"vertical-align:middle ! important\">\n\t\t\t\t<div class=\"logo-container\">\n\t\t\t\t\t{% set company_logo = frappe.db.get_value(\"Company\", doc.company, \"company_logo\") %} {% if\n\t\t\t\t\tcompany_logo %}\n\t\t\t\t\t<img src=\"{{ frappe.utils.get_url(company_logo) }}\" alt=\"Company Logo\">\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t</td>\n\n\t\t\t<td class=\"company-details\">\n\t\t\t\t{% if doc.company %}<div class=\"company-name\">{{ doc.company }}</div>{% endif %}\n\t\t\t\t{% if doc.company_address %} {% set company_address = frappe.db.get_value(\"Address\",\n\t\t\t\tdoc.company_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\",\n\t\t\t\t\"country\"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =\n\t\t\t\tfrappe.db.get_value(\"Address\", doc.billing_address, [\"address_line1\", \"address_line2\", \"city\",\n\t\t\t\t\"state\", \"pincode\", \"country\"], as_dict=True) %} {% endif %} {% if company_address %} {{\n\t\t\t\tcompany_address.address_line1 or \"\" }}<br>\n\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }}<br>\n\t\t\t\t{% endif %} {{ company_address.city or \"\" }}, {{ company_address.state or \"\" }} {{\n\t\t\t\tcompany_address.pincode or \"\" }}, {{ company_address.country or \"\"}}<br>\n\t\t\t\t{% endif %}\n\t\t\t</td>\n\n\t\t\t<td class=\"invoice-info-cell\">\n\t\t\t\t{% set website = frappe.db.get_value(\"Company\", doc.company, \"website\") %} {% set email =\n\t\t\t\tfrappe.db.get_value(\"Company\", doc.company, \"email\") %} {% set phone_no =\n\t\t\t\tfrappe.db.get_value(\"Company\", doc.company, \"phone_no\") %}\n\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ doc.doctype }}</span>\n\t\t\t\t\t<span>{{ doc.name }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% if website %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Website:\") }}</span>\n\t\t\t\t\t<span>{{ website }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Email:\") }}</span>\n\t\t\t\t\t<span>{{ email }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Contact:\") }}</span>\n\t\t\t\t\t<span>{{ phone_no }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %}\n\t\t\t</td>\n\t\t</tr>\n\t</tbody>\n</table>",
"content": "<table class=\"invoice-header\">\n\t<tbody>\n\t\t<tr>\n\t\t\t<td class=\"logo-cell\" style=\"vertical-align:middle ! important\">\n\t\t\t\t<div class=\"logo-container\">\n\t\t\t\t\t{% set company_logo = frappe.db.get_value(\"Company\", doc.company, \"company_logo\") if doc.get(\"company\") else None %} {% if\n\t\t\t\t\tcompany_logo %}\n\t\t\t\t\t<img src=\"{{ frappe.utils.get_url(company_logo) }}\" alt=\"Company Logo\">\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t</td>\n\n\t\t\t<td class=\"company-details\">\n\t\t\t\t{% if doc.company %}<div class=\"company-name\">{{ doc.company }}</div>{% endif %}\n\t\t\t\t{% if doc.company_address %} {% set company_address = frappe.db.get_value(\"Address\",\n\t\t\t\tdoc.company_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\",\n\t\t\t\t\"country\"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =\n\t\t\t\tfrappe.db.get_value(\"Address\", doc.billing_address, [\"address_line1\", \"address_line2\", \"city\",\n\t\t\t\t\"state\", \"pincode\", \"country\"], as_dict=True) %} {% endif %} {% if company_address %} {{\n\t\t\t\tcompany_address.address_line1 or \"\" }}<br>\n\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }}<br>\n\t\t\t\t{% endif %} {{ company_address.city or \"\" }}, {{ company_address.state or \"\" }} {{\n\t\t\t\tcompany_address.pincode or \"\" }}, {{ company_address.country or \"\"}}<br>\n\t\t\t\t{% endif %}\n\t\t\t</td>\n\n\t\t\t<td class=\"invoice-info-cell\">\n\t\t\t\t{% set website = frappe.db.get_value(\"Company\", doc.company, \"website\") if doc.get(\"company\") else None %} {% set email =\n\t\t\t\tfrappe.db.get_value(\"Company\", doc.company, \"email\") if doc.get(\"company\") else None %} {% set phone_no =\n\t\t\t\tfrappe.db.get_value(\"Company\", doc.company, \"phone_no\") if doc.get(\"company\") else None %}\n\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ doc.doctype }}</span>\n\t\t\t\t\t<span>{{ doc.name }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% if website %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Website:\") }}</span>\n\t\t\t\t\t<span>{{ website }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Email:\") }}</span>\n\t\t\t\t\t<span>{{ email }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Contact:\") }}</span>\n\t\t\t\t\t<span>{{ phone_no }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %}\n\t\t\t</td>\n\t\t</tr>\n\t</tbody>\n</table>",
"creation": "2026-05-15 15:21:48.255627",
"custom_css": "\t.letter-head {\n\t\tborder-radius: 18px;\n\t\tpadding-right: 12px;\n\t\tmargin-left: 12px;\n\t\tmargin-right: 12px;\n\t}\n\n\t.letter-head td {\n\t\tpadding: 0px !important;\n\t}\n\t.invoice-header {\n\t\twidth: 100%;\n\t}\n\t.logo-cell {\n\t\twidth: 100px;\n\t\ttext-align: center;\n\t\tposition: relative;\n\t}\n\t.logo-container {\n\t\twidth: 90px;\n\t\tdisplay: block;\n\t}\n\t.logo-container img {\n\t\tmax-width: 90px;\n\t\tmax-height: 90px;\n\t\tdisplay: inline-block;\n\t\tborder-radius: 15px;\n\t}\n\t.company-details {\n\t\twidth: 40%;\n\t\talign-content: center;\n\t}\n\t.company-name {\n\t\tfont-size: 14px;\n\t\tfont-weight: bold;\n\t\tcolor: #171717;\n\t\tmargin-bottom: 4px;\n\t}\n\t.invoice-info-cell {\n\t\tfloat: right;\n\t\tvertical-align: top;\n\t}\n\t.invoice-info {\n\t\tmargin-bottom: 2px;\n\t}\n\t.invoice-label {\n\t\tcolor: #7c7c7c;\n\t\tdisplay: inline-block;\n\t\tmargin-right: 5px;\n\t}",
"disabled": 0,
@@ -16,7 +16,7 @@
"is_default": 0,
"letter_head_for": "DocType",
"letter_head_name": "Company Letterhead",
"modified": "2026-06-24 17:49:52.350750",
"modified": "2026-07-12 21:11:44.765083",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Company Letterhead",

View File

@@ -1,6 +1,6 @@
{
"align": "Left",
"content": "<table class=\"letterhead-container\">\n\t<tbody>\n\t\t<tr>\n\t\t\t<td class=\"logo-address\">\n\t\t\t\t{% set company_logo = frappe.db.get_value(\"Company\", doc.company, \"company_logo\") %} {% if\n\t\t\t\tcompany_logo %}\n\t\t\t\t<div class=\"logo\">\n\t\t\t\t\t<img src=\"{{ frappe.utils.get_url(company_logo) }}\">\n\t\t\t\t</div>\n\t\t\t\t{% endif %}\n\t\t\t\t{% if doc.company %}<div class=\"company-name\">{{ doc.company }}</div>{% endif %}\n\t\t\t\t<div class=\"company-address\">\n\t\t\t\t\t{% if doc.company_address %} {% set company_address = frappe.db.get_value(\"Address\",\n\t\t\t\t\tdoc.company_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\",\n\t\t\t\t\t\"country\"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =\n\t\t\t\t\tfrappe.db.get_value(\"Address\", doc.billing_address, [\"address_line1\", \"address_line2\",\n\t\t\t\t\t\"city\", \"state\", \"pincode\", \"country\"], as_dict=True) %} {% endif %} {% if company_address\n\t\t\t\t\t%} {{ company_address.address_line1 or \"\" }}<br>\n\t\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }}<br>\n\t\t\t\t\t{% endif %} {{ company_address.city or \"\" }}, {{ company_address.state or \"\" }} {{\n\t\t\t\t\tcompany_address.pincode or \"\" }}, {{ company_address.country or \"\"}}<br>\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t</td>\n\n\t\t\t<td style=\"vertical-align:top\">\n\t\t\t\t<div style=\"height:90px;margin-bottom:10px;text-align:right\">\n\t\t\t\t\t<div class=\"invoice-title\">{{ doc.doctype }}</div>\n\t\t\t\t\t<div class=\"invoice-number\">{{ doc.name }}</div>\n\t\t\t\t\t<br>\n\t\t\t\t</div>\n\t\t\t\t<div style=\"text-align:left;float:right\" class=\"other-details\">\n\t\t\t\t\t{% if doc.company %}{% set company_details = frappe.db.get_value(\"Company\", doc.company, [\"website\", \"email\",\n\t\t\t\t\t\"phone_no\"], as_dict=True) %}{% set website = company_details.website %}{% set email =\n\t\t\t\t\tcompany_details.email %}{% set phone_no = company_details.phone_no %}{% else %}{% set website = None %}{% set email = None %}{% set phone_no = None %}{% endif %} {% if website %}\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<span class=\"contact-title\">{{ _(\"Website:\") }}</span><span class=\"contact-value\">{{ website }}</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<span class=\"contact-title\">{{ _(\"Email:\") }}</span><span class=\"contact-value\">{{ email }}</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<span class=\"contact-title\">{{ _(\"Contact:\") }}</span><span class=\"contact-value\">{{ phone_no }}</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t</td>\n\t\t</tr>\n\t</tbody>\n</table>\n",
"content": "<table class=\"letterhead-container\" style=\"width:100%\">\n\t<tbody>\n\t\t<tr>\n\t\t\t<td class=\"logo-address\">\n\t\t\t\t{% set company_logo = frappe.db.get_value(\"Company\", doc.company, \"company_logo\") if doc.get(\"company\") else None %} {% if\n\t\t\t\tcompany_logo %}\n\t\t\t\t<div class=\"logo\">\n\t\t\t\t\t<img src=\"{{ frappe.utils.get_url(company_logo) }}\" style=\"width:200px\">\n\t\t\t\t</div>\n\t\t\t\t{% endif %}\n\t\t\t\t{% if doc.company %}<div class=\"company-name\">{{ doc.company }}</div>{% endif %}\n\t\t\t\t<div class=\"company-address\">\n\t\t\t\t\t{% if doc.company_address %} {% set company_address = frappe.db.get_value(\"Address\",\n\t\t\t\t\tdoc.company_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\",\n\t\t\t\t\t\"country\"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =\n\t\t\t\t\tfrappe.db.get_value(\"Address\", doc.billing_address, [\"address_line1\", \"address_line2\",\n\t\t\t\t\t\"city\", \"state\", \"pincode\", \"country\"], as_dict=True) %} {% endif %} {% if company_address\n\t\t\t\t\t%} {{ company_address.address_line1 or \"\" }}<br>\n\t\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }}<br>\n\t\t\t\t\t{% endif %} {{ company_address.city or \"\" }}, {{ company_address.state or \"\" }} {{\n\t\t\t\t\tcompany_address.pincode or \"\" }}, {{ company_address.country or \"\"}}<br>\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t</td>\n\n\t\t\t<td style=\"vertical-align:top\">\n\t\t\t\t<div style=\"height:90px;margin-bottom:10px;text-align:right\">\n\t\t\t\t\t<div class=\"invoice-title\">{{ doc.doctype }}</div>\n\t\t\t\t\t<div class=\"invoice-number\">{{ doc.name }}</div>\n\t\t\t\t\t<br>\n\t\t\t\t</div>\n\t\t\t\t<div style=\"text-align:left;float:right\" class=\"other-details\">\n\t\t\t\t\t{% if doc.company %}{% set company_details = frappe.db.get_value(\"Company\", doc.company, [\"website\", \"email\",\n\t\t\t\t\t\"phone_no\"], as_dict=True) %}{% set website = company_details.website %}{% set email =\n\t\t\t\t\tcompany_details.email %}{% set phone_no = company_details.phone_no %}{% else %}{% set website = None %}{% set email = None %}{% set phone_no = None %}{% endif %} {% if website %}\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<span class=\"contact-title\">{{ _(\"Website:\") }}</span><span class=\"contact-value\">{{ website }}</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<span class=\"contact-title\">{{ _(\"Email:\") }}</span><span class=\"contact-value\">{{ email }}</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<span class=\"contact-title\">{{ _(\"Contact:\") }}</span><span class=\"contact-value\">{{ phone_no }}</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t</td>\n\t\t</tr>\n\t</tbody>\n</table>\n",
"creation": "2026-05-15 15:21:48.373815",
"custom_css": "\t.print-format-preview {\n\t\tmargin-top: 12px;\n\t}\n\t.letter-head {\n\t\tborder-radius: 18px;\n\t\tbackground: #f8f8f8;\n\t\tpadding: 12px;\n\t\tmargin-left: 12px;\n\t\tmargin-right: 12px;\n\t}\n\t.letterhead-container {\n\t\twidth: 100%;\n\t}\n\t.letterhead-container .other-details {\n\t\tposition: absolute;\n\t\tright: 0;\n\t\tbottom: 0;\n\t}\n\t.logo-address {\n\t\twidth: 65%;\n\t\tvertical-align: top;\n\t}\n\n\t.letter-head .logo {\n\t\twidth: 90px;\n\t\tdisplay: block;\n\t\tmargin-bottom: 10px;\n\t}\n\n\t.letter-head .logo img {\n\t\tborder-radius: 15px;\n\t}\n\n\t.company-name {\n\t\tcolor: #171717;\n\t\tfont-weight: bold;\n\t\tline-height: 23px;\n\t\tmargin-bottom: 5px;\n\t}\n\n\t.company-address {\n\t\tcolor: #171717;\n\t\twidth: 300px;\n\t}\n\n\t.invoice-title {\n\t\tfont-weight: bold;\n\t}\n\n\t.invoice-number {\n\t\tcolor: #7c7c7c;\n\t}\n\n\t.contact-title {\n\t\tcolor: #7c7c7c;\n\t\twidth: 60px;\n\t\tdisplay: inline-block;\n\t\tvertical-align: top;\n\t\tmargin-right: 10px;\n\t}\n\n\t.contact-value {\n\t\tcolor: #171717;\n\t\tdisplay: inline-block;\n\t}\n\t.letterhead-container td {\n\t\tpadding: 0px !important;\n\t\tposition: relative;\n\t}",
"disabled": 0,
@@ -16,7 +16,7 @@
"is_default": 0,
"letter_head_for": "DocType",
"letter_head_name": "Company Letterhead - Grey",
"modified": "2026-06-24 18:23:05.120521",
"modified": "2026-07-12 22:03:24.525672",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Company Letterhead - Grey",

View File

@@ -4,13 +4,20 @@
"columns": [],
"creation": "2013-04-22 16:16:03",
"default_print_format": "Accounts Payable Standard",
"disable_prepared_report_automation": 0,
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"doctype_to_sync": [
{
"doc_type": "Payment Ledger Entry"
}
],
"filters": [],
"generate_csv": 0,
"idx": 3,
"is_standard": "Yes",
"modified": "2026-05-22 14:35:14.716933",
"modified": "2026-07-01 13:37:41.185347",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Payable",
@@ -33,5 +40,6 @@
"role": "Auditor"
}
],
"snapshot_report": 0,
"timeout": 0
}

View File

@@ -4,13 +4,20 @@
"columns": [],
"creation": "2013-04-16 11:31:13",
"default_print_format": "Accounts Receivable Standard",
"disable_prepared_report_automation": 0,
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"doctype_to_sync": [
{
"doc_type": "Payment Ledger Entry"
}
],
"filters": [],
"generate_csv": 0,
"idx": 5,
"is_standard": "Yes",
"modified": "2026-05-22 14:34:57.666402",
"modified": "2026-07-01 13:37:44.167999",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Receivable",
@@ -27,5 +34,6 @@
"role": "Accounts User"
}
],
"snapshot_report": 0,
"timeout": 0
}

View File

@@ -4,13 +4,20 @@
"columns": [],
"creation": "2014-07-14 05:24:20.385279",
"default_print_format": "Balance Sheet Standard",
"disable_prepared_report_automation": 0,
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"doctype_to_sync": [
{
"doc_type": "GL Entry"
}
],
"filters": [],
"generate_csv": 0,
"idx": 3,
"is_standard": "Yes",
"modified": "2026-05-22 14:35:28.187799",
"modified": "2026-06-22 13:38:25.236839",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Balance Sheet",
@@ -30,5 +37,6 @@
"role": "Auditor"
}
],
"synced_report": 0,
"timeout": 0
}

View File

@@ -4,18 +4,27 @@
import frappe
from frappe import _
from frappe.utils import cint, flt
from frappe.utils import add_days, cint, flt
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
FinancialReportEngine,
get_xlsx_styles, #! DO NOT REMOVE - hook for styling
)
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,
)
@@ -266,3 +275,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

@@ -428,6 +428,11 @@ def build_comparison_chart_data(filters, columns, data):
if not fieldname:
continue
# skip the dimension column ("budget_against"), it only matches the
# "budget_" prefix by coincidence and would shift the actual values by one
if fieldname == "budget_against":
continue
if fieldname.startswith("budget_"):
budget_fields.append(fieldname)
elif fieldname.startswith("actual_"):
@@ -439,7 +444,7 @@ def build_comparison_chart_data(filters, columns, data):
labels = [
col["label"].replace("Budget", "").strip()
for col in columns
if col.get("fieldname", "").startswith("budget_")
if col.get("fieldname", "").startswith("budget_") and col.get("fieldname") != "budget_against"
]
budget_values = [0] * len(budget_fields)

View File

@@ -4,13 +4,20 @@
"columns": [],
"creation": "2013-12-06 13:22:23",
"default_print_format": "General Ledger Standard",
"disable_prepared_report_automation": 0,
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"doctype_to_sync": [
{
"doc_type": "GL Entry"
}
],
"filters": [],
"generate_csv": 0,
"idx": 4,
"is_standard": "Yes",
"modified": "2026-05-22 14:34:35.246000",
"modified": "2026-07-01 13:36:06.682661",
"modified_by": "Administrator",
"module": "Accounts",
"name": "General Ledger",
@@ -30,5 +37,6 @@
"role": "Auditor"
}
],
"snapshot_report": 0,
"timeout": 0
}

View File

@@ -818,3 +818,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

@@ -4,13 +4,20 @@
"columns": [],
"creation": "2014-07-18 11:43:33.173207",
"default_print_format": "P&L Statement Standard",
"disable_prepared_report_automation": 0,
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"doctype_to_sync": [
{
"doc_type": "GL Entry"
}
],
"filters": [],
"generate_csv": 0,
"idx": 2,
"is_standard": "Yes",
"modified": "2026-05-22 14:36:04.544347",
"modified": "2026-07-01 13:36:14.934965",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Profit and Loss Statement",
@@ -30,5 +37,6 @@
"role": "Auditor"
}
],
"snapshot_report": 0,
"timeout": 0
}

View File

@@ -11,12 +11,20 @@ from erpnext.accounts.doctype.financial_report_template.financial_report_engine
get_xlsx_styles, #! DO NOT REMOVE - hook for styling
)
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,
)
@@ -197,3 +205,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

@@ -4,13 +4,20 @@
"columns": [],
"creation": "2014-07-22 11:41:23.743564",
"default_print_format": "Trial Balance Standard",
"disable_prepared_report_automation": 0,
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"doctype_to_sync": [
{
"doc_type": "GL Entry"
}
],
"filters": [],
"idx": 2,
"generate_csv": 0,
"idx": 4,
"is_standard": "Yes",
"modified": "2026-05-22 14:35:44.889062",
"modified": "2026-07-01 17:32:21.801141",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Trial Balance",
@@ -30,5 +37,6 @@
"role": "Auditor"
}
],
"snapshot_report": 0,
"timeout": 0
}

View File

@@ -571,3 +571,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

@@ -1408,13 +1408,11 @@ def get_account_balances(accounts, company, finance_book=None, include_default_f
def get_account_balances_coa(company: str, include_default_fb_balances: bool = False):
company_currency = frappe.get_cached_value("Company", company, "default_currency")
Account = DocType("Account")
account_list = (
frappe.qb.from_(Account)
.select(Account.name, Account.parent_account, Account.account_currency)
.where(Account.company == company)
.orderby(Account.lft)
.run(as_dict=True)
account_list = frappe.get_list(
"Account",
fields=["name", "parent_account", "account_currency"],
filters={"company": company},
order_by="lft",
)
account_balances_cc = {account.get("name"): 0 for account in account_list}
@@ -1424,9 +1422,8 @@ def get_account_balances_coa(company: str, include_default_fb_balances: bool = F
GLEntry = DocType("GL Entry")
precision = get_currency_precision()
get_ledger_balances_query = (
frappe.qb.from_(GLEntry)
frappe.get_query(GLEntry, fields=[GLEntry.account], ignore_permissions=False)
.select(
GLEntry.account,
(Sum(Round(GLEntry.debit, precision)) - Sum(Round(GLEntry.credit, precision))).as_("balance"),
(
Sum(Round(GLEntry.debit_in_account_currency, precision))
@@ -1436,7 +1433,7 @@ def get_account_balances_coa(company: str, include_default_fb_balances: bool = F
.groupby(GLEntry.account)
)
condition_list = [GLEntry.company == company, GLEntry.is_cancelled == 0]
conditions = [GLEntry.company == company, GLEntry.is_cancelled == 0]
default_finance_book = None
@@ -1444,12 +1441,9 @@ def get_account_balances_coa(company: str, include_default_fb_balances: bool = F
default_finance_book = frappe.get_cached_value("Company", company, "default_finance_book")
if default_finance_book:
condition_list.append(
(GLEntry.finance_book == default_finance_book) | (GLEntry.finance_book.isnull())
)
conditions.append((GLEntry.finance_book == default_finance_book) | (GLEntry.finance_book.isnull()))
for condition in condition_list:
get_ledger_balances_query = get_ledger_balances_query.where(condition)
get_ledger_balances_query = get_ledger_balances_query.where(Criterion.all(conditions))
ledger_balances = get_ledger_balances_query.run(as_dict=True)

View File

@@ -1,190 +0,0 @@
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import json
import frappe
from frappe import _
from frappe.utils import get_date_str, nowdate
from erpnext.accounts.dashboard_fixtures import _get_fiscal_year
from erpnext.buying.dashboard_fixtures import get_company_for_dashboards
def get_data():
fiscal_year = _get_fiscal_year(nowdate())
if not fiscal_year:
return frappe._dict()
year_start_date = get_date_str(fiscal_year.get("year_start_date"))
year_end_date = get_date_str(fiscal_year.get("year_end_date"))
return frappe._dict(
{
"dashboards": get_dashboards(),
"charts": get_charts(fiscal_year, year_start_date, year_end_date),
"number_cards": get_number_cards(fiscal_year, year_start_date, year_end_date),
}
)
def get_dashboards():
return [
{
"name": "Asset",
"dashboard_name": "Asset",
"charts": [
{"chart": "Asset Value Analytics", "width": "Full"},
{"chart": "Category-wise Asset Value", "width": "Half"},
{"chart": "Location-wise Asset Value", "width": "Half"},
],
"cards": [
{"card": "Total Assets"},
{"card": "New Assets (This Year)"},
{"card": "Asset Value"},
],
}
]
def get_charts(fiscal_year, year_start_date, year_end_date):
company = get_company_for_dashboards()
return [
{
"name": "Asset Value Analytics",
"chart_name": _("Asset Value Analytics"),
"chart_type": "Report",
"report_name": "Fixed Asset Register",
"is_custom": 1,
"group_by_type": "Count",
"number_of_groups": 0,
"is_public": 0,
"timespan": "Last Year",
"time_interval": "Yearly",
"timeseries": 0,
"filters_json": json.dumps(
{
"company": company,
"status": "In Location",
"filter_based_on": "Fiscal Year",
"from_fiscal_year": fiscal_year.get("name"),
"to_fiscal_year": fiscal_year.get("name"),
"period_start_date": year_start_date,
"period_end_date": year_end_date,
"date_based_on": "Purchase Date",
"group_by": "--Select a group--",
}
),
"type": "Bar",
"custom_options": json.dumps(
{
"type": "bar",
"barOptions": {"stacked": 1},
"axisOptions": {"shortenYAxisNumbers": 1},
"tooltipOptions": {},
}
),
"doctype": "Dashboard Chart",
"y_axis": [],
},
{
"name": "Category-wise Asset Value",
"chart_name": _("Category-wise Asset Value"),
"chart_type": "Report",
"report_name": "Fixed Asset Register",
"x_field": "asset_category",
"timeseries": 0,
"filters_json": json.dumps(
{
"company": company,
"status": "In Location",
"group_by": "Asset Category",
"asset_type": ["!=", "Existing Asset"],
}
),
"type": "Donut",
"doctype": "Dashboard Chart",
"y_axis": [
{
"parent": "Category-wise Asset Value",
"parentfield": "y_axis",
"parenttype": "Dashboard Chart",
"y_field": "asset_value",
"doctype": "Dashboard Chart Field",
}
],
"custom_options": json.dumps(
{"type": "donut", "height": 300, "axisOptions": {"shortenYAxisNumbers": 1}}
),
},
{
"name": "Location-wise Asset Value",
"chart_name": "Location-wise Asset Value",
"chart_type": "Report",
"report_name": "Fixed Asset Register",
"x_field": "location",
"timeseries": 0,
"filters_json": json.dumps(
{
"company": company,
"status": "In Location",
"group_by": "Location",
"asset_type": ["!=", "Existing Asset"],
}
),
"type": "Donut",
"doctype": "Dashboard Chart",
"y_axis": [
{
"parent": "Location-wise Asset Value",
"parentfield": "y_axis",
"parenttype": "Dashboard Chart",
"y_field": "asset_value",
"doctype": "Dashboard Chart Field",
}
],
"custom_options": json.dumps(
{"type": "donut", "height": 300, "axisOptions": {"shortenYAxisNumbers": 1}}
),
},
]
def get_number_cards(fiscal_year, year_start_date, year_end_date):
return [
{
"name": "Total Assets",
"label": _("Total Assets"),
"function": "Count",
"document_type": "Asset",
"is_public": 1,
"show_percentage_stats": 1,
"stats_time_interval": "Monthly",
"filters_json": "[]",
"doctype": "Number Card",
},
{
"name": "New Assets (This Year)",
"label": _("New Assets (This Year)"),
"function": "Count",
"document_type": "Asset",
"is_public": 1,
"show_percentage_stats": 1,
"stats_time_interval": "Monthly",
"filters_json": json.dumps([["Asset", "creation", "between", [year_start_date, year_end_date]]]),
"doctype": "Number Card",
},
{
"name": "Asset Value",
"label": _("Asset Value"),
"function": "Sum",
"aggregate_function_based_on": "value_after_depreciation",
"document_type": "Asset",
"is_public": 1,
"show_percentage_stats": 1,
"stats_time_interval": "Monthly",
"filters_json": "[]",
"doctype": "Number Card",
},
]

View File

@@ -147,7 +147,15 @@ frappe.ui.form.on("Asset", {
__("Actions")
);
}
if (frm.doc.status === "Fully Depreciated") {
frm.add_custom_button(
__("Asset Repair"),
function () {
frm.trigger("create_asset_repair");
},
__("Actions")
);
}
frm.add_custom_button(
__("Split Asset"),
function () {

View File

@@ -1391,6 +1391,47 @@ class TestDepreciationBasics(AssetSetup):
self.assertFalse(depr_schedule[1].journal_entry)
self.assertFalse(depr_schedule[2].journal_entry)
def test_depr_schedule_link_matches_at_currency_precision(self):
"""A Depreciation Schedule row whose amount carries more decimals than the
company currency (e.g. 25701.202 vs a JE debit of 25701.20) must still be
matched and stamped with the Journal Entry. Comparing at exact float
equality left the link NULL, so the scheduler treated the row as unposted
and created a duplicate Journal Entry on every run. Regression test for
JournalEntry.update_journal_entry_link_on_depr_schedule()."""
from unittest.mock import MagicMock, patch
from erpnext.accounts.doctype.journal_entry import journal_entry as journal_entry_module
posting_date = getdate("2021-06-01")
je = frappe.new_doc("Journal Entry")
je.name = "JE-DEPR-TEST"
je.finance_book = None
je.posting_date = posting_date
# JE debit is stored at company currency precision (2 dp)...
je_row = MagicMock()
je_row.debit = 25701.20
je_row.precision.return_value = 2
# ...while the schedule row amount carries a third decimal.
schedule_row = frappe._dict(
name="DS-ROW-1",
schedule_date=posting_date,
journal_entry=None,
depreciation_amount=25701.202,
)
asset = frappe._dict(name="ASSET-TEST")
with (
patch.object(journal_entry_module, "get_depr_schedule", return_value=[schedule_row]),
patch.object(frappe.db, "set_value") as mock_set_value,
):
je.update_journal_entry_link_on_depr_schedule(asset, je_row)
mock_set_value.assert_called_once_with(
"Depreciation Schedule", "DS-ROW-1", "journal_entry", "JE-DEPR-TEST"
)
def test_depr_entry_posting_when_depr_expense_account_is_an_expense_account(self):
"""Tests if the Depreciation Expense Account gets debited and the Accumulated Depreciation Account gets credited when the former's an Expense Account."""

View File

@@ -84,6 +84,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"
);
});
}
},
show_general_ledger: function (frm) {

View File

@@ -130,7 +130,7 @@
"fieldtype": "Link",
"in_list_view": 1,
"label": "Asset",
"link_filters": "[[\"Asset\",\"status\",\"not in\",[\"Work In Progress\",\"Capitalized\",\"Fully Depreciated\",\"Sold\",\"Scrapped\",\"Cancelled\"]]]",
"link_filters": "[[\"Asset\",\"status\",\"not in\",[\"Work In Progress\",\"Capitalized\",\"Sold\",\"Scrapped\",\"Cancelled\"]]]",
"options": "Asset",
"reqd": 1
},
@@ -275,7 +275,7 @@
"link_fieldname": "asset_repair"
}
],
"modified": "2026-02-06 14:57:54.257572",
"modified": "2026-06-20 15:43:54.943335",
"modified_by": "Administrator",
"module": "Assets",
"name": "Asset Repair",

View File

@@ -70,12 +70,15 @@ class AssetRepair(AccountsController):
self.check_repair_status()
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

@@ -270,6 +270,7 @@ class PurchaseOrder(BuyingController):
"ref_dn_field": "material_request_item",
"compare_fields": mri_compare_fields,
"is_child_table": True,
"allow_duplicate_prev_row_id": True,
},
}
)

View File

@@ -923,8 +923,10 @@
"fieldname": "job_card",
"fieldtype": "Link",
"label": "Job Card",
"no_copy": 1,
"options": "Job Card",
"search_index": 1
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "distributed_discount_amount",
@@ -951,7 +953,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-05-14 12:16:16.192936",
"modified": "2026-07-15 10:30:04.600510",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Order Item",

View File

@@ -307,6 +307,7 @@
"fieldname": "net_rate",
"fieldtype": "Currency",
"label": "Net Rate",
"options": "currency",
"print_hide": 1,
"read_only": 1
},
@@ -613,7 +614,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-06-17 12:05:52.441645",
"modified": "2026-07-15 10:33:24.855979",
"modified_by": "Administrator",
"module": "Buying",
"name": "Supplier Quotation Item",

View File

@@ -1347,66 +1347,63 @@ class StockController(AccountsController):
if not batches:
return
field_mapper = {
"Sales Invoice": [["Sales Order", "sales_order"]],
"Delivery Note": [["Sales Order", "against_sales_order"]],
"Stock Entry": [
["Work Order", "work_order"],
["Subcontracting Inward Order", "subcontracting_inward_order"],
],
reference_fields = {
"Sales Invoice": ["sales_order"],
"Delivery Note": ["against_sales_order"],
"Stock Entry": ["work_order", "subcontracting_inward_order"],
}.get(self.doctype)
qty_field = {
"Sales Invoice": "qty",
"Delivery Note": "qty",
"Stock Entry": "fg_completed_qty",
}.get(self.doctype)
reserved_batches_data = self.get_reserved_batches(batches)
items = self.items
if self.doctype == "Stock Entry":
items = [self]
for item in items:
for field in field_mapper:
if not item.get(field[1]):
continue
own_vouchers = {item.get(field) for item in items for field in reference_fields if item.get(field)}
value = item.get(field[1])
for row in reserved_batches_data:
if self.doctype in ["Sales Invoice", "Delivery Note"] and row.item_code != item.get(
"item_code"
):
continue
outstanding_qty = defaultdict(float)
reservations = defaultdict(list)
for row in self.get_reserved_batches(batches):
if row.voucher_no in own_vouchers:
continue
if row.voucher_no == value:
continue
key = (row.batch_no, row.warehouse)
outstanding = flt(row.qty) - flt(row.delivered_qty)
outstanding_qty[key] += outstanding
if outstanding > 0:
reservations[key].append(row)
batch_qty = get_batch_qty(
row.batch_no,
row.warehouse,
posting_date=self.posting_date,
posting_time=self.posting_time,
consider_negative_batches=True,
)
for (batch_no, warehouse), reserved_qty in outstanding_qty.items():
if flt(reserved_qty, 6) <= 0:
continue
if item.get(qty_field) < batch_qty:
continue
batch_qty = get_batch_qty(
batch_no,
warehouse,
posting_date=self.posting_date,
posting_time=self.posting_time,
consider_negative_batches=True,
)
frappe.throw(
_(
"The batch {0} is already reserved in {1} {2}. So, cannot proceed with the {3} {4}, which is created against the {5} {6}."
).format(
frappe.bold(row.batch_no),
frappe.bold(row.voucher_type),
frappe.bold(row.voucher_no),
frappe.bold(self.doctype),
frappe.bold(self.name),
frappe.bold(field[0]),
frappe.bold(value),
),
title=_("Reserved Batch Conflict"),
)
if flt(batch_qty, 6) >= flt(reserved_qty, 6):
continue
vouchers = ", ".join(
f"{frappe.bold(voucher_type)} {frappe.bold(voucher_no)}"
for voucher_type, voucher_no in dict.fromkeys(
(row.voucher_type, row.voucher_no) for row in reservations[(batch_no, warehouse)]
)
)
frappe.throw(
_(
"The batch {0} is reserved for {1} in the warehouse {2} and the remaining quantity is not enough to cover the reservations. So, cannot proceed with the {3} {4}."
).format(
frappe.bold(batch_no),
vouchers,
frappe.bold(warehouse),
frappe.bold(self.doctype),
frappe.bold(self.name),
),
title=_("Reserved Batch Conflict"),
)
def get_reserved_batches(self, batches):
doctype = frappe.qb.DocType("Stock Reservation Entry")
@@ -1418,9 +1415,10 @@ class StockController(AccountsController):
.on(doctype.name == child_doc.parent)
.select(
child_doc.batch_no,
child_doc.qty,
child_doc.delivered_qty,
doctype.voucher_type,
doctype.voucher_no,
doctype.item_code,
doctype.warehouse,
)
.where((doctype.docstatus == 1) & (child_doc.batch_no.isin(batches)))
@@ -2057,7 +2055,7 @@ class StockController(AccountsController):
@frappe.whitelist()
def show_accounting_ledger_preview(company, doctype, docname):
def show_accounting_ledger_preview(company: str, doctype: str, docname: str):
filters = frappe._dict(company=company, include_dimensions=1)
doc = frappe.get_lazy_doc(doctype, docname)
doc.check_permission("read")
@@ -2071,7 +2069,7 @@ def show_accounting_ledger_preview(company, doctype, docname):
@frappe.whitelist()
def show_stock_ledger_preview(company, doctype, docname):
def show_stock_ledger_preview(company: str, doctype: str, docname: str):
filters = frappe._dict(company=company)
doc = frappe.get_lazy_doc(doctype, docname)
doc.check_permission("read")

View File

@@ -492,6 +492,7 @@ scheduler_events = {
],
"weekly": [
"erpnext.accounts.utils.auto_create_exchange_rate_revaluation_weekly",
"erpnext.stock.doctype.stock_reposting_settings.stock_reposting_settings.repost_incorrect_valuation_entries",
],
"monthly_long": [
"erpnext.accounts.deferred_revenue.process_deferred_accounting",

View File

@@ -457,6 +457,49 @@ class TestJobCard(ERPNextTestSuite):
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

@@ -1806,6 +1806,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(

View File

@@ -490,3 +490,4 @@ erpnext.patches.v15_0.backfill_sla_link_filters_on_custom_field
erpnext.patches.v15_0.backfill_sla_link_filters_on_docfield
erpnext.patches.v16_0.crm_settings_handle_allowed_users_for_frappe_crm
erpnext.patches.v16_0.backfill_pick_list_transferred_qty
erpnext.patches.v16_0.access_control_for_project_users

View File

@@ -0,0 +1,33 @@
import frappe
def execute():
Project = frappe.qb.DocType("Project")
ProjectUser = frappe.qb.DocType("Project User")
query = (
frappe.qb.from_(Project)
.join(ProjectUser)
.on(Project.name == ProjectUser.parent)
.select(Project.name, ProjectUser.user)
)
proj_users = query.run(as_dict=1)
project_mapped_users = get_project_mapped_users(proj_users)
for d in proj_users:
if d.user in project_mapped_users[d.name]:
continue
frappe.share.add_docshare("Project", d.name, user=d.user)
def get_project_mapped_users(proj_users):
projects = set([d.name for d in proj_users])
project_mapped_users = {}
for d in projects:
project_mapped_users[d] = [d.user for d in frappe.share.get_users("Project", d)]
return project_mapped_users

View File

@@ -86,7 +86,7 @@
"no_copy": 1,
"oldfieldname": "status",
"oldfieldtype": "Select",
"options": "Open\nCompleted\nCancelled",
"options": "Open\nOn hold\nCompleted\nCancelled",
"search_index": 1
},
{
@@ -210,13 +210,15 @@
"fieldname": "users",
"fieldtype": "Table",
"label": "Users",
"options": "Project User"
"options": "Project User",
"permlevel": 1
},
{
"fieldname": "copied_from",
"fieldtype": "Data",
"hidden": 1,
"label": "Copied From",
"permlevel": 1,
"read_only": 1
},
{
@@ -482,13 +484,25 @@
"index_web_pages_for_search": 1,
"links": [],
"max_attachments": 4,
"modified": "2026-05-22 16:45:50.762759",
"modified": "2026-07-14 14:32:11.328347",
"modified_by": "Administrator",
"module": "Projects",
"name": "Project",
"naming_rule": "By \"Naming Series\" field",
"owner": "Administrator",
"permissions": [
{
"delete": 1,
"email": 1,
"export": 1,
"permlevel": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Projects Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,

View File

@@ -61,7 +61,7 @@ class Project(Document):
project_type: DF.Link | None
sales_order: DF.Link | None
second_email: DF.Time | None
status: DF.Literal["Open", "Completed", "Cancelled"]
status: DF.Literal["Open", "On hold", "Completed", "Cancelled"]
subject: DF.Data | None
to_time: DF.Time | None
total_billable_amount: DF.Currency
@@ -92,7 +92,8 @@ class Project(Document):
def validate(self):
if not self.is_new():
self.copy_from_template() # nosemgrep
self.copy_from_template()
self.control_access_for_project_users()
self.send_welcome_email()
self.update_costing()
self.update_percent_complete()
@@ -205,6 +206,7 @@ class Project(Document):
self.copy_from_template() # nosemgrep
if self.sales_order:
frappe.db.set_value("Sales Order", self.sales_order, "project", self.name)
self.control_access_for_project_users()
def on_trash(self):
frappe.db.set_value("Sales Order", {"project": self.name}, "project", "")
@@ -262,8 +264,8 @@ class Project(Document):
pct_complete += row["progress"] * frappe.utils.safe_div(row["task_weight"], weight_sum)
self.percent_complete = flt(flt(pct_complete), 2)
# don't update status if it is cancelled
if self.status == "Cancelled":
# don't update status if it is manually set to cancelled or on hold
if self.status in ("Cancelled", "On hold"):
return
self.status = "Completed" if self.percent_complete == 100 else "Open"
@@ -377,6 +379,34 @@ class Project(Document):
)
user.welcome_email_sent = 1
def control_access_for_project_users(self):
def revoke_access_for_project_users(removed_users):
users = set([d.user for d in frappe.share.get_users(self.doctype, self.name)])
for user in removed_users:
if user not in users:
continue
frappe.share.remove(self.doctype, self.name, user)
def grant_access_for_project_users(new_users):
for user in new_users:
frappe.share.add_docshare(self.doctype, self.name, user=user)
current_users = set([d.user for d in self.users])
old_doc = self.get_doc_before_save()
if not old_doc:
grant_access_for_project_users(current_users)
return
previous_users = set([d.user for d in old_doc.users])
new_users = current_users - previous_users
removed_users = previous_users - current_users
revoke_access_for_project_users(removed_users)
grant_access_for_project_users(new_users)
def get_timeline_data(doctype: str, name: str) -> dict[int, int]:
"""Return timeline for attendance"""

View File

@@ -4,6 +4,8 @@ frappe.listview_settings["Project"] = {
get_indicator: function (doc) {
if (doc.status == "Open" && doc.percent_complete) {
return [__("{0}%", [cint(doc.percent_complete)]), "orange", "percent_complete,>,0|status,=,Open"];
} else if (doc.status == "On hold") {
return [__("On hold"), "blue", "status,=,On hold"];
} else {
return [__(doc.status), frappe.utils.guess_colour(doc.status), "status,=," + doc.status];
}

View File

@@ -244,6 +244,61 @@ class TestProject(ERPNextTestSuite):
project.save()
self.assertEqual(project.status, "Completed")
def _create_portal_user(self, email):
"""A user with no Project-related role, so read access can only come from
control_access_for_project_users() sharing the doc with them."""
if not frappe.db.exists("User", email):
frappe.get_doc(
{
"doctype": "User",
"email": email,
"first_name": "Portal",
"send_welcome_email": 0,
}
).insert(ignore_permissions=True)
return email
def test_new_project_grants_access_to_its_users(self):
member = self._create_portal_user(f"new_proj_member_{frappe.generate_hash(length=6)}@example.com")
project = frappe.get_doc(
doctype="Project",
project_name=f"_Test New Project Access {frappe.generate_hash(length=6)}",
status="Open",
company="_Test Company",
)
project.append("users", {"user": member, "welcome_email_sent": 1})
project.insert() # must not raise
self.assertTrue(project.has_permission(user=member))
shared_with = [d.user for d in frappe.share.get_users("Project", project.name)]
self.assertIn(member, shared_with)
def test_adding_and_removing_project_user_updates_access(self):
stays = self._create_portal_user(f"stays_{frappe.generate_hash(length=6)}@example.com")
leaves = self._create_portal_user(f"leaves_{frappe.generate_hash(length=6)}@example.com")
project = frappe.get_doc(
doctype="Project",
project_name=f"_Test Project User Membership {frappe.generate_hash(length=6)}",
status="Open",
company="_Test Company",
)
project.append("users", {"user": stays, "welcome_email_sent": 1})
project.insert()
self.assertTrue(project.has_permission(user=stays))
# adding a user on update (not insert) must also grant them access
project.append("users", {"user": leaves, "welcome_email_sent": 1})
project.save()
self.assertTrue(project.has_permission(user=leaves))
# removing a user must revoke the share that was granted for membership
project.users = [d for d in project.users if d.user != leaves]
project.save()
self.assertFalse(project.has_permission(user=leaves))
self.assertTrue(project.has_permission(user=stays))
def get_project(name, template):
project = frappe.get_doc(

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);
}
@@ -454,8 +462,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

@@ -284,19 +284,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

@@ -30,7 +30,7 @@ class TermsandConditions(Document):
def validate(self):
if self.terms:
validate_template(self.terms)
validate_template(self.terms, restrict_globals=True)
if not cint(self.buying) and not cint(self.selling) and not cint(self.hr) and not cint(self.disabled):
throw(_("At least one of the Applicable Modules should be selected"))
@@ -40,7 +40,10 @@ def get_terms_and_conditions(template_name, doc):
if isinstance(doc, str):
doc = json.loads(doc)
terms_and_conditions = frappe.get_doc("Terms and Conditions", template_name)
tnc = frappe.get_cached_doc("Terms and Conditions", template_name)
tnc.check_permission()
if terms_and_conditions.terms:
return frappe.render_template(terms_and_conditions.terms, doc)
if not tnc.terms:
return
return frappe.render_template(tnc.terms, doc, restrict_globals=1)

View File

@@ -288,7 +288,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: "not_supported", // doesn't make sense for picklist without a separate field.
};

View File

@@ -2584,22 +2584,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

@@ -10,8 +10,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
from erpnext.tests.utils import ERPNextTestSuite
@@ -1467,3 +1471,133 @@ def make_serial_batch_bundle(kwargs):
return sb.make_serial_and_batch_bundle()
return sb
class TestSerialandBatchBundleLogic(ERPNextTestSuite):
"""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_validate_docstatus_rejects_unsubmitted_entries(self):
doc = frappe.new_doc("Serial and Batch Bundle")
doc.append("entries", {"qty": 1}) # a fresh row has docstatus 0
self.assertRaises(frappe.ValidationError, doc.validate_docstatus)
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

@@ -22,7 +22,9 @@
"column_break_itvd",
"enable_separate_reposting_for_gl",
"errors_notification_section",
"notify_reposting_error_to_role"
"notify_reposting_error_to_role",
"auto_reposting_section",
"repost_incorrect_valuation_entries"
],
"fields": [
{
@@ -113,12 +115,24 @@
"fieldname": "do_not_fetch_incoming_rate_from_serial_no",
"fieldtype": "Check",
"label": "Do not fetch incoming rate from Serial No"
},
{
"fieldname": "auto_reposting_section",
"fieldtype": "Section Break",
"label": "Auto Reposting of Incorrect Valuation"
},
{
"default": "0",
"description": "If enabled, a weekly scheduler scans the Stock Ledger Variance for item-warehouses with incorrect valuation in the current financial year and auto-creates Item & Warehouse based reposts to fix them.",
"fieldname": "repost_incorrect_valuation_entries",
"fieldtype": "Check",
"label": "Auto Repost Incorrect Valuation Entries (Weekly)"
}
],
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-05-15 12:59:34.392491",
"modified": "2026-07-01 14:41:51.499245",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Reposting Settings",

View File

@@ -4,7 +4,16 @@
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import add_to_date, get_datetime, get_time_str, time_diff_in_hours
from frappe.utils import (
add_to_date,
get_datetime,
get_link_to_form,
get_time_str,
getdate,
time_diff_in_hours,
today,
)
from frappe.utils.user import get_users_with_role
class StockRepostingSettings(Document):
@@ -27,6 +36,7 @@ class StockRepostingSettings(Document):
]
no_of_parallel_reposting: DF.Int
notify_reposting_error_to_role: DF.Link | None
repost_incorrect_valuation_entries: DF.Check
start_time: DF.Time | None
# end: auto-generated types
@@ -117,3 +127,205 @@ def create_repost_item_valuation(item_code, warehouse, posting_date):
"status": "Queued",
}
).submit()
def repost_incorrect_valuation_entries():
"""Weekly scheduler entry point.
When `repost_incorrect_valuation_entries` is enabled in Stock Reposting Settings, scan each
company's Stock Ledger Variance and Stock and Account Value Comparison reports for incorrect stock
valuation in the current financial year and auto-create reposts to correct them. Journal Entries are
never reposted, and warehouses pointing at a non-'Stock' account are reported to System Managers
instead. Disabled by default; does nothing unless explicitly turned on."""
if not frappe.db.get_single_value("Stock Reposting Settings", "repost_incorrect_valuation_entries"):
return
for company in frappe.get_all("Company", pluck="name"):
# The Stock Ledger Variance scan runs the invariant check for every item-warehouse, so process
# each company as its own long-running background job rather than blocking the weekly scheduler.
frappe.enqueue(
repost_incorrect_valuation_entries_for_company,
queue="long",
job_id=f"repost_incorrect_valuation::{company}",
deduplicate=True,
company=company,
)
def repost_incorrect_valuation_entries_for_company(company):
"""Detect and repost incorrect stock valuation for a single company, limited to the current
financial year, using two reports:
1. Stock Ledger Variance - item-warehouses whose ledger valuation is internally inconsistent
(typically a wrong previous-SLE pick). Fixed with an Item & Warehouse repost.
2. Stock and Account Value Comparison - vouchers whose stock value does not match the accounting
ledger. Reposted via the report's own logic (Journal Entries are excluded - ERPNext does not
repost them). If a voucher's warehouse points at an account that is not of type 'Stock',
reposting can never clear the difference, so System Managers are notified instead."""
from erpnext.accounts.utils import get_fiscal_year
fy_start_date = get_fiscal_year(today(), company=company)[1]
_repost_stock_ledger_variance(company, fy_start_date)
_repost_stock_account_value_comparison(company, fy_start_date)
def _repost_stock_ledger_variance(company, fy_start_date):
from erpnext.stock.report.stock_ledger_variance.stock_ledger_variance import (
get_data as get_stock_ledger_variance,
)
created = []
for row in get_stock_ledger_variance({"company": company}) or []:
row = frappe._dict(row)
# Only correct issues that originate in the current financial year.
if not row.posting_date or getdate(row.posting_date) < getdate(fy_start_date):
continue
# Avoid piling up duplicate reposts week over week for the same item-warehouse.
if has_pending_valuation_repost(company, row.item_code, row.warehouse):
continue
create_repost_item_valuation(row.item_code, row.warehouse, row.posting_date)
created.append(row)
if created:
frappe.logger("stock_reposting").info(
f"Auto-reposted {len(created)} incorrect-valuation item-warehouse(s) for {company}: "
+ ", ".join(f"{d.item_code} @ {d.warehouse} from {d.posting_date}" for d in created)
)
return created
def _repost_stock_account_value_comparison(company, fy_start_date):
import erpnext
# Stock vs accounting values only exist under perpetual inventory.
if not erpnext.is_perpetual_inventory_enabled(company):
return
from erpnext.stock.report.stock_and_account_value_comparison.stock_and_account_value_comparison import (
create_reposting_entries,
)
from erpnext.stock.report.stock_and_account_value_comparison.stock_and_account_value_comparison import (
get_data as get_value_comparison,
)
to_repost = []
misconfigured = [] # (voucher_type, voucher_no, warehouse, account)
# Scope the report's DB scan to the current financial year (see get_data) instead of loading every
# voucher ever posted and filtering in Python afterwards.
comparison_filters = frappe._dict(company=company, from_date=fy_start_date, as_on_date=today())
for row in get_value_comparison(comparison_filters) or []:
row = frappe._dict(row)
# ERPNext does not repost Journal Entries (GL-only postings have no stock ledger to repost).
if row.voucher_type == "Journal Entry":
continue
# Only correct issues that originate in the current financial year.
if not row.posting_date or getdate(row.posting_date) < getdate(fy_start_date):
continue
# If a warehouse on this voucher is mapped to an account that is not of type 'Stock', reposting
# can never reconcile stock vs accounting value - flag it for a human instead of reposting.
# Only flag accounts with a concrete, non-'Stock' type. An unset/blank account_type is treated as
# "unknown" - reposting may well reconcile it - so we don't skip the voucher or email a false alarm.
wrong_accounts = [
(warehouse, account)
for warehouse, account, account_type in get_voucher_warehouse_accounts(row.voucher_no, company)
if account_type and account_type != "Stock"
]
if wrong_accounts:
misconfigured.extend(
(row.voucher_type, row.voucher_no, warehouse, account)
for warehouse, account in wrong_accounts
)
continue
to_repost.append(row)
if to_repost:
# create_reposting_entries reposts Purchase Receipt/Invoice transaction-wise and everything else
# item-warehouse-wise, and de-duplicates against existing reposts.
create_reposting_entries(to_repost, company)
frappe.logger("stock_reposting").info(
f"Auto-reposted {len(to_repost)} stock/account value mismatch voucher(s) for {company}."
)
if misconfigured:
notify_incorrect_stock_account(company, misconfigured)
def get_voucher_warehouse_accounts(voucher_no, company):
"""Return (warehouse, account, account_type) for each distinct warehouse the voucher posted stock
into, so the caller can verify the account is a 'Stock' asset account."""
warehouses = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_no": voucher_no, "is_cancelled": 0},
pluck="warehouse",
distinct=True,
)
rows = []
for warehouse in {w for w in warehouses if w}:
account = frappe.get_cached_value("Warehouse", warehouse, "account") or frappe.get_cached_value(
"Company", company, "default_inventory_account"
)
account_type = frappe.get_cached_value("Account", account, "account_type") if account else None
rows.append((warehouse, account, account_type))
return rows
def notify_incorrect_stock_account(company, misconfigured):
"""Email System Managers about warehouse accounts that are not of type 'Stock', which keep stock
and accounting values from reconciling even after reposting."""
recipients = get_users_with_role("System Manager")
if not recipients:
return
items = "".join(
"<li>{} {} &rarr; {}: {}</li>".format(
voucher_type,
get_link_to_form(voucher_type, voucher_no),
warehouse,
account or _("No account set"),
)
for voucher_type, voucher_no, warehouse, account in misconfigured
)
subject = _("Incorrect Stock Asset Account in {0}").format(company)
message = (
_("Stock and accounting values could not be reconciled by reposting for {0}.").format(
frappe.bold(company)
)
+ "<br><br>"
+ _(
"The warehouse account(s) below are not of type 'Stock'. Please set a correct Stock asset account on the warehouse (Account Type must be 'Stock'):"
)
+ f"<ul>{items}</ul>"
)
frappe.sendmail(recipients=recipients, subject=subject, message=message)
def has_pending_valuation_repost(company, item_code, warehouse):
"""True if an Item & Warehouse repost for this item-warehouse is already queued or running, so the
weekly job does not stack duplicate reposts."""
return bool(
frappe.db.exists(
"Repost Item Valuation",
{
"company": company,
"item_code": item_code,
"warehouse": warehouse,
"based_on": "Item and Warehouse",
"status": ("in", ["Queued", "In Progress"]),
"docstatus": 1,
},
)
)

View File

@@ -1,14 +1,137 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import unittest
from unittest.mock import patch
import frappe
from frappe.utils import add_days, getdate, today
from erpnext.accounts.utils import get_fiscal_year
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.repost_item_valuation.repost_item_valuation import get_recipients
from erpnext.stock.doctype.stock_reposting_settings import stock_reposting_settings as srs
from erpnext.tests.utils import ERPNextTestSuite
TEST_COMPANY = "_Test Company"
TEST_WAREHOUSE = "_Test Warehouse - _TC"
class TestStockRepostingSettings(ERPNextTestSuite):
def tearDown(self):
frappe.db.set_single_value("Stock Reposting Settings", "repost_incorrect_valuation_entries", 0)
super().tearDown()
def test_auto_repost_disabled_does_nothing(self):
frappe.db.set_single_value("Stock Reposting Settings", "repost_incorrect_valuation_entries", 0)
with patch("frappe.enqueue") as enqueue:
srs.repost_incorrect_valuation_entries()
enqueue.assert_not_called()
def test_auto_repost_enabled_enqueues_per_company(self):
frappe.db.set_single_value("Stock Reposting Settings", "repost_incorrect_valuation_entries", 1)
with patch("frappe.enqueue") as enqueue:
srs.repost_incorrect_valuation_entries()
self.assertTrue(enqueue.called)
# one job per company
self.assertEqual(enqueue.call_count, frappe.db.count("Company"))
def test_reposts_only_current_financial_year_entries(self):
item = make_item().name
fy_start_date = get_fiscal_year(today(), company=TEST_COMPANY)[1]
current_fy_row = {"item_code": item, "warehouse": TEST_WAREHOUSE, "posting_date": today()}
prior_fy_row = {
"item_code": item,
"warehouse": TEST_WAREHOUSE,
"posting_date": add_days(fy_start_date, -1),
}
calls = []
variance_path = "erpnext.stock.report.stock_ledger_variance.stock_ledger_variance.get_data"
with (
patch.object(
srs, "create_repost_item_valuation", side_effect=lambda i, w, d: calls.append((i, w, str(d)))
),
patch(variance_path, return_value=[current_fy_row, prior_fy_row]),
):
srs.repost_incorrect_valuation_entries_for_company(TEST_COMPANY)
# Only the current-FY entry is reposted; the prior-FY one is ignored.
self.assertEqual(calls, [(item, TEST_WAREHOUSE, str(today()))])
def test_skips_when_repost_already_pending(self):
item = make_item().name
current_fy_row = {"item_code": item, "warehouse": TEST_WAREHOUSE, "posting_date": today()}
calls = []
variance_path = "erpnext.stock.report.stock_ledger_variance.stock_ledger_variance.get_data"
with (
patch.object(srs, "has_pending_valuation_repost", return_value=True),
patch.object(
srs, "create_repost_item_valuation", side_effect=lambda i, w, d: calls.append((i, w, str(d)))
),
patch(variance_path, return_value=[current_fy_row]),
):
srs.repost_incorrect_valuation_entries_for_company(TEST_COMPANY)
self.assertEqual(calls, [])
def test_value_comparison_excludes_je_and_flags_wrong_account(self):
fy_start = getdate(today())
rows = [
# correct stock account, current FY -> reposted
{"voucher_type": "Purchase Receipt", "voucher_no": "PR-OK", "posting_date": today()},
# Journal Entry -> never reposted
{"voucher_type": "Journal Entry", "voucher_no": "JE-1", "posting_date": today()},
# warehouse account not of type "Stock" -> notify, not reposted
{"voucher_type": "Purchase Receipt", "voucher_no": "PR-BADACC", "posting_date": today()},
# warehouse account with an unset account_type -> unknown, repost (not a false alarm)
{"voucher_type": "Purchase Receipt", "voucher_no": "PR-NOTYPE", "posting_date": today()},
# prior financial year -> ignored
{
"voucher_type": "Purchase Receipt",
"voucher_no": "PR-OLD",
"posting_date": add_days(fy_start, -1),
},
]
accounts = {
"PR-OK": [("WH-A", "Stock A - _TC", "Stock")],
"PR-BADACC": [("WH-B", "Debtors - _TC", "Receivable")],
"PR-NOTYPE": [("WH-C", "Unclassified - _TC", None)],
"PR-OLD": [("WH-A", "Stock A - _TC", "Stock")],
}
reposted = {}
sent = []
comparison = (
"erpnext.stock.report.stock_and_account_value_comparison.stock_and_account_value_comparison"
)
with (
patch("erpnext.is_perpetual_inventory_enabled", return_value=True),
patch(f"{comparison}.get_data", return_value=rows),
patch(
f"{comparison}.create_reposting_entries",
side_effect=lambda r, c: reposted.update(rows=r, company=c),
),
patch.object(
srs, "get_voucher_warehouse_accounts", side_effect=lambda vno, c: accounts.get(vno, [])
),
patch.object(srs, "get_users_with_role", return_value=["sysmgr@test.com"]),
patch("frappe.sendmail", side_effect=lambda **kw: sent.append(kw)),
):
srs._repost_stock_account_value_comparison(TEST_COMPANY, fy_start)
# Current-FY, non-Journal-Entry vouchers are reposted: the correct-account one and the one whose
# account_type is unset (unknown is treated as "proceed", not "wrong account").
self.assertEqual([r["voucher_no"] for r in reposted["rows"]], ["PR-OK", "PR-NOTYPE"])
# Only the concrete wrong-account voucher triggers a System Manager notification.
self.assertEqual(len(sent), 1)
self.assertIn("PR-BADACC", sent[0]["message"])
self.assertNotIn("PR-NOTYPE", sent[0]["message"])
class TestStockRepostingSettingsNotification(ERPNextTestSuite):
def test_notify_reposting_error_to_role(self):
role = "Notify Reposting Role"

View File

@@ -1316,6 +1316,7 @@ class StockReservation:
"voucher_type": entry.voucher_type or to_doctype,
"voucher_no": entry.voucher_no,
"voucher_detail_no": entry.voucher_detail_no,
"stock_uom": entry.stock_uom,
"serial_nos": [],
"sre_names": defaultdict(float),
"batches": defaultdict(float),
@@ -1373,6 +1374,7 @@ class StockReservation:
sre.voucher_qty = entry.required_qty
sre.item_code = entry.item_code
sre.warehouse = entry.warehouse
sre.stock_uom = entry.stock_uom
sre.reserved_qty = min(sre.available_qty, entry.qty)
sre.has_serial_no = frappe.get_value("Item", sre.item_code, "has_serial_no")
sre.has_batch_no = frappe.get_value("Item", sre.item_code, "has_batch_no")

View File

@@ -697,6 +697,66 @@ class TestStockReservationEntry(ERPNextTestSuite):
# Test - 1: ValidationError should be thrown as the inwarded stock is reserved.
self.assertRaises(frappe.ValidationError, se.cancel)
@ERPNextTestSuite.change_settings(
"Stock Settings",
{
"allow_negative_stock": 0,
"enable_stock_reservation": 1,
"auto_reserve_serial_and_batch": 1,
"pick_serial_and_batch_based_on": "FIFO",
"use_serial_batch_fields": 1,
},
)
def test_batch_shared_across_sales_orders_can_be_delivered(self) -> None:
# Regression (#57159): one batch reserved by two Sales Orders. Delivering each order's own
# reserved unit must not raise Reserved Batch Conflict — the remainder covers the other order.
# The batch is set on the row explicitly as the v16 reserved-stock mapper does not carry
# the reserved batch onto the Delivery Note row.
item_doc = make_batch_item()
create_material_receipt(items={item_doc.name: item_doc}, warehouse=self.warehouse, qty=2)
orders = []
for _i in range(2):
so = make_sales_order(item_code=item_doc.name, warehouse=self.warehouse, qty=1, rate=100)
so.create_stock_reservation_entries()
orders.append(so)
(batch_no,) = get_reserved_batch_nos(orders[0].name) | get_reserved_batch_nos(orders[1].name)
for so in orders:
dn = make_delivery_note(so.name)
dn.items[0].batch_no = batch_no
dn.save()
dn.submit()
self.assertEqual(dn.docstatus, 1)
@ERPNextTestSuite.change_settings(
"Stock Settings",
{
"allow_negative_stock": 0,
"enable_stock_reservation": 1,
"auto_reserve_serial_and_batch": 1,
"pick_serial_and_batch_based_on": "FIFO",
"use_serial_batch_fields": 1,
},
)
def test_delivery_draining_a_batch_reserved_for_another_sales_order_is_blocked(self) -> None:
# Guard for #57159 fix: an order without a reservation must still be blocked from draining
# a batch below what another order has reserved from it, even if other batches have stock.
item_doc = make_batch_item()
create_material_receipt(items={item_doc.name: item_doc}, warehouse=self.warehouse, qty=2)
create_material_receipt(items={item_doc.name: item_doc}, warehouse=self.warehouse, qty=2)
so_a = make_sales_order(item_code=item_doc.name, warehouse=self.warehouse, qty=2, rate=100)
so_a.create_stock_reservation_entries()
(reserved_batch_no,) = get_reserved_batch_nos(so_a.name)
so_b = make_sales_order(item_code=item_doc.name, warehouse=self.warehouse, qty=2, rate=100)
dn = make_delivery_note(so_b.name)
dn.items[0].batch_no = reserved_batch_no
dn.save()
self.assertRaisesRegex(frappe.ValidationError, "is reserved for", dn.submit)
def create_items() -> dict:
items_properties = [
@@ -737,6 +797,33 @@ def create_items() -> dict:
return items
def make_batch_item():
return make_item(
properties={
"is_stock_item": 1,
"valuation_rate": 100,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "SRBI-.#####.",
}
)
def get_reserved_batch_nos(sales_order: str) -> set:
sre = frappe.qb.DocType("Stock Reservation Entry")
sb_entry = frappe.qb.DocType("Serial and Batch Entry")
batch_nos = (
frappe.qb.from_(sre)
.inner_join(sb_entry)
.on(sre.name == sb_entry.parent)
.select(sb_entry.batch_no)
.where((sre.voucher_no == sales_order) & (sre.docstatus == 1))
).run(pluck=True)
return set(batch_nos)
def create_material_receipt(
items: dict, warehouse: str = "_Test Warehouse - _TC", qty: float = 100
) -> StockEntry:

View File

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

View File

@@ -33,6 +33,11 @@ def get_data(report_filters):
"posting_date": ("<=", report_filters.as_on_date),
}
# Optional lower bound: lets callers (e.g. the weekly auto-repost job) scope the scan to the current
# fiscal year in the query itself instead of loading every voucher ever posted and filtering later.
if report_filters.get("from_date"):
filters["posting_date"] = ("between", [report_filters.from_date, report_filters.as_on_date])
get_currency_precision() or 2
stock_ledger_entries = get_stock_ledger_data(report_filters, filters)
voucher_wise_gl_data = get_gl_data(report_filters, filters)
@@ -240,19 +245,26 @@ def repost_based_on_transaction(rows, company=None, entries=None):
continue
duplicate_vouchers.add(voucher_key)
doc = frappe.get_doc(
{
"doctype": "Repost Item Valuation",
"based_on": "Transaction",
"status": "Queued",
"voucher_type": row.get("voucher_type"),
"voucher_no": row.get("voucher_no"),
"posting_date": row.get("posting_date"),
"posting_time": row.get("posting_time"),
"company": company,
"allow_nagative_stock": 1,
"recalculate_valuation_rate": 1,
}
).submit()
# Isolate each submit in a savepoint: an already-queued repost raises DuplicateEntryError, and on
# PostgreSQL a failed insert aborts the whole transaction, killing the rest of the loop (and the
# silent weekly job). Rolling back to the savepoint keeps prior/next reposts intact.
frappe.db.savepoint("repost_based_on_transaction")
try:
doc = frappe.get_doc(
{
"doctype": "Repost Item Valuation",
"based_on": "Transaction",
"status": "Queued",
"voucher_type": row.get("voucher_type"),
"voucher_no": row.get("voucher_no"),
"posting_date": row.get("posting_date"),
"posting_time": row.get("posting_time"),
"company": company,
"allow_nagative_stock": 1,
"recalculate_valuation_rate": 1,
}
).submit()
entries.append(get_link_to_form("Repost Item Valuation", doc.name))
entries.append(get_link_to_form("Repost Item Valuation", doc.name))
except frappe.DuplicateEntryError:
frappe.db.rollback(save_point="repost_based_on_transaction")

View File

@@ -27,6 +27,40 @@ class TestGetItemDetail(ERPNextTestSuite):
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)
# making this test in get_item_details test file as feat/fix is present in that method
def test_fetch_price_from_list_rate_on_doc_save(self):
# create item

View File

@@ -6,21 +6,12 @@ import frappe
def get_context(context):
project_user = frappe.db.get_value(
"Project User",
{"parent": frappe.form_dict.project, "user": frappe.session.user},
["user", "view_attachments", "hide_timesheets"],
as_dict=True,
)
if frappe.session.user != "Administrator" and (not project_user or frappe.session.user == "Guest"):
raise frappe.PermissionError
project_user = validate_and_get_project_user(project=frappe.form_dict.project)
context.no_cache = 1
context.show_sidebar = True
project = frappe.get_doc("Project", frappe.form_dict.project)
project.has_permission("read")
project.tasks = get_tasks(
project.name, start=0, item_status="open", search=frappe.form_dict.get("search")
)
@@ -64,6 +55,22 @@ def get_tasks(project, start=0, search=None, item_status=None):
return list(filter(lambda x: not x.parent_task, tasks))
@frappe.whitelist()
def get_task_html(project: str, start: int = 0, item_status: str | None = None):
validate_and_get_project_user(project=project)
return frappe.render_template(
"erpnext/templates/includes/projects/project_tasks.html",
{
"doc": {
"name": project,
"project_name": project,
"tasks": get_tasks(project, start, item_status=item_status),
}
},
is_path=True,
)
def get_timesheets(project, start=0, search=None):
filters = {"project": project}
if search:
@@ -89,9 +96,28 @@ def get_timesheets(project, start=0, search=None):
return timesheets
@frappe.whitelist()
def get_timesheet_html(project: str, start: int = 0):
validate_and_get_project_user(project=project)
return frappe.render_template(
"erpnext/templates/includes/projects/project_timesheets.html",
{"doc": {"timesheets": get_timesheets(project, start)}},
is_path=True,
)
def get_attachments(project):
return frappe.get_all(
"File",
filters={"attached_to_name": project, "attached_to_doctype": "Project", "is_private": 0},
fields=["file_name", "file_url", "file_size"],
)
def validate_and_get_project_user(project: str):
project_doc = frappe.get_doc("Project", project)
project_doc.check_permission()
project_user = next((d for d in project_doc.users if d.user == frappe.session.user), None)
return project_user

View File

@@ -0,0 +1,72 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from erpnext.projects.doctype.project.test_project import make_project
from erpnext.templates.pages.projects import validate_and_get_project_user
from erpnext.tests.utils import ERPNextTestSuite
class TestProjectsPage(ERPNextTestSuite):
"""validate_and_get_project_user() gates the /projects portal page. It must raise
frappe.PermissionError for a user who can't read the Project, and otherwise return
that user's Project User row (or None if they're permitted but not listed as one --
e.g. an internal Projects Manager browsing the portal)."""
def _create_user(self, email):
if not frappe.db.exists("User", email):
frappe.get_doc(
{
"doctype": "User",
"email": email,
"first_name": "Portal",
"send_welcome_email": 0,
}
).insert(ignore_permissions=True)
return email
def test_raises_permission_error_for_user_without_access(self):
project = make_project({"project_name": f"_Test Portal Access {frappe.generate_hash(length=6)}"})
outsider = self._create_user(f"outsider_{frappe.generate_hash(length=6)}@example.com")
with self.set_user(outsider):
self.assertRaises(frappe.PermissionError, validate_and_get_project_user, project.name)
def test_allows_user_listed_as_project_user_and_returns_their_row(self):
# Being a Project User shares the Project with that user (see
# Project.control_access_for_project_users), which is what lets them past
# check_permission() here.
member = self._create_user(f"member_{frappe.generate_hash(length=6)}@example.com")
project = frappe.get_doc(
doctype="Project",
project_name=f"_Test Portal Access {frappe.generate_hash(length=6)}",
status="Open",
company="_Test Company",
)
project.append(
"users", {"user": member, "view_attachments": 1, "hide_timesheets": 1, "welcome_email_sent": 1}
)
project.insert()
with self.set_user(member):
project_user = validate_and_get_project_user(project.name)
self.assertIsNotNone(project_user)
self.assertEqual(project_user.user, member)
self.assertEqual(project_user.view_attachments, 1)
self.assertEqual(project_user.hide_timesheets, 1)
def test_allows_internally_permitted_user_not_listed_as_project_user(self):
# The permission gate must be the real permission system (check_permission()),
# not "is this user in the Project's users child table" -- a Projects Manager
# can open any project's portal page without ever being added as its user.
project = make_project({"project_name": f"_Test Portal Access {frappe.generate_hash(length=6)}"})
manager = self._create_user(f"manager_{frappe.generate_hash(length=6)}@example.com")
frappe.get_doc("User", manager).add_roles("Projects Manager")
with self.set_user(manager):
project_user = validate_and_get_project_user(project.name)
self.assertIsNone(project_user)

View File

@@ -39,11 +39,10 @@ build-backend = "flit_core.buildapi"
max_module_depth = 1
skip_namespaces = [
"erpnext.deprecation_dumpster",
"erpnext.assets.dashboard_fixtures", # https://github.com/frappe/erpnext/issues/44418
]
[tool.bench.frappe-dependencies]
frappe = ">=16.0.0,<17.0.0"
frappe = ">=16.21.0,<17.0.0"
[tool.bench.assets]
build_dir = "./banking"