mirror of
https://github.com/frappe/erpnext.git
synced 2026-07-02 13:16:55 +00:00
Compare commits
8 Commits
chore/test
...
l10n_devel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7362ef043e | ||
|
|
57f7ed8d82 | ||
|
|
1ad1570e3e | ||
|
|
5d762c6e9e | ||
|
|
af3a08dd9e | ||
|
|
e52562f711 | ||
|
|
f5a2503206 | ||
|
|
ffec95263c |
10
.github/POSTGRES_COMPATIBILITY.md
vendored
10
.github/POSTGRES_COMPATIBILITY.md
vendored
@@ -177,16 +177,6 @@ These are auto-handled by the framework and are **not** breaks:
|
||||
frappe#40075). Such a handler must wrap the fallible insert in `frappe.db.savepoint(name)` +
|
||||
`rollback(save_point=name)` — unless it re-`throw`s with no DB call before the throw, or the
|
||||
insert uses `ignore_if_duplicate=True` / `autoname="hash"` (→ `ON CONFLICT DO NOTHING`).
|
||||
- **Recover the txn with a *scoped* savepoint, not a full `frappe.db.rollback()`, if any prior work
|
||||
must survive.** A full rollback un-poisons the txn but also discards every row the handler committed
|
||||
*before* the failure — which MariaDB kept (it has no statement-abort), so it's a **silent MariaDB
|
||||
regression**. **"The background job / whitelist entrypoint owns the txn" does NOT make a full rollback
|
||||
safe** if it did multiple inserts in a loop first — it drops the partial results MariaDB retained. A
|
||||
full rollback is safe only when it (a) immediately re-`throw`s/`raise`s (MariaDB rolls back anyway),
|
||||
(b) has nothing successful before it (a single op), or (c) the batch is genuinely meant to be
|
||||
**atomic** (a partial result is an invalid state → rollback + mark *Failed* is correct). Otherwise use
|
||||
a **per-iteration / per-record savepoint** — and keep the function's success/`None` return contract:
|
||||
do **not** return the doc when the savepoint was rolled back.
|
||||
|
||||
---
|
||||
|
||||
|
||||
7
.github/workflows/server-tests-postgres.yml
vendored
7
.github/workflows/server-tests-postgres.yml
vendored
@@ -105,11 +105,6 @@ jobs:
|
||||
FRAPPE_BRANCH: develop
|
||||
BENCH_CACHE_DIR: /home/runner/bench-cache
|
||||
|
||||
- name: Warm up test data
|
||||
run: |
|
||||
cd ~/frappe-bench/
|
||||
bench --site test_site run-tests --lightmode --module erpnext.tests.bootstrap_test_data
|
||||
|
||||
- name: Stop DB and stage datadir
|
||||
run: |
|
||||
PG_BIN=$(ls -d /usr/lib/postgresql/*/bin | sort -V | tail -1)
|
||||
@@ -137,7 +132,7 @@ jobs:
|
||||
compression-level: 0
|
||||
|
||||
test:
|
||||
name: Python Unit Tests
|
||||
name: Python Unit Tests (PG)
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -14,32 +14,33 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"chrono-node": "^2.9.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dayjs": "^1.11.20",
|
||||
"frappe-react-sdk": "^1.17.0",
|
||||
"frappe-react-sdk": "^1.15.0",
|
||||
"fuse.js": "^7.3.0",
|
||||
"jotai": "^2.20.1",
|
||||
"jotai-family": "^1.0.2",
|
||||
"jotai": "^2.20.0",
|
||||
"jotai-family": "^1.0.1",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lucide-react": "^1.14.0",
|
||||
"radix-ui": "^1.6.1",
|
||||
"react": "^19.2.7",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.6",
|
||||
"react-currency-input-field": "^4.0.5",
|
||||
"react-day-picker": "9.14.0",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-dropzone": "^15.0.0",
|
||||
"react-hook-form": "^7.75.0",
|
||||
"react-hotkeys-hook": "^5.3.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^8.1.0",
|
||||
"react-router": "^7.15.0",
|
||||
"react-router-dom": "^7.15.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"safe-expr-eval": "^1.0.4",
|
||||
@@ -51,15 +52,15 @@
|
||||
"vite": "^8.0.16"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^25.3.0",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.62.1"
|
||||
"typescript-eslint": "^8.48.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { lazy, useEffect } from 'react'
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router'
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { FrappeProvider } from 'frappe-react-sdk'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import BankReconciliation from '@/pages/BankReconciliation'
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
||||
import { useFrappeEventListener, useFrappePostCall } from 'frappe-react-sdk'
|
||||
import { toast } from 'sonner'
|
||||
import ErrorBanner from '@/components/ui/error-banner'
|
||||
import { Link, useNavigate } from 'react-router'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { useSetAtom } from 'jotai'
|
||||
|
||||
2612
banking/yarn.lock
2612
banking/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -179,12 +179,7 @@ def normalize_ctx_input(T: type) -> callable:
|
||||
|
||||
def decorator(func: callable):
|
||||
# conserve annotations for frappe.utils.typing_validations
|
||||
@functools.wraps(
|
||||
func,
|
||||
assigned=(
|
||||
a for a in functools.WRAPPER_ASSIGNMENTS if a not in ("__annotations__", "__annotate__")
|
||||
),
|
||||
)
|
||||
@functools.wraps(func, assigned=(a for a in functools.WRAPPER_ASSIGNMENTS if a != "__annotations__"))
|
||||
def wrapper(ctx: T | Document | dict | str, *args, **kwargs):
|
||||
if isinstance(ctx, Document):
|
||||
ctx = T(**ctx.as_dict())
|
||||
|
||||
@@ -37,10 +37,6 @@
|
||||
"account_type": "Stock",
|
||||
"account_category": "Stock Assets"
|
||||
},
|
||||
"Stock Delivered But Not Billed": {
|
||||
"account_type": "Stock Delivered But Not Billed",
|
||||
"account_category": "Stock Assets"
|
||||
},
|
||||
"account_type": "Stock",
|
||||
"account_category": "Stock Assets"
|
||||
},
|
||||
@@ -227,6 +223,10 @@
|
||||
"Stock Received But Not Billed": {
|
||||
"account_type": "Stock Received But Not Billed",
|
||||
"account_category": "Trade Payables"
|
||||
},
|
||||
"Stock Delivered But Not Billed": {
|
||||
"account_type": "Stock Delivered But Not Billed",
|
||||
"account_category": "Trade Payables"
|
||||
}
|
||||
},
|
||||
"Duties and Taxes": {
|
||||
|
||||
@@ -829,9 +829,7 @@ def compute_final_transactions(transaction_rows: list, date_format: str, amount_
|
||||
|
||||
if amount_format == 'Amount column has "CR"/"DR" values':
|
||||
amount = transaction_row.get("amount")
|
||||
|
||||
# If the amount column has CR/DR in it - we should remove any signs (negative or positive) from the amount
|
||||
float_amount = abs(get_float_amount(amount) or 0)
|
||||
float_amount = get_float_amount(amount)
|
||||
if "cr" in amount.lower():
|
||||
return 0, float_amount
|
||||
else:
|
||||
@@ -934,18 +932,14 @@ def extract_pdf_tables(content: bytes, password: str | None = None) -> list[dict
|
||||
from pypdf import PdfReader
|
||||
|
||||
reader = PdfReader(io.BytesIO(content))
|
||||
if reader.is_encrypted:
|
||||
# Try opening the PDF with a password - if no password is provided, try with a blank password
|
||||
if not password:
|
||||
password = ""
|
||||
if not reader.decrypt(password):
|
||||
frappe.throw(
|
||||
_(
|
||||
"This PDF is password protected. Please set the correct statement password on the"
|
||||
" Bank Account and try again."
|
||||
),
|
||||
title=_("Password Required"),
|
||||
)
|
||||
if reader.is_encrypted and (not password or not reader.decrypt(password)):
|
||||
frappe.throw(
|
||||
_(
|
||||
"This PDF is password protected. Please set the correct statement password on the"
|
||||
" Bank Account and try again."
|
||||
),
|
||||
title=_("Password Required"),
|
||||
)
|
||||
|
||||
text_settings = {"vertical_strategy": "text", "horizontal_strategy": "text"}
|
||||
tables = []
|
||||
|
||||
@@ -107,9 +107,6 @@ def auto_create_fiscal_year():
|
||||
)
|
||||
|
||||
for d in fiscal_year:
|
||||
# savepoint so a duplicate-year INSERT (Fiscal Year autoname=field:year) that aborts the
|
||||
# statement doesn't poison the whole scheduler transaction on Postgres and kill the next iteration
|
||||
frappe.db.savepoint("auto_create_fiscal_year")
|
||||
try:
|
||||
current_fy = frappe.get_doc("Fiscal Year", d[0])
|
||||
|
||||
@@ -130,7 +127,7 @@ def auto_create_fiscal_year():
|
||||
|
||||
new_fy.insert(ignore_permissions=True)
|
||||
except frappe.NameError:
|
||||
frappe.db.rollback(save_point="auto_create_fiscal_year")
|
||||
pass
|
||||
|
||||
|
||||
def get_from_and_to_date(fiscal_year):
|
||||
|
||||
@@ -471,25 +471,6 @@ def on_doctype_update():
|
||||
frappe.db.add_index("GL Entry", ["posting_date", "company"])
|
||||
frappe.db.add_index("GL Entry", ["party_type", "party"])
|
||||
|
||||
if frappe.db.db_type == "postgres":
|
||||
# Postgres-only partial/covering indexes for the financial reports (General Ledger, Trial
|
||||
# Balance, Balance Sheet, P&L), which always filter `is_cancelled = 0` and scope by company.
|
||||
# `where`/`include` are no-ops on MariaDB and its optimizer ignores these anyway, so they are
|
||||
# added only on postgres to avoid dead write overhead on this insert-hot table.
|
||||
frappe.db.add_index(
|
||||
"GL Entry",
|
||||
["company", "posting_date", "account"],
|
||||
index_name="gle_active_detail",
|
||||
where="is_cancelled = 0",
|
||||
)
|
||||
frappe.db.add_index(
|
||||
"GL Entry",
|
||||
["company", "account", "posting_date"],
|
||||
index_name="gle_active_cover",
|
||||
where="is_cancelled = 0",
|
||||
include=["debit", "credit"],
|
||||
)
|
||||
|
||||
|
||||
def rename_gle_sle_docs():
|
||||
for doctype in ["GL Entry", "Stock Ledger Entry"]:
|
||||
|
||||
@@ -29,7 +29,7 @@ frappe.ui.form.on("Journal Entry", {
|
||||
|
||||
refresh(frm) {
|
||||
if (frm.doc.reversal_of && (frm.is_new() || frm.doc.docstatus == 0)) {
|
||||
erpnext.journal_entry.lock_reversal_entry(frm);
|
||||
frm.set_read_only();
|
||||
}
|
||||
|
||||
erpnext.toggle_naming_series();
|
||||
@@ -232,13 +232,6 @@ Object.assign(erpnext.journal_entry, {
|
||||
}
|
||||
},
|
||||
|
||||
lock_reversal_entry(frm) {
|
||||
frm.fields
|
||||
.filter((field) => field.has_input)
|
||||
.forEach((field) => frm.set_df_property(field.df.fieldname, "read_only", 1));
|
||||
frm.set_df_property("accounts", "read_only", 1);
|
||||
},
|
||||
|
||||
add_custom_buttons(frm) {
|
||||
if (frm.doc.docstatus > 0) {
|
||||
frm.add_custom_button(
|
||||
|
||||
@@ -2795,9 +2795,6 @@ def get_open_payment_requests_for_references(references=None):
|
||||
.where(PR.docstatus == 1)
|
||||
.where(PR.outstanding_amount > 0) # to avoid old PRs with 0 outstanding amount
|
||||
.orderby(Coalesce(PR.transaction_date, PR.creation), order=frappe.qb.asc)
|
||||
# unique tiebreaker so PRs sharing a transaction_date allocate in the same order on both engines
|
||||
.orderby(PR.creation, order=frappe.qb.asc)
|
||||
.orderby(PR.name, order=frappe.qb.asc)
|
||||
).run(as_dict=True)
|
||||
|
||||
if not response:
|
||||
|
||||
@@ -360,15 +360,12 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
|
||||
self.make_period_closing_voucher(posting_date="2021-03-31")
|
||||
|
||||
frappe.db.set_value("Company", "Test PCV Company", "accounts_frozen_till_date", "2021-12-31")
|
||||
|
||||
try:
|
||||
make_reverse_gl_entries(
|
||||
voucher_type="Journal Entry",
|
||||
voucher_no=jv.name,
|
||||
)
|
||||
finally:
|
||||
frappe.db.set_value("Company", "Test PCV Company", "accounts_frozen_till_date", None)
|
||||
# Passed posting_date is after PCV end date, so cancellation should not fail.
|
||||
make_reverse_gl_entries(
|
||||
voucher_type="Journal Entry",
|
||||
voucher_no=jv.name,
|
||||
posting_date="2022-01-01",
|
||||
)
|
||||
|
||||
totals_after_cancel = frappe.get_all(
|
||||
"GL Entry",
|
||||
|
||||
@@ -663,6 +663,7 @@ class POSInvoice(SalesInvoice):
|
||||
def set_pos_fields(self, for_validate=False):
|
||||
"""Set retail related fields from POS Profiles"""
|
||||
from erpnext.stock.get_item_details import (
|
||||
ItemDetailsCtx,
|
||||
get_pos_profile,
|
||||
get_pos_profile_item_details_,
|
||||
)
|
||||
@@ -735,7 +736,7 @@ class POSInvoice(SalesInvoice):
|
||||
for item in self.get("items"):
|
||||
if item.get("item_code"):
|
||||
profile_details = get_pos_profile_item_details_(
|
||||
frappe._dict(item.as_dict()), profile.get("company"), profile
|
||||
ItemDetailsCtx(item.as_dict()), profile.get("company"), profile
|
||||
)
|
||||
for fname, val in profile_details.items():
|
||||
if (not for_validate) or (for_validate and not item.get(fname)):
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h2 class="text-center">{{ _("STATEMENT OF ACCOUNTS") }}</h2>
|
||||
<h2 class="text-center">{{ _("GENERAL LEDGER") }}</h2>
|
||||
<div>
|
||||
{% if filters.party[0] == filters.party_name[0] %}
|
||||
<h5 style="float: left;">{{ _("Customer: ") }} <b>{{ filters.party_name[0] }}</b></h5>
|
||||
|
||||
@@ -33,11 +33,9 @@ class SalesInvoiceGLComposer(BaseGLComposer):
|
||||
|
||||
self.make_item_gl_entries(gl_entries)
|
||||
|
||||
disable_sdbnb_in_sr, is_sdbnb_enabled = frappe.get_cached_value(
|
||||
"Company", doc.company, ["disable_sdbnb_in_sr", "enable_stock_delivered_but_not_billed"]
|
||||
)
|
||||
disable_sdbnb_in_sr = frappe.get_cached_value("Company", doc.company, "disable_sdbnb_in_sr")
|
||||
|
||||
if is_sdbnb_enabled and not (doc.is_return and disable_sdbnb_in_sr):
|
||||
if not (doc.is_return and disable_sdbnb_in_sr):
|
||||
self.stock_delivered_but_not_billed_gl_entries(gl_entries)
|
||||
|
||||
self.make_precision_loss_gl_entry(gl_entries)
|
||||
|
||||
@@ -126,13 +126,13 @@ class POSService:
|
||||
doc.update_stock = 0 if dn_flag else cint(pos.get("update_stock"))
|
||||
|
||||
def _apply_pos_item_defaults(self, pos, for_validate: bool) -> None:
|
||||
from erpnext.stock.get_item_details import get_pos_profile_item_details_
|
||||
from erpnext.stock.get_item_details import ItemDetailsCtx, get_pos_profile_item_details_
|
||||
|
||||
for item in self.doc.get("items"):
|
||||
if not item.get("item_code"):
|
||||
continue
|
||||
profile_details = get_pos_profile_item_details_(
|
||||
frappe._dict(item.as_dict()), pos, pos, update_data=True
|
||||
ItemDetailsCtx(item.as_dict()), pos, pos, update_data=True
|
||||
)
|
||||
for fname, val in profile_details.items():
|
||||
if (not for_validate) or (for_validate and not item.get(fname)):
|
||||
|
||||
@@ -1576,14 +1576,14 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
frappe.db.set_single_value("POS Settings", "post_change_gl_entries", 1)
|
||||
|
||||
def test_stock_delivered_but_not_billed_gl_on_invoice(self):
|
||||
company = "_Test SDBNB Company"
|
||||
company = "_Test Company with perpetual inventory"
|
||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||
|
||||
make_purchase_receipt(
|
||||
company=company,
|
||||
item_code="_Test FG Item",
|
||||
warehouse="Stores - _TSDBNB",
|
||||
cost_center="Main - _TSDBNB",
|
||||
warehouse="Stores - TCP1",
|
||||
cost_center="Main - TCP1",
|
||||
qty=5,
|
||||
rate=100,
|
||||
)
|
||||
@@ -1591,13 +1591,13 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
dn = create_delivery_note(
|
||||
company=company,
|
||||
item_code="_Test FG Item",
|
||||
warehouse="Stores - _TSDBNB",
|
||||
cost_center="Main - _TSDBNB",
|
||||
warehouse="Stores - TCP1",
|
||||
cost_center="Main - TCP1",
|
||||
qty=2,
|
||||
rate=300,
|
||||
)
|
||||
# A perpetual-inventory Delivery Note books the cost to the SDBNB account
|
||||
self.assertEqual(dn.items[0].expense_account, "Stock Delivered But Not Billed - _TSDBNB")
|
||||
self.assertEqual(dn.items[0].expense_account, "Stock Delivered But Not Billed - TCP1")
|
||||
|
||||
si = make_sales_invoice(dn.name)
|
||||
si.insert()
|
||||
@@ -1609,9 +1609,9 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
fields=["account", "debit", "credit"],
|
||||
)
|
||||
sdbnb_credit = sum(
|
||||
row.credit for row in gl_entries if row.account == "Stock Delivered But Not Billed - _TSDBNB"
|
||||
row.credit for row in gl_entries if row.account == "Stock Delivered But Not Billed - TCP1"
|
||||
)
|
||||
cogs_debit = sum(row.debit for row in gl_entries if row.account == "Cost of Goods Sold - _TSDBNB")
|
||||
cogs_debit = sum(row.debit for row in gl_entries if row.account == "Cost of Goods Sold - TCP1")
|
||||
|
||||
# Billing reverses SDBNB and recognises the cost in COGS for an equal amount
|
||||
self.assertTrue(sdbnb_credit > 0)
|
||||
|
||||
@@ -640,15 +640,13 @@ def make_reverse_gl_entries(
|
||||
partial_cancel=partial_cancel,
|
||||
)
|
||||
validate_accounting_period(gl_entries)
|
||||
check_freezing_date(gl_entries[0]["posting_date"], gl_entries[0]["company"], adv_adj)
|
||||
|
||||
is_opening = any(d.get("is_opening") == "Yes" for d in gl_entries)
|
||||
|
||||
if immutable_ledger_enabled:
|
||||
validation_date = posting_date or frappe.form_dict.get("posting_date") or getdate()
|
||||
else:
|
||||
validation_date = posting_date if posting_date else gl_entries[0]["posting_date"]
|
||||
|
||||
check_freezing_date(validation_date, gl_entries[0]["company"], adv_adj)
|
||||
# For reverse entries, use the posting_date parameter if provided and valid
|
||||
# Otherwise fall back to original posting_date
|
||||
validation_date = posting_date if posting_date else gl_entries[0]["posting_date"]
|
||||
validate_against_pcv(is_opening, validation_date, gl_entries[0]["company"])
|
||||
|
||||
if partial_cancel:
|
||||
@@ -717,7 +715,7 @@ def make_reverse_gl_entries(
|
||||
|
||||
if immutable_ledger_enabled:
|
||||
new_gle["is_cancelled"] = 0
|
||||
new_gle["posting_date"] = posting_date or frappe.form_dict.get("posting_date") or getdate()
|
||||
new_gle["posting_date"] = frappe.form_dict.get("posting_date") or getdate()
|
||||
elif posting_date:
|
||||
new_gle["posting_date"] = posting_date
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"generate_csv": 0,
|
||||
"idx": 3,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2026-07-01 13:37:41.185347",
|
||||
"modified": "2026-06-25 12:03:36.559152",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Accounts Payable",
|
||||
@@ -40,6 +40,6 @@
|
||||
"role": "Auditor"
|
||||
}
|
||||
],
|
||||
"snapshot_report": 0,
|
||||
"synced_report": 0,
|
||||
"timeout": 0
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"generate_csv": 0,
|
||||
"idx": 5,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2026-07-01 13:37:44.167999",
|
||||
"modified": "2026-06-25 12:03:28.812092",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Accounts Receivable",
|
||||
@@ -34,6 +34,6 @@
|
||||
"role": "Accounts User"
|
||||
}
|
||||
],
|
||||
"snapshot_report": 0,
|
||||
"synced_report": 0,
|
||||
"timeout": 0
|
||||
}
|
||||
|
||||
@@ -277,7 +277,7 @@ def get_chart_data(filters, chart_columns, asset, liability, equity, currency):
|
||||
return chart
|
||||
|
||||
|
||||
def execute_snapshot_report(filters):
|
||||
def execute_synced_report(filters):
|
||||
from frappe.database.duckdb.database import get_latest_sync
|
||||
|
||||
if not (conn := get_latest_sync("GL Entry")):
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
from erpnext.accounts.report.bank_clearance_summary.bank_clearance_summary import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
BANK_ACCOUNT = "_Test Bank - _TC"
|
||||
|
||||
|
||||
class TestBankClearanceSummary(ERPNextTestSuite):
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"account": BANK_ACCOUNT,
|
||||
"company": "_Test Company",
|
||||
"from_date": "2026-01-01",
|
||||
"to_date": "2026-12-31",
|
||||
}
|
||||
)
|
||||
filters.update(extra)
|
||||
return execute(filters)[1]
|
||||
|
||||
def find_row(self, data, payment_entry):
|
||||
for row in data:
|
||||
if row[1] == payment_entry:
|
||||
return row
|
||||
return None
|
||||
|
||||
def test_uncleared_then_cleared_journal_entry(self):
|
||||
je = make_journal_entry(BANK_ACCOUNT, "Sales - _TC", 5000, submit=True, posting_date="2026-06-01")
|
||||
|
||||
# Uncleared: the bank row appears with the debit amount and no clearance date
|
||||
row = self.find_row(self.run_report(), je.name)
|
||||
self.assertIsNotNone(row, "Journal Entry not listed in Bank Clearance Summary")
|
||||
self.assertEqual(row[0], "Journal Entry")
|
||||
self.assertEqual(frappe.utils.getdate(row[2]), frappe.utils.getdate("2026-06-01"))
|
||||
self.assertIsNone(row[4]) # clearance_date empty -> uncleared
|
||||
self.assertEqual(row[5], "Sales - _TC") # against account
|
||||
self.assertEqual(row[6], 5000) # debit - credit on the bank account
|
||||
|
||||
# Cleared: set the clearance date on the Journal Entry and re-run
|
||||
frappe.db.set_value("Journal Entry", je.name, "clearance_date", "2026-06-05")
|
||||
|
||||
row = self.find_row(self.run_report(), je.name)
|
||||
self.assertIsNotNone(row)
|
||||
self.assertEqual(frappe.utils.getdate(row[4]), frappe.utils.getdate("2026-06-05"))
|
||||
self.assertEqual(row[6], 5000)
|
||||
|
||||
def test_date_filter_excludes_out_of_range_entries(self):
|
||||
je = make_journal_entry(BANK_ACCOUNT, "Sales - _TC", 3000, submit=True, posting_date="2026-06-10")
|
||||
|
||||
# Within range: present
|
||||
self.assertIsNotNone(self.find_row(self.run_report(), je.name))
|
||||
|
||||
# Window entirely after the posting date (from_date lower bound): excluded
|
||||
after = self.run_report(from_date="2026-07-01", to_date="2026-12-31")
|
||||
self.assertIsNone(self.find_row(after, je.name))
|
||||
|
||||
# Window ending before the posting date (to_date upper bound): excluded
|
||||
before = self.run_report(from_date="2026-01-01", to_date="2026-06-09")
|
||||
self.assertIsNone(self.find_row(before, je.name))
|
||||
@@ -31,7 +31,7 @@ def get_report_filters(report_filters):
|
||||
]
|
||||
|
||||
if report_filters.get("purchase_invoice"):
|
||||
filters.append(["Purchase Invoice", "name", "=", report_filters.get("purchase_invoice")])
|
||||
filters.append(["Purchase Invoice", "per_received", "in", [report_filters.get("purchase_invoice")]])
|
||||
|
||||
return filters
|
||||
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import today
|
||||
|
||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
||||
from erpnext.accounts.report.billed_items_to_be_received.billed_items_to_be_received import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestBilledItemsToBeReceived(ERPNextTestSuite):
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"posting_date": today(),
|
||||
}
|
||||
)
|
||||
filters.update(extra)
|
||||
return execute(filters)[1]
|
||||
|
||||
def get_rows_for(self, data, pi_name):
|
||||
return [row for row in data if row.get("name") == pi_name]
|
||||
|
||||
def test_billed_but_not_received_item_appears(self):
|
||||
pi = make_purchase_invoice(
|
||||
supplier="_Test Supplier",
|
||||
item_code="_Test Item",
|
||||
qty=5,
|
||||
rate=200,
|
||||
update_stock=0,
|
||||
)
|
||||
|
||||
rows = self.get_rows_for(self.run_report(), pi.name)
|
||||
self.assertEqual(len(rows), 1)
|
||||
|
||||
row = rows[0]
|
||||
self.assertEqual(row.get("supplier"), "_Test Supplier")
|
||||
self.assertEqual(row.get("company"), "_Test Company")
|
||||
self.assertEqual(row.get("item_code"), "_Test Item")
|
||||
self.assertEqual(row.get("qty"), 5)
|
||||
self.assertEqual(row.get("received_qty"), 0)
|
||||
self.assertEqual(row.get("rate"), 200)
|
||||
self.assertEqual(row.get("amount"), 1000)
|
||||
|
||||
def test_stock_updating_invoice_is_excluded(self):
|
||||
"""update_stock=1 means the item is already received; it must not appear."""
|
||||
pi = make_purchase_invoice(
|
||||
supplier="_Test Supplier",
|
||||
item_code="_Test Item",
|
||||
qty=5,
|
||||
rate=200,
|
||||
update_stock=1,
|
||||
)
|
||||
|
||||
rows = self.get_rows_for(self.run_report(), pi.name)
|
||||
self.assertEqual(len(rows), 0)
|
||||
|
||||
def test_fully_received_invoice_drops_off(self):
|
||||
"""When per_received reaches 100 the invoice is fully received and drops off."""
|
||||
pi = make_purchase_invoice(
|
||||
supplier="_Test Supplier",
|
||||
item_code="_Test Item",
|
||||
qty=5,
|
||||
rate=200,
|
||||
update_stock=0,
|
||||
)
|
||||
|
||||
# Present while nothing has been received.
|
||||
self.assertEqual(len(self.get_rows_for(self.run_report(), pi.name)), 1)
|
||||
|
||||
frappe.db.set_value("Purchase Invoice", pi.name, "per_received", 100)
|
||||
|
||||
# Absent once fully received.
|
||||
self.assertEqual(len(self.get_rows_for(self.run_report(), pi.name)), 0)
|
||||
|
||||
def test_posting_date_upper_bound_filter(self):
|
||||
"""A PI posted after the filter's posting_date must be excluded."""
|
||||
pi = make_purchase_invoice(
|
||||
supplier="_Test Supplier",
|
||||
item_code="_Test Item",
|
||||
qty=5,
|
||||
rate=200,
|
||||
update_stock=0,
|
||||
)
|
||||
|
||||
rows = self.get_rows_for(self.run_report(posting_date="2000-01-01"), pi.name)
|
||||
self.assertEqual(len(rows), 0)
|
||||
|
||||
def test_purchase_invoice_filter_scopes_to_that_invoice(self):
|
||||
"""The optional purchase_invoice filter must narrow to that invoice only."""
|
||||
pi = make_purchase_invoice(
|
||||
supplier="_Test Supplier", item_code="_Test Item", qty=5, rate=200, update_stock=0
|
||||
)
|
||||
other = make_purchase_invoice(
|
||||
supplier="_Test Supplier", item_code="_Test Item", qty=3, rate=200, update_stock=0
|
||||
)
|
||||
|
||||
names = {row.get("name") for row in self.run_report(purchase_invoice=pi.name)}
|
||||
self.assertEqual(names, {pi.name})
|
||||
self.assertNotIn(other.name, names)
|
||||
@@ -2,116 +2,26 @@
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import nowdate
|
||||
|
||||
from erpnext.accounts.doctype.budget.test_budget import make_budget, set_total_expense_zero
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
from erpnext.accounts.report.budget_variance_report.budget_variance_report import execute
|
||||
from erpnext.accounts.utils import get_fiscal_year
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
ACCOUNT = "_Test Account Cost for Goods Sold - _TC"
|
||||
COST_CENTER = "_Test Cost Center - _TC"
|
||||
COST_CENTER_2 = "_Test Cost Center 2 - _TC"
|
||||
|
||||
|
||||
class TestBudgetVarianceReport(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
self.fy = get_fiscal_year(nowdate())[0]
|
||||
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"from_fiscal_year": self.fy,
|
||||
"to_fiscal_year": self.fy,
|
||||
"period": "Yearly",
|
||||
"budget_against": "Cost Center",
|
||||
**extra,
|
||||
}
|
||||
)
|
||||
return execute(filters)[1]
|
||||
|
||||
def report_row(self, data, dimension, account=ACCOUNT):
|
||||
row = next(
|
||||
(r for r in data if r["budget_against"] == dimension and r["account"] == account),
|
||||
None,
|
||||
)
|
||||
self.assertIsNotNone(row, f"No report row for {dimension} / {account}")
|
||||
return row
|
||||
|
||||
def field(self, label):
|
||||
return frappe.scrub(f"{label} {self.fy}")
|
||||
|
||||
def test_report_executes(self):
|
||||
# Smoke-guards the raw-SQL -> query-builder port: the report query must compile and run on
|
||||
# both MariaDB and postgres.
|
||||
company = frappe.db.get_value("Company", {}, "name")
|
||||
fy = frappe.db.get_value("Fiscal Year", {}, "name", order_by="year_start_date desc")
|
||||
columns, *_rest = execute(
|
||||
frappe._dict(
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"from_fiscal_year": self.fy,
|
||||
"to_fiscal_year": self.fy,
|
||||
"company": company,
|
||||
"from_fiscal_year": fy,
|
||||
"to_fiscal_year": fy,
|
||||
"period": "Yearly",
|
||||
"budget_against": "Cost Center",
|
||||
}
|
||||
)
|
||||
)
|
||||
self.assertTrue(columns)
|
||||
|
||||
def test_budget_amount_shown_with_zero_actual(self):
|
||||
# neutralise any committed actuals so the exact Actual/Variance assertions hold
|
||||
set_total_expense_zero(nowdate(), "cost_center")
|
||||
make_budget(
|
||||
budget_against="Cost Center", cost_center=COST_CENTER, budget_amount=120000, submit_budget=1
|
||||
)
|
||||
|
||||
row = self.report_row(self.run_report(), COST_CENTER)
|
||||
self.assertEqual(row[self.field("Budget")], 120000)
|
||||
self.assertEqual(row[self.field("Actual")], 0)
|
||||
self.assertEqual(row[self.field("Variance")], 120000)
|
||||
|
||||
def test_actual_expense_updates_actual_and_variance(self):
|
||||
# zero out pre-committed actuals: keeps Actual exact and avoids the budget's
|
||||
# "Stop" action rejecting the journal entry when prior actuals already exist
|
||||
set_total_expense_zero(nowdate(), "cost_center")
|
||||
make_budget(
|
||||
budget_against="Cost Center", cost_center=COST_CENTER, budget_amount=120000, submit_budget=1
|
||||
)
|
||||
# book an actual expense well within the annual budget so the "Stop" action does not block it
|
||||
make_journal_entry(ACCOUNT, "_Test Bank - _TC", 50000, cost_center=COST_CENTER, submit=True)
|
||||
|
||||
row = self.report_row(self.run_report(), COST_CENTER)
|
||||
self.assertEqual(row[self.field("Actual")], 50000)
|
||||
self.assertEqual(row[self.field("Variance")], 70000) # 120000 - 50000
|
||||
|
||||
def test_budget_against_filter_limits_dimensions(self):
|
||||
make_budget(
|
||||
budget_against="Cost Center", cost_center=COST_CENTER, budget_amount=120000, submit_budget=1
|
||||
)
|
||||
make_budget(
|
||||
budget_against="Cost Center", cost_center=COST_CENTER_2, budget_amount=80000, submit_budget=1
|
||||
)
|
||||
|
||||
data = self.run_report(budget_against_filter=[COST_CENTER])
|
||||
dimensions = {row["budget_against"] for row in data}
|
||||
self.assertEqual(dimensions, {COST_CENTER})
|
||||
|
||||
def test_monthly_period_totals(self):
|
||||
# zero out pre-committed actuals so total_actual reflects only this test's entry
|
||||
set_total_expense_zero(nowdate(), "cost_center")
|
||||
make_budget(
|
||||
budget_against="Cost Center", cost_center=COST_CENTER, budget_amount=120000, submit_budget=1
|
||||
)
|
||||
make_journal_entry(ACCOUNT, "_Test Bank - _TC", 50000, cost_center=COST_CENTER, submit=True)
|
||||
|
||||
row = self.report_row(self.run_report(period="Monthly"), COST_CENTER)
|
||||
# totals roll up the per-month columns across the year
|
||||
self.assertEqual(row["total_budget"], 120000)
|
||||
self.assertEqual(row["total_actual"], 50000)
|
||||
self.assertEqual(row["total_variance"], 70000)
|
||||
|
||||
def test_no_budget_returns_no_rows(self):
|
||||
# a dimension without any budget produces no report rows
|
||||
data = self.run_report(budget_against_filter=["_Test Write Off Cost Center - _TC"])
|
||||
self.assertEqual(data, [])
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import json
|
||||
|
||||
import frappe
|
||||
from frappe.utils.formatters import format_value
|
||||
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.accounts.report.calculated_discount_mismatch.calculated_discount_mismatch import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestCalculatedDiscountMismatch(ERPNextTestSuite):
|
||||
"""Integrity detector: flag transactions whose stored ``discount_amount`` was tampered
|
||||
after the fact (a Version records the change) while ``additional_discount_percentage``
|
||||
stayed the same, so the stored amount no longer matches the percentage-derived value.
|
||||
"""
|
||||
|
||||
def run_report(self, docname: str) -> dict | None:
|
||||
"""Run the (filter-less) report and return the row for ``docname``, if any."""
|
||||
_columns, data = execute(frappe._dict({}))
|
||||
return next((row for row in data if row["docname"] == docname), None)
|
||||
|
||||
def create_discounted_invoice(self) -> "frappe.Document":
|
||||
"""Draft Sales Invoice (rate 1000) with a 10% additional discount.
|
||||
|
||||
The controller derives ``discount_amount`` = 10% of the grand total = 100.00,
|
||||
so the stored amount is consistent with the percentage.
|
||||
"""
|
||||
invoice = create_sales_invoice(rate=1000, qty=1, do_not_submit=1)
|
||||
invoice.additional_discount_percentage = 10
|
||||
invoice.save()
|
||||
invoice.reload()
|
||||
return invoice
|
||||
|
||||
def test_consistent_discount_is_not_flagged(self):
|
||||
"""A submitted invoice whose discount_amount matches its percentage is not reported."""
|
||||
invoice = self.create_discounted_invoice()
|
||||
invoice.submit()
|
||||
invoice.reload()
|
||||
|
||||
self.assertEqual(invoice.discount_amount, 100.0)
|
||||
self.assertIsNone(self.run_report(invoice.name))
|
||||
|
||||
def test_tampered_discount_is_flagged(self):
|
||||
"""Directly overwriting discount_amount (leaving the percentage intact) is reported.
|
||||
|
||||
This reproduces the real-world integrity breach: a Version records the
|
||||
``discount_amount`` change, its ``new`` value equals the current stored amount, and
|
||||
``additional_discount_percentage`` was not touched -- exactly the shape the report
|
||||
queries for.
|
||||
"""
|
||||
invoice = self.create_discounted_invoice()
|
||||
consistent_amount = invoice.discount_amount # 100.00, matches the 10% percentage
|
||||
tampered_amount = 250.0
|
||||
|
||||
discount_field = frappe.get_meta("Sales Invoice").get_field("discount_amount")
|
||||
# Format exactly as the report does so version.new == format_value(current amount).
|
||||
suspected = format_value(consistent_amount, df=discount_field, currency=invoice.currency)
|
||||
actual = format_value(tampered_amount, df=discount_field, currency=invoice.currency)
|
||||
|
||||
# Tamper the stored amount directly, bypassing the controller that would recompute it.
|
||||
frappe.db.set_value("Sales Invoice", invoice.name, "discount_amount", tampered_amount)
|
||||
self.record_discount_change(invoice.name, suspected, actual)
|
||||
|
||||
row = self.run_report(invoice.name)
|
||||
|
||||
self.assertIsNotNone(row)
|
||||
self.assertEqual(row["doctype"], "Sales Invoice")
|
||||
self.assertEqual(row["actual_discount_percentage"], 10.0)
|
||||
self.assertEqual(row["actual_discount_amount"], actual)
|
||||
self.assertEqual(row["suspected_discount_amount"], suspected)
|
||||
|
||||
def record_discount_change(self, docname: str, old: str, new: str) -> None:
|
||||
"""Insert the Version audit row a direct discount_amount edit would have produced."""
|
||||
version = frappe.new_doc("Version")
|
||||
version.ref_doctype = "Sales Invoice"
|
||||
version.docname = docname
|
||||
version.data = json.dumps({"changed": [["discount_amount", old, new]]}, separators=(",", ":"))
|
||||
version.flags.ignore_version = True
|
||||
version.insert(ignore_permissions=True)
|
||||
@@ -582,12 +582,7 @@ def prepare_data(accounts, start_date, end_date, balance_must_be, companies, com
|
||||
total += flt(row[company])
|
||||
|
||||
row["has_value"] = has_value
|
||||
# when accumulating into the group company, that company's column already consolidates its
|
||||
# descendants, so summing every company column would double-count; use the group total directly.
|
||||
if filters.get("accumulated_in_group_company"):
|
||||
row["total"] = flt(row.get(filters.company, 0.0), 3)
|
||||
else:
|
||||
row["total"] = total
|
||||
row["total"] = total
|
||||
|
||||
data.append(row)
|
||||
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import flt, today
|
||||
|
||||
from erpnext.accounts.report.consolidated_financial_statement.consolidated_financial_statement import (
|
||||
execute,
|
||||
)
|
||||
from erpnext.accounts.utils import get_fiscal_year
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
PARENT_COMPANY = "Parent Group Company India"
|
||||
CHILD_COMPANY = "Child Company India"
|
||||
|
||||
|
||||
class TestConsolidatedFinancialStatement(ERPNextTestSuite):
|
||||
"""Consolidation is exercised via the bootstrap group of companies
|
||||
(`Parent Group Company India` with child `Child Company India`). Income and
|
||||
expense posted in the child company must surface in the report that is run
|
||||
for the parent (group) company."""
|
||||
|
||||
def setUp(self):
|
||||
self.fiscal_year = get_fiscal_year(today(), company=PARENT_COMPANY)[0]
|
||||
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"company": PARENT_COMPANY,
|
||||
"filter_based_on": "Fiscal Year",
|
||||
"from_fiscal_year": self.fiscal_year,
|
||||
"to_fiscal_year": self.fiscal_year,
|
||||
"periodicity": "Yearly",
|
||||
"include_default_book_entries": 1,
|
||||
}
|
||||
)
|
||||
filters.update(extra)
|
||||
return execute(filters)[1]
|
||||
|
||||
def post_journal_entry(self, debit_account, credit_account, amount):
|
||||
je = frappe.new_doc("Journal Entry")
|
||||
je.posting_date = today()
|
||||
je.company = CHILD_COMPANY
|
||||
je.set(
|
||||
"accounts",
|
||||
[
|
||||
{"account": debit_account, "debit_in_account_currency": amount},
|
||||
{"account": credit_account, "credit_in_account_currency": amount},
|
||||
],
|
||||
)
|
||||
je.save()
|
||||
je.submit()
|
||||
return je
|
||||
|
||||
def get_row(self, data, account_name_fragment, last_match=False):
|
||||
"""Return the first (or last) row whose account_name contains the fragment.
|
||||
|
||||
Pass ``last_match=True`` to get the leaf/most-specific match when the fragment
|
||||
is also a prefix of a parent group account (parents precede children in tree order).
|
||||
"""
|
||||
found = None
|
||||
for row in data:
|
||||
if account_name_fragment in str(row.get("account_name") or ""):
|
||||
if not last_match:
|
||||
return row
|
||||
found = row
|
||||
return found
|
||||
|
||||
def test_profit_and_loss_reflects_child_company_income(self):
|
||||
amount = 7000
|
||||
self.post_journal_entry("Cash - CCI", "Sales - CCI", amount)
|
||||
|
||||
data = self.run_report(report="Profit and Loss Statement", accumulated_in_group_company=0)
|
||||
|
||||
self.assertTrue(data, "Report returned no rows")
|
||||
|
||||
# child's Sales account is mapped onto the parent chart (Sales - PGCI)
|
||||
sales_row = self.get_row(data, "Sales", last_match=True)
|
||||
self.assertIsNotNone(sales_row, "Sales row missing from consolidated P&L")
|
||||
# >= so a pre-existing Sales balance in the fiscal year doesn't make this brittle
|
||||
self.assertGreaterEqual(flt(sales_row.get(CHILD_COMPANY)), amount)
|
||||
|
||||
total_income_row = self.get_row(data, "Total Income (Credit)")
|
||||
self.assertIsNotNone(total_income_row, "Total Income row missing")
|
||||
self.assertGreaterEqual(flt(total_income_row.get("total")), amount)
|
||||
|
||||
def test_profit_and_loss_reflects_child_company_expense(self):
|
||||
amount = 3000
|
||||
self.post_journal_entry("Marketing Expenses - CCI", "Cash - CCI", amount)
|
||||
|
||||
data = self.run_report(report="Profit and Loss Statement", accumulated_in_group_company=0)
|
||||
|
||||
expense_row = self.get_row(data, "Marketing Expenses", last_match=True)
|
||||
self.assertIsNotNone(expense_row, "Marketing Expenses row missing from consolidated P&L")
|
||||
self.assertGreaterEqual(flt(expense_row.get(CHILD_COMPANY)), amount)
|
||||
|
||||
total_expense_row = self.get_row(data, "Total Expense (Debit)")
|
||||
self.assertIsNotNone(total_expense_row, "Total Expense row missing")
|
||||
self.assertGreaterEqual(flt(total_expense_row.get("total")), amount)
|
||||
|
||||
def test_accumulated_in_group_company_rolls_up_to_parent(self):
|
||||
"""With `accumulated_in_group_company`, the child's amount is also
|
||||
accumulated into the parent company column."""
|
||||
amount = 5000
|
||||
self.post_journal_entry("Cash - CCI", "Sales - CCI", amount)
|
||||
|
||||
data = self.run_report(report="Profit and Loss Statement", accumulated_in_group_company=1)
|
||||
|
||||
sales_row = self.get_row(data, "Sales", last_match=True)
|
||||
self.assertIsNotNone(sales_row)
|
||||
child_value = flt(sales_row.get(CHILD_COMPANY))
|
||||
self.assertGreaterEqual(child_value, amount)
|
||||
# parent column picks up the child value when accumulated
|
||||
self.assertEqual(flt(sales_row.get(PARENT_COMPANY)), child_value)
|
||||
# the total equals the consolidated (group) value, not the sum of parent + child
|
||||
# columns -- this is the regression guard for the double-count fix
|
||||
self.assertEqual(flt(sales_row.get("total")), child_value)
|
||||
|
||||
def test_balance_sheet_executes_and_returns_rows(self):
|
||||
# posting income leaves a balancing entry in the child's Cash (Asset) account
|
||||
amount = 4000
|
||||
self.post_journal_entry("Cash - CCI", "Sales - CCI", amount)
|
||||
|
||||
data = self.run_report(report="Balance Sheet", accumulated_in_group_company=0)
|
||||
|
||||
self.assertTrue(data, "Balance Sheet returned no rows")
|
||||
cash_row = self.get_row(data, "Cash")
|
||||
self.assertIsNotNone(cash_row, "Cash asset row missing from consolidated Balance Sheet")
|
||||
self.assertGreaterEqual(flt(cash_row.get(CHILD_COMPANY)), amount)
|
||||
@@ -1,89 +0,0 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.report.delivered_items_to_be_billed.delivered_items_to_be_billed import execute
|
||||
from erpnext.stock.doctype.delivery_note.mapper import make_sales_invoice
|
||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestDeliveredItemsToBeBilled(ERPNextTestSuite):
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"posting_date": "2026-06-30",
|
||||
}
|
||||
)
|
||||
filters.update(extra)
|
||||
return execute(filters)[1]
|
||||
|
||||
def stock_up_item(self):
|
||||
make_stock_entry(
|
||||
item_code="_Test Item",
|
||||
target="Stores - _TC",
|
||||
qty=20,
|
||||
basic_rate=100,
|
||||
posting_date="2026-05-25",
|
||||
)
|
||||
|
||||
def test_unbilled_delivery_note_appears(self):
|
||||
self.stock_up_item()
|
||||
dn = create_delivery_note(
|
||||
item_code="_Test Item",
|
||||
warehouse="Stores - _TC",
|
||||
qty=5,
|
||||
rate=300,
|
||||
customer="_Test Customer",
|
||||
posting_date="2026-06-01",
|
||||
)
|
||||
|
||||
rows = self.run_report(delivery_note=dn.name)
|
||||
self.assertEqual(len(rows), 1)
|
||||
|
||||
row = rows[0]
|
||||
self.assertEqual(row.name, dn.name)
|
||||
self.assertEqual(row.customer, "_Test Customer")
|
||||
self.assertEqual(row.item_code, "_Test Item")
|
||||
self.assertEqual(row.amount, 1500)
|
||||
self.assertEqual(row.billed_amount, 0)
|
||||
self.assertEqual(row.returned_amount, 0)
|
||||
self.assertEqual(row.pending_amount, 1500)
|
||||
|
||||
def test_fully_billed_delivery_note_drops_out(self):
|
||||
self.stock_up_item()
|
||||
dn = create_delivery_note(
|
||||
item_code="_Test Item",
|
||||
warehouse="Stores - _TC",
|
||||
qty=5,
|
||||
rate=300,
|
||||
customer="_Test Customer",
|
||||
posting_date="2026-06-01",
|
||||
)
|
||||
|
||||
self.assertEqual(len(self.run_report(delivery_note=dn.name)), 1)
|
||||
|
||||
si = make_sales_invoice(dn.name)
|
||||
si.posting_date = "2026-06-02"
|
||||
si.set_posting_time = 1
|
||||
si.insert()
|
||||
si.submit()
|
||||
|
||||
self.assertEqual(self.run_report(delivery_note=dn.name), [])
|
||||
|
||||
def test_date_filter_excludes_later_delivery_notes(self):
|
||||
self.stock_up_item()
|
||||
dn = create_delivery_note(
|
||||
item_code="_Test Item",
|
||||
warehouse="Stores - _TC",
|
||||
qty=5,
|
||||
rate=300,
|
||||
customer="_Test Customer",
|
||||
posting_date="2026-07-15",
|
||||
)
|
||||
|
||||
rows = self.run_report(delivery_note=dn.name, posting_date="2026-06-30")
|
||||
self.assertEqual(rows, [])
|
||||
@@ -17,7 +17,7 @@
|
||||
"generate_csv": 0,
|
||||
"idx": 4,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2026-07-01 13:36:06.682661",
|
||||
"modified": "2026-06-22 13:38:35.057216",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "General Ledger",
|
||||
@@ -37,6 +37,6 @@
|
||||
"role": "Auditor"
|
||||
}
|
||||
],
|
||||
"snapshot_report": 0,
|
||||
"synced_report": 0,
|
||||
"timeout": 0
|
||||
}
|
||||
|
||||
@@ -820,7 +820,7 @@ def get_columns(filters):
|
||||
return columns
|
||||
|
||||
|
||||
def execute_snapshot_report(filters):
|
||||
def execute_synced_report(filters):
|
||||
from frappe.database.duckdb.database import get_latest_sync
|
||||
|
||||
if conn := get_latest_sync("GL Entry"):
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.account.test_account import create_account
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
from erpnext.accounts.report.gross_and_net_profit_report.gross_and_net_profit_report import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
BANK = "_Test Bank - _TC"
|
||||
INCOME_PARENT = "Income - _TC"
|
||||
EXPENSE_PARENT = "Expenses - _TC"
|
||||
# bootstrap leaf accounts that already have include_in_gross = 0 (no creation needed)
|
||||
NON_GROSS_INCOME = "_Test Account Sales - _TC"
|
||||
NON_GROSS_EXPENSE = "_Test Account Cost for Goods Sold - _TC"
|
||||
# an isolated fiscal year so other accounts contribute nothing to the totals
|
||||
FY = "_Test Fiscal Year 2049"
|
||||
DATE = "2049-06-01"
|
||||
|
||||
|
||||
class TestGrossAndNetProfitReport(ERPNextTestSuite):
|
||||
def run_report(self, from_fiscal_year=FY, to_fiscal_year=FY):
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"filter_based_on": "Fiscal Year",
|
||||
"from_fiscal_year": from_fiscal_year,
|
||||
"to_fiscal_year": to_fiscal_year,
|
||||
"period_start_date": "2049-01-01",
|
||||
"period_end_date": "2049-12-31",
|
||||
"periodicity": "Yearly",
|
||||
"accumulated_values": 0,
|
||||
"presentation_currency": None,
|
||||
}
|
||||
)
|
||||
return execute(filters)[1]
|
||||
|
||||
def make_account(self, name, parent, include_in_gross):
|
||||
account = create_account(account_name=name, parent_account=parent, company="_Test Company")
|
||||
frappe.db.set_value("Account", account, "include_in_gross", include_in_gross)
|
||||
return account
|
||||
|
||||
def book_income(self, account, amount):
|
||||
make_journal_entry(BANK, account, amount, posting_date=DATE, submit=True)
|
||||
|
||||
def book_expense(self, account, amount):
|
||||
make_journal_entry(account, BANK, amount, posting_date=DATE, submit=True)
|
||||
|
||||
def report_row(self, data, account):
|
||||
return next(row for row in data if row.get("account") == account)
|
||||
|
||||
def test_gross_profit_excludes_non_gross_accounts(self):
|
||||
# reuse bootstrap accounts for the non-gross (include_in_gross = 0) side
|
||||
gross_income = self.make_account("_Test GNP Gross Income", INCOME_PARENT, include_in_gross=1)
|
||||
gross_expense = self.make_account("_Test GNP Gross Expense", EXPENSE_PARENT, include_in_gross=1)
|
||||
|
||||
self.book_income(gross_income, 10000)
|
||||
self.book_income(NON_GROSS_INCOME, 2000)
|
||||
self.book_expense(gross_expense, 4000)
|
||||
self.book_expense(NON_GROSS_EXPENSE, 1000)
|
||||
|
||||
data = self.run_report()
|
||||
# gross profit only counts include_in_gross accounts: 10000 - 4000
|
||||
self.assertEqual(self.report_row(data, "'Gross Profit'")["total"], 6000)
|
||||
# net profit counts everything: (10000 + 2000) - (4000 + 1000)
|
||||
self.assertEqual(self.report_row(data, "'Net Profit'")["total"], 7000)
|
||||
|
||||
def test_net_profit_equals_gross_when_all_included(self):
|
||||
income = self.make_account("_Test GNP All Income", INCOME_PARENT, include_in_gross=1)
|
||||
expense = self.make_account("_Test GNP All Expense", EXPENSE_PARENT, include_in_gross=1)
|
||||
|
||||
self.book_income(income, 9000)
|
||||
self.book_expense(expense, 5000)
|
||||
|
||||
data = self.run_report()
|
||||
self.assertEqual(self.report_row(data, "'Gross Profit'")["total"], 4000)
|
||||
self.assertEqual(self.report_row(data, "'Net Profit'")["total"], 4000)
|
||||
|
||||
def test_nothing_included_in_gross_when_no_entries(self):
|
||||
# a fiscal year with no income/expense entries yields the placeholder row
|
||||
data = self.run_report(
|
||||
from_fiscal_year="_Test Fiscal Year 2048", to_fiscal_year="_Test Fiscal Year 2048"
|
||||
)
|
||||
self.assertEqual(data[0]["account"], "'Nothing is included in gross'")
|
||||
@@ -562,12 +562,7 @@ class GrossProfitGenerator:
|
||||
row.base_amount = packed_item.base_amount
|
||||
|
||||
# get buying amount
|
||||
if row.is_debit_note:
|
||||
# Rate adjustment debit notes have no stock movement, so buying amount is zero
|
||||
if not grouped_by_invoice:
|
||||
row.qty = 0
|
||||
row.buying_amount = 0
|
||||
elif row.item_code in product_bundles:
|
||||
if row.item_code in product_bundles:
|
||||
row.buying_amount = flt(
|
||||
self.get_buying_amount_from_product_bundle(row, product_bundles[row.item_code]),
|
||||
self.currency_precision,
|
||||
@@ -965,7 +960,6 @@ class GrossProfitGenerator:
|
||||
SalesInvoice.customer_group,
|
||||
SalesInvoice.customer_name,
|
||||
SalesInvoice.territory,
|
||||
SalesInvoice.is_debit_note,
|
||||
SalesInvoiceItem.item_code,
|
||||
SalesInvoice.base_net_total.as_("invoice_base_net_total"),
|
||||
SalesInvoiceItem.item_name,
|
||||
@@ -1146,7 +1140,6 @@ class GrossProfitGenerator:
|
||||
"posting_time": row.posting_time,
|
||||
"project": row.project,
|
||||
"update_stock": row.update_stock,
|
||||
"is_debit_note": row.is_debit_note,
|
||||
"customer": row.customer,
|
||||
"customer_group": row.customer_group,
|
||||
"customer_name": row.customer_name,
|
||||
@@ -1185,7 +1178,6 @@ class GrossProfitGenerator:
|
||||
"description": item.description,
|
||||
"warehouse": item.warehouse or row.warehouse,
|
||||
"update_stock": row.update_stock,
|
||||
"is_debit_note": row.is_debit_note,
|
||||
"item_group": "",
|
||||
"brand": "",
|
||||
"dn_detail": row.dn_detail,
|
||||
|
||||
@@ -700,160 +700,6 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
self.assertIsNone(data[1].buying_rate)
|
||||
self.assertEqual(data[1]["gross_profit_%"], 20)
|
||||
|
||||
def create_rate_adjustment_debit_note(self, against_invoice, adjustment_rate, item_code=None):
|
||||
"""Create a rate adjustment debit note with no stock movement."""
|
||||
dn = self.create_sales_invoice(qty=1, rate=adjustment_rate, do_not_save=True, do_not_submit=True)
|
||||
if item_code:
|
||||
dn.items[0].item_code = item_code
|
||||
dn.items[0].item_name = item_code
|
||||
dn.is_debit_note = 1
|
||||
dn.return_against = against_invoice.name
|
||||
dn.items[0].allow_zero_valuation_rate = 1
|
||||
return dn.save().submit()
|
||||
|
||||
def test_debit_note_has_zero_buying_amount_and_full_gross_profit(self):
|
||||
"""
|
||||
Rate adjustment debit note (is_debit_note=1) should show buying_amount=0
|
||||
since there is no stock movement. Gross profit equals the adjustment amount
|
||||
and gross profit % equals 100%.
|
||||
"""
|
||||
make_stock_entry(
|
||||
company=self.company,
|
||||
item_code=self.item,
|
||||
target=self.warehouse,
|
||||
qty=1,
|
||||
basic_rate=100,
|
||||
)
|
||||
|
||||
sinv = self.create_sales_invoice(qty=1, rate=200, do_not_submit=True)
|
||||
sinv.update_stock = 1
|
||||
sinv = sinv.save().submit()
|
||||
|
||||
debit_note = self.create_rate_adjustment_debit_note(sinv, adjustment_rate=20)
|
||||
|
||||
filters = frappe._dict(
|
||||
company=self.company,
|
||||
from_date=nowdate(),
|
||||
to_date=nowdate(),
|
||||
group_by="Invoice",
|
||||
)
|
||||
|
||||
columns, data = execute(filters=filters)
|
||||
|
||||
dn_item_rows = [
|
||||
x for x in data if x.get("parent_invoice") == debit_note.name and x.get("indent") == 1.0
|
||||
]
|
||||
self.assertEqual(len(dn_item_rows), 1)
|
||||
|
||||
dn_row = dn_item_rows[0]
|
||||
self.assertEqual(dn_row.buying_amount, 0.0)
|
||||
self.assertEqual(dn_row.selling_amount, 20.0)
|
||||
self.assertEqual(dn_row.gross_profit, 20.0)
|
||||
self.assertEqual(dn_row["gross_profit_%"], 100.0)
|
||||
|
||||
def test_original_invoice_unaffected_by_rate_adjustment_debit_note(self):
|
||||
"""
|
||||
The original invoice's GP should be derived solely from its own selling
|
||||
amount and COGS — the rate adjustment debit note must not alter it.
|
||||
"""
|
||||
make_stock_entry(
|
||||
company=self.company,
|
||||
item_code=self.item,
|
||||
target=self.warehouse,
|
||||
qty=1,
|
||||
basic_rate=100,
|
||||
)
|
||||
|
||||
sinv = self.create_sales_invoice(qty=1, rate=200, do_not_submit=True)
|
||||
sinv.update_stock = 1
|
||||
sinv = sinv.save().submit()
|
||||
|
||||
self.create_rate_adjustment_debit_note(sinv, adjustment_rate=20)
|
||||
|
||||
filters = frappe._dict(
|
||||
company=self.company,
|
||||
from_date=nowdate(),
|
||||
to_date=nowdate(),
|
||||
group_by="Invoice",
|
||||
)
|
||||
|
||||
columns, data = execute(filters=filters)
|
||||
|
||||
sinv_item_rows = [x for x in data if x.get("parent_invoice") == sinv.name and x.get("indent") == 1.0]
|
||||
self.assertEqual(len(sinv_item_rows), 1)
|
||||
|
||||
sinv_row = sinv_item_rows[0]
|
||||
self.assertEqual(sinv_row.selling_amount, 200.0)
|
||||
self.assertEqual(sinv_row.buying_amount, 100.0)
|
||||
self.assertEqual(sinv_row.gross_profit, 100.0)
|
||||
self.assertEqual(sinv_row["gross_profit_%"], 50.0)
|
||||
|
||||
def test_debit_note_qty_not_inflated_in_grouped_report(self):
|
||||
"""
|
||||
When grouped by Item Code, the debit note (qty=0) must not inflate
|
||||
the group's qty or buying_amount. The selling amount and average
|
||||
selling rate correctly reflect the rate adjustment.
|
||||
"""
|
||||
item = create_item("_Test Rate Adjustment Debit Note Item")
|
||||
|
||||
make_stock_entry(
|
||||
company=self.company,
|
||||
item_code=item.item_code,
|
||||
target=self.warehouse,
|
||||
qty=1,
|
||||
basic_rate=100,
|
||||
)
|
||||
|
||||
sinv = create_sales_invoice(
|
||||
qty=1,
|
||||
rate=200,
|
||||
company=self.company,
|
||||
customer=self.customer,
|
||||
item_code=item.item_code,
|
||||
item_name=item.item_code,
|
||||
cost_center=self.cost_center,
|
||||
warehouse=self.warehouse,
|
||||
debit_to=self.debit_to,
|
||||
parent_cost_center=self.cost_center,
|
||||
update_stock=1,
|
||||
currency="INR",
|
||||
income_account=self.income_account,
|
||||
expense_account=self.expense_account,
|
||||
)
|
||||
|
||||
self.create_rate_adjustment_debit_note(sinv, adjustment_rate=20, item_code=item.item_code)
|
||||
|
||||
filters = frappe._dict(
|
||||
company=self.company,
|
||||
from_date=nowdate(),
|
||||
to_date=nowdate(),
|
||||
group_by="Item Code",
|
||||
)
|
||||
|
||||
columns, data = execute(filters=filters)
|
||||
|
||||
# group_by="Item Code" column order:
|
||||
# [item_code, item_name, brand, description, qty, base_rate,
|
||||
# buying_rate, base_amount, buying_amount, gross_profit, gross_profit_percent, currency]
|
||||
item_row = next((row for row in data if row[0] == item.item_code), None)
|
||||
self.assertIsNotNone(item_row)
|
||||
|
||||
qty, base_rate, buying_amount, base_amount, gross_profit, gp_percent = (
|
||||
item_row[4],
|
||||
item_row[5],
|
||||
item_row[8],
|
||||
item_row[7],
|
||||
item_row[9],
|
||||
item_row[10],
|
||||
)
|
||||
|
||||
self.assertEqual(qty, 1.0) # debit note adds qty=0, not inflated
|
||||
self.assertEqual(buying_amount, 100.0) # only original invoice COGS
|
||||
self.assertEqual(base_amount, 220.0) # 200 (original) + 20 (adjustment)
|
||||
self.assertEqual(base_rate, 220.0) # avg selling rate = 220/1
|
||||
self.assertEqual(gross_profit, 120.0) # 220 - 100
|
||||
self.assertAlmostEqual(gp_percent, 54.545, places=2) # 120/220 * 100
|
||||
|
||||
|
||||
def make_sales_person(sales_person_name="_Test Sales Person"):
|
||||
if not frappe.db.exists("Sales Person", {"sales_person_name": sales_person_name}):
|
||||
|
||||
@@ -84,8 +84,7 @@ def build_query_filters(filters: dict | None = None) -> list:
|
||||
qb_filters = []
|
||||
if filters:
|
||||
if filters.account:
|
||||
accounts = filters.account if isinstance(filters.account, list | tuple) else [filters.account]
|
||||
qb_filters.append(qb.Field("account").isin(accounts))
|
||||
qb_filters.append(qb.Field("account").isin(filters.account))
|
||||
|
||||
if filters.voucher_no:
|
||||
qb_filters.append(qb.Field("voucher_no").eq(filters.voucher_no))
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe import qb
|
||||
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
from erpnext.accounts.report.invalid_ledger_entries.invalid_ledger_entries import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestInvalidLedgerEntries(ERPNextTestSuite):
|
||||
"""Tests for the Invalid Ledger Entries integrity report.
|
||||
|
||||
The report flags vouchers that still have *active* ledger entries
|
||||
(GL Entry with is_cancelled=0 or Payment Ledger Entry with delinked=0)
|
||||
in the given period, but whose source voucher document is no longer
|
||||
submitted (docstatus != 1). Such orphaned ledgers indicate corruption.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.company = "_Test Company"
|
||||
self.debit_account = "_Test Bank - _TC"
|
||||
self.credit_account = "_Test Cash - _TC"
|
||||
self.from_date = "2026-01-01"
|
||||
self.to_date = "2026-12-31"
|
||||
self.posting_date = "2026-06-01"
|
||||
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"company": self.company,
|
||||
"from_date": self.from_date,
|
||||
"to_date": self.to_date,
|
||||
}
|
||||
)
|
||||
filters.update(extra)
|
||||
return execute(filters)[1]
|
||||
|
||||
def make_submitted_jv(self):
|
||||
return make_journal_entry(
|
||||
self.debit_account,
|
||||
self.credit_account,
|
||||
amount=500,
|
||||
posting_date=self.posting_date,
|
||||
company=self.company,
|
||||
submit=True,
|
||||
)
|
||||
|
||||
def test_healthy_voucher_not_flagged(self):
|
||||
"""A normal balanced, submitted Journal Entry must NOT be flagged."""
|
||||
jv = self.make_submitted_jv()
|
||||
|
||||
# It genuinely posted active GL entries, so it is in scope of the scan.
|
||||
self.assertTrue(
|
||||
frappe.db.exists(
|
||||
"GL Entry",
|
||||
{"voucher_no": jv.name, "is_cancelled": 0, "company": self.company},
|
||||
)
|
||||
)
|
||||
|
||||
flagged = {row.get("voucher_no") for row in self.run_report()}
|
||||
self.assertNotIn(jv.name, flagged)
|
||||
|
||||
def test_orphaned_gl_entries_flagged(self):
|
||||
"""A voucher whose document was set non-submitted while its GL entries
|
||||
remain active (is_cancelled=0) must be flagged as invalid."""
|
||||
jv = self.make_submitted_jv()
|
||||
|
||||
# Corrupt the state: mark the source document as cancelled (docstatus=2)
|
||||
# without cancelling/removing its GL Entries. This is the exact orphaned
|
||||
# ledger condition the report detects.
|
||||
frappe.db.set_value("Journal Entry", jv.name, "docstatus", 2, update_modified=False)
|
||||
|
||||
data = self.run_report()
|
||||
|
||||
matching = [
|
||||
row
|
||||
for row in data
|
||||
if row.get("voucher_no") == jv.name and row.get("voucher_type") == "Journal Entry"
|
||||
]
|
||||
self.assertEqual(len(matching), 1, "Orphaned voucher should be flagged exactly once")
|
||||
self.assertEqual(matching[0]["voucher_type"], "Journal Entry")
|
||||
self.assertEqual(matching[0]["voucher_no"], jv.name)
|
||||
|
||||
def test_voucher_no_filter_scopes_scan(self):
|
||||
"""The voucher_no filter must restrict the scan to that voucher only."""
|
||||
orphan = self.make_submitted_jv()
|
||||
other = self.make_submitted_jv()
|
||||
frappe.db.set_value("Journal Entry", orphan.name, "docstatus", 2, update_modified=False)
|
||||
frappe.db.set_value("Journal Entry", other.name, "docstatus", 2, update_modified=False)
|
||||
|
||||
flagged = {row.get("voucher_no") for row in self.run_report(voucher_no=orphan.name)}
|
||||
self.assertIn(orphan.name, flagged)
|
||||
self.assertNotIn(other.name, flagged)
|
||||
|
||||
def test_account_filter_scopes_scan(self):
|
||||
"""The account filter (a MultiSelectList, so a list) must restrict the
|
||||
scan to vouchers touching one of the given accounts."""
|
||||
orphan = self.make_submitted_jv()
|
||||
frappe.db.set_value("Journal Entry", orphan.name, "docstatus", 2, update_modified=False)
|
||||
|
||||
# Filtering on an account the voucher touches -> flagged.
|
||||
flagged = {row.get("voucher_no") for row in self.run_report(account=[self.debit_account])}
|
||||
self.assertIn(orphan.name, flagged)
|
||||
|
||||
# Filtering on an unrelated account -> not in scope.
|
||||
unrelated = "Creditors - _TC"
|
||||
flagged = {row.get("voucher_no") for row in self.run_report(account=[unrelated])}
|
||||
self.assertNotIn(orphan.name, flagged)
|
||||
|
||||
def test_account_filter_accepts_a_scalar(self):
|
||||
"""A scalar (non-list) account filter must not crash the query."""
|
||||
orphan = self.make_submitted_jv()
|
||||
frappe.db.set_value("Journal Entry", orphan.name, "docstatus", 2, update_modified=False)
|
||||
|
||||
flagged = {row.get("voucher_no") for row in self.run_report(account=self.debit_account)}
|
||||
self.assertIn(orphan.name, flagged)
|
||||
|
||||
def test_period_filter_excludes_out_of_range(self):
|
||||
"""Vouchers posted outside the from/to window must not be scanned."""
|
||||
orphan = self.make_submitted_jv()
|
||||
frappe.db.set_value("Journal Entry", orphan.name, "docstatus", 2, update_modified=False)
|
||||
|
||||
flagged = {
|
||||
row.get("voucher_no") for row in self.run_report(from_date="2025-01-01", to_date="2025-12-31")
|
||||
}
|
||||
self.assertNotIn(orphan.name, flagged)
|
||||
|
||||
def test_cancelled_gl_entries_not_flagged(self):
|
||||
"""If the ledger entries are properly cancelled (is_cancelled=1), the
|
||||
voucher is out of scope even when its document is non-submitted."""
|
||||
jv = self.make_submitted_jv()
|
||||
|
||||
gle = qb.DocType("GL Entry")
|
||||
qb.update(gle).set(gle.is_cancelled, 1).where(gle.voucher_no == jv.name).run()
|
||||
frappe.db.set_value("Journal Entry", jv.name, "docstatus", 2, update_modified=False)
|
||||
|
||||
flagged = {row.get("voucher_no") for row in self.run_report()}
|
||||
self.assertNotIn(jv.name, flagged)
|
||||
|
||||
def test_missing_filters_raises(self):
|
||||
"""validate_filters must guard mandatory inputs."""
|
||||
self.assertRaises(frappe.ValidationError, execute, None)
|
||||
|
||||
bad = frappe._dict({"from_date": self.from_date, "to_date": self.to_date})
|
||||
self.assertRaises(frappe.ValidationError, execute, bad)
|
||||
|
||||
reversed_dates = frappe._dict(
|
||||
{"company": self.company, "from_date": self.to_date, "to_date": self.from_date}
|
||||
)
|
||||
self.assertRaises(frappe.ValidationError, execute, reversed_dates)
|
||||
@@ -21,13 +21,6 @@ def execute(filters=None):
|
||||
entries = get_entries(filters)
|
||||
invoice_details = get_invoice_posting_date_map(filters)
|
||||
|
||||
# Only four range columns are defined (range1-range4, the last being "90 Above").
|
||||
# Three thresholds yield exactly four buckets, so payments more than 90 days after
|
||||
# the invoice land in range4 instead of an unread range5.
|
||||
report_filters = frappe._dict(filters)
|
||||
report_filters.range = "30, 60, 90"
|
||||
report = ReceivablePayableReport(report_filters)
|
||||
|
||||
data = []
|
||||
for d in entries:
|
||||
invoice = invoice_details.get(d.against_voucher_no) or frappe._dict()
|
||||
@@ -36,9 +29,7 @@ def execute(filters=None):
|
||||
d.update({"range1": 0, "range2": 0, "range3": 0, "range4": 0, "outstanding": payment_amount})
|
||||
|
||||
if d.against_voucher_no:
|
||||
# age the payment by how long after the invoice it was made (payment date - invoice date)
|
||||
report.age_as_on = getdate(d.posting_date)
|
||||
report.get_ageing_data(invoice.posting_date, d)
|
||||
ReceivablePayableReport(filters).get_ageing_data(invoice.posting_date, d)
|
||||
|
||||
row = [
|
||||
d.voucher_type,
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import getdate
|
||||
|
||||
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.accounts.report.payment_period_based_on_invoice_date.payment_period_based_on_invoice_date import (
|
||||
execute,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestPaymentPeriodBasedOnInvoiceDate(ERPNextTestSuite):
|
||||
"""Depth tests for the Payment Period Based On Invoice Date report.
|
||||
|
||||
The report lists Payment Ledger Entries against invoices and buckets the paid
|
||||
amount by the payment period -- how long after the invoice the payment was made
|
||||
(payment date - invoice date) -- into ranges: range1 (0-30), range2 (30-60),
|
||||
range3 (60-90), range4 (90 Above).
|
||||
"""
|
||||
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"payment_type": "Incoming",
|
||||
"party_type": "Customer",
|
||||
"from_date": "2026-01-01",
|
||||
"to_date": "2026-12-31",
|
||||
}
|
||||
)
|
||||
filters.update(extra)
|
||||
columns, data = execute(filters)
|
||||
fieldnames = [c["fieldname"] for c in columns]
|
||||
# Map each positional row to a dict keyed by column fieldname so assertions
|
||||
# stay correct even if a column is inserted or reordered.
|
||||
return columns, [dict(zip(fieldnames, row, strict=False)) for row in data]
|
||||
|
||||
def find_payment_row(self, data, payment_name):
|
||||
for row in data:
|
||||
if row["payment_entry"] == payment_name:
|
||||
return row
|
||||
return None
|
||||
|
||||
def pay_invoice(self, invoice, payment_date):
|
||||
pe = get_payment_entry("Sales Invoice", invoice.name)
|
||||
pe.posting_date = payment_date
|
||||
pe.reference_no = "1"
|
||||
pe.reference_date = payment_date
|
||||
pe.submit()
|
||||
return pe
|
||||
|
||||
def test_paid_amount_lands_in_0_30_bucket(self):
|
||||
# invoice 2026-06-01, paid 2026-06-20 -> 19 days after -> 0-30 bucket
|
||||
invoice = create_sales_invoice(customer="_Test Customer", rate=1000, posting_date="2026-06-01")
|
||||
payment = self.pay_invoice(invoice, "2026-06-20")
|
||||
|
||||
_columns, data = self.run_report()
|
||||
|
||||
row = self.find_payment_row(data, payment.name)
|
||||
self.assertIsNotNone(row, "Payment row not found in report output")
|
||||
|
||||
self.assertEqual(row["party_type"], "Customer")
|
||||
self.assertEqual(row["posting_date"], getdate("2026-06-20"))
|
||||
self.assertEqual(row["invoice"], invoice.name)
|
||||
self.assertEqual(row["invoice_posting_date"], getdate("2026-06-01"))
|
||||
self.assertEqual(row["amount"], 1000)
|
||||
self.assertEqual(row["age"], 19) # age = payment date - invoice date
|
||||
|
||||
# Buckets: 0-30 filled, others empty.
|
||||
self.assertEqual(row["range1"], 1000) # 0-30
|
||||
self.assertEqual(row["range2"], 0) # 30-60
|
||||
self.assertEqual(row["range3"], 0) # 60-90
|
||||
self.assertEqual(row["range4"], 0) # 90 Above
|
||||
|
||||
def test_paid_amount_lands_in_30_60_bucket(self):
|
||||
# invoice 2026-06-01, paid 2026-07-16 -> 45 days after -> 30-60 bucket
|
||||
invoice = create_sales_invoice(customer="_Test Customer 1", rate=1000, posting_date="2026-06-01")
|
||||
payment = self.pay_invoice(invoice, "2026-07-16")
|
||||
|
||||
_columns, data = self.run_report()
|
||||
|
||||
row = self.find_payment_row(data, payment.name)
|
||||
self.assertIsNotNone(row, "Payment row not found in report output")
|
||||
|
||||
self.assertEqual(row["amount"], 1000)
|
||||
self.assertEqual(row["age"], 45)
|
||||
# Buckets: 30-60 filled, others empty.
|
||||
self.assertEqual(row["range1"], 0)
|
||||
self.assertEqual(row["range2"], 1000)
|
||||
self.assertEqual(row["range3"], 0)
|
||||
self.assertEqual(row["range4"], 0)
|
||||
|
||||
def test_payment_over_90_days_lands_in_90_above_bucket(self):
|
||||
# invoice 2026-01-01, paid 2026-06-01 -> 151 days after -> "90 Above" bucket.
|
||||
# Regression guard: with four range columns, a payment older than the last
|
||||
# threshold must fall into range4 rather than an unread range5 (showing 0).
|
||||
invoice = create_sales_invoice(customer="_Test Customer 2", rate=1000, posting_date="2026-01-01")
|
||||
payment = self.pay_invoice(invoice, "2026-06-01")
|
||||
|
||||
_columns, data = self.run_report()
|
||||
|
||||
row = self.find_payment_row(data, payment.name)
|
||||
self.assertIsNotNone(row, "Payment row not found in report output")
|
||||
|
||||
self.assertEqual(row["amount"], 1000)
|
||||
self.assertEqual(row["age"], 151)
|
||||
self.assertEqual(row["range1"], 0)
|
||||
self.assertEqual(row["range2"], 0)
|
||||
self.assertEqual(row["range3"], 0)
|
||||
self.assertEqual(row["range4"], 1000) # 90 Above captures the full amount
|
||||
|
||||
def test_columns_expose_expected_age_buckets(self):
|
||||
columns, _data = self.run_report()
|
||||
labels_by_fieldname = {c["fieldname"]: c["label"] for c in columns}
|
||||
self.assertEqual(labels_by_fieldname["range1"], "0-30")
|
||||
self.assertEqual(labels_by_fieldname["range2"], "30-60")
|
||||
self.assertEqual(labels_by_fieldname["range3"], "60-90")
|
||||
self.assertEqual(labels_by_fieldname["range4"], "90 Above")
|
||||
# Sales Invoice link for Incoming payments.
|
||||
invoice_col = next(c for c in columns if c["fieldname"] == "invoice")
|
||||
self.assertEqual(invoice_col["options"], "Sales Invoice")
|
||||
|
||||
def test_invalid_payment_type_party_type_combo_throws(self):
|
||||
# Incoming + Supplier is invalid.
|
||||
self.assertRaises(
|
||||
frappe.ValidationError,
|
||||
self.run_report,
|
||||
payment_type="Incoming",
|
||||
party_type="Supplier",
|
||||
)
|
||||
# Outgoing + Customer is invalid.
|
||||
self.assertRaises(
|
||||
frappe.ValidationError,
|
||||
self.run_report,
|
||||
payment_type="Outgoing",
|
||||
party_type="Customer",
|
||||
)
|
||||
@@ -17,7 +17,7 @@
|
||||
"generate_csv": 0,
|
||||
"idx": 2,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2026-07-01 13:36:14.934965",
|
||||
"modified": "2026-06-22 13:38:15.898375",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Profit and Loss Statement",
|
||||
@@ -37,6 +37,6 @@
|
||||
"role": "Auditor"
|
||||
}
|
||||
],
|
||||
"snapshot_report": 0,
|
||||
"synced_report": 0,
|
||||
"timeout": 0
|
||||
}
|
||||
|
||||
@@ -207,7 +207,7 @@ def get_chart_data(filters, chart_columns, income, expense, net_profit_loss, cur
|
||||
return chart
|
||||
|
||||
|
||||
def execute_snapshot_report(filters):
|
||||
def execute_synced_report(filters):
|
||||
from frappe.database.duckdb.database import get_latest_sync
|
||||
|
||||
if not (conn := get_latest_sync("GL Entry")):
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.accounts.report.profitability_analysis.profitability_analysis import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
INCOME = "Sales - _TC"
|
||||
EXPENSE = "_Test Account Cost for Goods Sold - _TC"
|
||||
BANK = "_Test Bank - _TC"
|
||||
|
||||
|
||||
class TestProfitabilityAnalysis(ERPNextTestSuite):
|
||||
def run_report(self, fiscal_year="_Test Fiscal Year 2026", **extra):
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"based_on": "Cost Center",
|
||||
"fiscal_year": fiscal_year,
|
||||
"from_date": "2026-01-01",
|
||||
"to_date": "2026-12-31",
|
||||
**extra,
|
||||
}
|
||||
)
|
||||
return execute(filters)[1]
|
||||
|
||||
def make_cc(self, name, **args):
|
||||
create_cost_center(cost_center_name=name, **args)
|
||||
return name + " - _TC"
|
||||
|
||||
def row(self, data, account):
|
||||
return next(r for r in data if r.get("account") == account)
|
||||
|
||||
def book_income(self, cost_center, amount, posting_date="2026-06-01"):
|
||||
create_sales_invoice(
|
||||
cost_center=cost_center, income_account=INCOME, rate=amount, qty=1, posting_date=posting_date
|
||||
)
|
||||
|
||||
def book_expense(self, cost_center, amount, posting_date="2026-06-01"):
|
||||
make_journal_entry(
|
||||
EXPENSE, BANK, amount, cost_center=cost_center, posting_date=posting_date, submit=True
|
||||
)
|
||||
|
||||
def test_income_expense_and_gross_profit(self):
|
||||
# a dedicated leaf cost center keeps these exact assertions free of GL that
|
||||
# other tests may book against a shared cost center in the same fiscal year
|
||||
cc = self.make_cc("_Test PA Income Expense")
|
||||
self.book_income(cc, 10000)
|
||||
self.book_expense(cc, 4000)
|
||||
|
||||
row = self.row(self.run_report(), cc)
|
||||
self.assertEqual(row["income"], 10000)
|
||||
self.assertEqual(row["expense"], 4000)
|
||||
self.assertEqual(row["gross_profit_loss"], 6000)
|
||||
|
||||
def test_parent_cost_center_accumulates_children(self):
|
||||
parent = self.make_cc("_Test PA Parent", is_group=1)
|
||||
child_1 = self.make_cc("_Test PA Child 1", parent_cost_center=parent)
|
||||
child_2 = self.make_cc("_Test PA Child 2", parent_cost_center=parent)
|
||||
|
||||
self.book_income(child_1, 10000)
|
||||
self.book_expense(child_2, 3000)
|
||||
|
||||
data = self.run_report()
|
||||
self.assertEqual(self.row(data, child_1)["income"], 10000)
|
||||
self.assertEqual(self.row(data, child_2)["expense"], 3000)
|
||||
|
||||
parent_row = self.row(data, parent)
|
||||
self.assertEqual(parent_row["income"], 10000)
|
||||
self.assertEqual(parent_row["expense"], 3000)
|
||||
self.assertEqual(parent_row["gross_profit_loss"], 7000)
|
||||
|
||||
def test_date_range_excludes_out_of_period_entries(self):
|
||||
cc = self.make_cc("_Test PA Date Range")
|
||||
self.book_income(cc, 10000, posting_date="2025-06-01")
|
||||
|
||||
# the 2025 income must not appear in a 2026 report (zero-value rows are dropped)
|
||||
accounts_2026 = {r.get("account") for r in self.run_report()}
|
||||
self.assertNotIn(cc, accounts_2026)
|
||||
|
||||
row_2025 = self.row(
|
||||
self.run_report(
|
||||
fiscal_year="_Test Fiscal Year 2025", from_date="2025-01-01", to_date="2025-12-31"
|
||||
),
|
||||
cc,
|
||||
)
|
||||
self.assertEqual(row_2025["income"], 10000)
|
||||
|
||||
def test_total_row_sums_income_and_expense(self):
|
||||
cc = "_Test Cost Center - _TC"
|
||||
self.book_income(cc, 10000)
|
||||
self.book_expense(cc, 4000)
|
||||
|
||||
data = self.run_report()
|
||||
# the report appends a blank separator row and a totals row at the end
|
||||
total_row = data[-1]
|
||||
# the report wraps the (possibly translated) "Total" label in single quotes
|
||||
self.assertEqual(total_row["account"], "'" + frappe._("Total") + "'")
|
||||
# total is built from direct (non-accumulated) values, so it stays internally consistent
|
||||
self.assertEqual(total_row["gross_profit_loss"], total_row["income"] - total_row["expense"])
|
||||
# and it includes this test's bookings
|
||||
self.assertGreaterEqual(total_row["income"], 10000)
|
||||
self.assertGreaterEqual(total_row["expense"], 4000)
|
||||
@@ -1,171 +0,0 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
||||
from erpnext.accounts.report.purchase_invoice_trends.purchase_invoice_trends import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
FISCAL_YEAR = "_Test Fiscal Year 2026"
|
||||
COMPANY = "_Test Company"
|
||||
SUPPLIER = "_Test Supplier"
|
||||
ITEM = "_Test Item"
|
||||
POSTING_DATE = "2026-06-01"
|
||||
|
||||
|
||||
def make_dated_purchase_invoice(qty, rate):
|
||||
# make_purchase_invoice ignores posting_date unless posting time is explicitly set, so build the
|
||||
# invoice unsubmitted, pin the posting date, then submit to land it in the intended period bucket.
|
||||
pi = make_purchase_invoice(
|
||||
supplier=SUPPLIER, item_code=ITEM, qty=qty, rate=rate, posting_date=POSTING_DATE, do_not_submit=1
|
||||
)
|
||||
pi.set_posting_time = 1
|
||||
pi.posting_date = POSTING_DATE
|
||||
pi.submit()
|
||||
return pi
|
||||
|
||||
|
||||
class TestPurchaseInvoiceTrends(ERPNextTestSuite):
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"company": COMPANY,
|
||||
"fiscal_year": FISCAL_YEAR,
|
||||
"period": "Yearly",
|
||||
"based_on": "Item",
|
||||
}
|
||||
)
|
||||
filters.update(extra)
|
||||
columns, data = execute(filters)
|
||||
labels = [c.split(":")[0] if isinstance(c, str) else c.get("label") for c in columns]
|
||||
return labels, data
|
||||
|
||||
@staticmethod
|
||||
def _cell(labels, row, label):
|
||||
return row[labels.index(label)]
|
||||
|
||||
def _find_row(self, data, key):
|
||||
for row in data:
|
||||
if row and row[0] == key:
|
||||
return row
|
||||
return None
|
||||
|
||||
def test_yearly_item_qty_and_amount(self):
|
||||
labels_before, data_before = self.run_report()
|
||||
before = self._find_row(data_before, ITEM)
|
||||
|
||||
qty, rate = 4, 250
|
||||
make_dated_purchase_invoice(qty, rate)
|
||||
|
||||
labels, data = self.run_report()
|
||||
self.assertIn("Item", labels)
|
||||
self.assertIn("Item Name", labels)
|
||||
self.assertIn("Currency", labels)
|
||||
self.assertIn("Total(Qty)", labels)
|
||||
self.assertIn("Total(Amt)", labels)
|
||||
# Yearly period bucket uses the fiscal year name as the label prefix
|
||||
self.assertIn(f"{FISCAL_YEAR} (Qty)", labels)
|
||||
self.assertIn(f"{FISCAL_YEAR} (Amt)", labels)
|
||||
|
||||
row = self._find_row(data, ITEM)
|
||||
self.assertIsNotNone(row)
|
||||
|
||||
before_qty = self._cell(labels_before, before, f"{FISCAL_YEAR} (Qty)") if before else 0
|
||||
before_amt = self._cell(labels_before, before, f"{FISCAL_YEAR} (Amt)") if before else 0
|
||||
before_tqty = self._cell(labels_before, before, "Total(Qty)") if before else 0
|
||||
before_tamt = self._cell(labels_before, before, "Total(Amt)") if before else 0
|
||||
|
||||
self.assertEqual(self._cell(labels, row, f"{FISCAL_YEAR} (Qty)") - before_qty, qty)
|
||||
self.assertEqual(self._cell(labels, row, f"{FISCAL_YEAR} (Amt)") - before_amt, qty * rate)
|
||||
self.assertEqual(self._cell(labels, row, "Total(Qty)") - before_tqty, qty)
|
||||
self.assertEqual(self._cell(labels, row, "Total(Amt)") - before_tamt, qty * rate)
|
||||
|
||||
def test_monthly_bucket(self):
|
||||
labels_before, data_before = self.run_report(period="Monthly")
|
||||
before = self._find_row(data_before, ITEM)
|
||||
|
||||
qty, rate = 3, 100
|
||||
make_dated_purchase_invoice(qty, rate)
|
||||
|
||||
labels, data = self.run_report(period="Monthly")
|
||||
# posting_date 2026-06-01 -> June bucket
|
||||
self.assertIn("Jun (Qty)", labels)
|
||||
self.assertIn("Jun (Amt)", labels)
|
||||
|
||||
row = self._find_row(data, ITEM)
|
||||
before_qty = self._cell(labels_before, before, "Jun (Qty)") if before else 0
|
||||
before_tamt = self._cell(labels_before, before, "Total(Amt)") if before else 0
|
||||
|
||||
self.assertEqual(self._cell(labels, row, "Jun (Qty)") - before_qty, qty)
|
||||
self.assertEqual(self._cell(labels, row, "Total(Amt)") - before_tamt, qty * rate)
|
||||
|
||||
def test_quarterly_bucket(self):
|
||||
labels_before, data_before = self.run_report(period="Quarterly")
|
||||
before = self._find_row(data_before, ITEM)
|
||||
|
||||
qty, rate = 2, 150
|
||||
make_dated_purchase_invoice(qty, rate)
|
||||
|
||||
labels, data = self.run_report(period="Quarterly")
|
||||
# 2026-06-01 falls in the Apr-Jun quarter
|
||||
self.assertIn("Apr-Jun (Qty)", labels)
|
||||
self.assertIn("Apr-Jun (Amt)", labels)
|
||||
|
||||
row = self._find_row(data, ITEM)
|
||||
before_qty = self._cell(labels_before, before, "Apr-Jun (Qty)") if before else 0
|
||||
before_amt = self._cell(labels_before, before, "Apr-Jun (Amt)") if before else 0
|
||||
|
||||
self.assertEqual(self._cell(labels, row, "Apr-Jun (Qty)") - before_qty, qty)
|
||||
self.assertEqual(self._cell(labels, row, "Apr-Jun (Amt)") - before_amt, qty * rate)
|
||||
|
||||
def test_based_on_supplier(self):
|
||||
labels_before, data_before = self.run_report(based_on="Supplier")
|
||||
before = self._find_row(data_before, SUPPLIER)
|
||||
|
||||
qty, rate = 5, 200
|
||||
make_dated_purchase_invoice(qty, rate)
|
||||
|
||||
labels, data = self.run_report(based_on="Supplier")
|
||||
self.assertIn("Supplier", labels)
|
||||
self.assertIn("Supplier Name", labels)
|
||||
self.assertIn("Supplier Group", labels)
|
||||
|
||||
row = self._find_row(data, SUPPLIER)
|
||||
self.assertIsNotNone(row)
|
||||
|
||||
before_tqty = self._cell(labels_before, before, "Total(Qty)") if before else 0
|
||||
before_tamt = self._cell(labels_before, before, "Total(Amt)") if before else 0
|
||||
|
||||
self.assertEqual(self._cell(labels, row, "Total(Qty)") - before_tqty, qty)
|
||||
self.assertEqual(self._cell(labels, row, "Total(Amt)") - before_tamt, qty * rate)
|
||||
|
||||
def test_group_by_item_under_supplier(self):
|
||||
labels_before, data_before = self.run_report(based_on="Supplier", group_by="Item")
|
||||
# group_by inserts an "Item" column; the item breakdown row carries the item key there
|
||||
item_idx = labels_before.index("Item")
|
||||
before = None
|
||||
for r in data_before:
|
||||
if r and r[0] != SUPPLIER and r[item_idx] == ITEM:
|
||||
before = r
|
||||
break
|
||||
|
||||
qty, rate = 6, 300
|
||||
make_dated_purchase_invoice(qty, rate)
|
||||
|
||||
labels, data = self.run_report(based_on="Supplier", group_by="Item")
|
||||
self.assertIn("Item", labels)
|
||||
|
||||
item_idx = labels.index("Item")
|
||||
row = None
|
||||
for r in data:
|
||||
if r and r[0] != SUPPLIER and r[0] != "'Total'" and r[item_idx] == ITEM:
|
||||
row = r
|
||||
break
|
||||
self.assertIsNotNone(row)
|
||||
|
||||
before_tqty = self._cell(labels_before, before, "Total(Qty)") if before else 0
|
||||
before_tamt = self._cell(labels_before, before, "Total(Amt)") if before else 0
|
||||
|
||||
self.assertEqual(self._cell(labels, row, "Total(Qty)") - before_tqty, qty)
|
||||
self.assertEqual(self._cell(labels, row, "Total(Amt)") - before_tamt, qty * rate)
|
||||
@@ -1,101 +0,0 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.report.received_items_to_be_billed.received_items_to_be_billed import execute
|
||||
from erpnext.stock.doctype.purchase_receipt.mapper import make_purchase_invoice as make_pi_from_pr
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestReceivedItemsToBeBilled(ERPNextTestSuite):
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"posting_date": "2026-06-30",
|
||||
}
|
||||
)
|
||||
filters.update(extra)
|
||||
return execute(filters)[1]
|
||||
|
||||
def get_row(self, data, purchase_receipt):
|
||||
matches = [row for row in data if row.get("name") == purchase_receipt]
|
||||
return matches[0] if matches else None
|
||||
|
||||
def test_unbilled_receipt_appears_with_pending_amount(self):
|
||||
pr = make_purchase_receipt(
|
||||
item_code="_Test Item",
|
||||
qty=5,
|
||||
rate=200,
|
||||
supplier="_Test Supplier",
|
||||
posting_date="2026-06-01",
|
||||
)
|
||||
|
||||
row = self.get_row(self.run_report(), pr.name)
|
||||
|
||||
self.assertIsNotNone(row, "Unbilled Purchase Receipt should appear in the report")
|
||||
self.assertEqual(row.get("supplier"), "_Test Supplier")
|
||||
self.assertEqual(row.get("item_code"), "_Test Item")
|
||||
self.assertEqual(row.get("amount"), 1000.0)
|
||||
self.assertEqual(row.get("billed_amount"), 0.0)
|
||||
self.assertEqual(row.get("returned_amount"), 0.0)
|
||||
self.assertEqual(row.get("pending_amount"), 1000.0)
|
||||
|
||||
def test_billed_receipt_drops_out_of_report(self):
|
||||
pr = make_purchase_receipt(
|
||||
item_code="_Test Item",
|
||||
qty=5,
|
||||
rate=200,
|
||||
supplier="_Test Supplier",
|
||||
posting_date="2026-06-01",
|
||||
)
|
||||
|
||||
self.assertIsNotNone(self.get_row(self.run_report(), pr.name))
|
||||
|
||||
pi = make_pi_from_pr(pr.name)
|
||||
pi.set_posting_time = 1
|
||||
pi.posting_date = "2026-06-02"
|
||||
pi.submit()
|
||||
|
||||
self.assertIsNone(
|
||||
self.get_row(self.run_report(), pr.name),
|
||||
"Fully billed Purchase Receipt should no longer appear in the report",
|
||||
)
|
||||
|
||||
def test_reference_field_filter_limits_to_single_receipt(self):
|
||||
first_pr = make_purchase_receipt(
|
||||
item_code="_Test Item",
|
||||
qty=5,
|
||||
rate=200,
|
||||
supplier="_Test Supplier",
|
||||
posting_date="2026-06-01",
|
||||
)
|
||||
second_pr = make_purchase_receipt(
|
||||
item_code="_Test Item",
|
||||
qty=3,
|
||||
rate=100,
|
||||
supplier="_Test Supplier",
|
||||
posting_date="2026-06-01",
|
||||
)
|
||||
|
||||
data = self.run_report(purchase_receipt=first_pr.name)
|
||||
|
||||
self.assertIsNotNone(self.get_row(data, first_pr.name))
|
||||
self.assertIsNone(self.get_row(data, second_pr.name))
|
||||
|
||||
def test_posting_date_cutoff_excludes_later_receipts(self):
|
||||
pr = make_purchase_receipt(
|
||||
item_code="_Test Item",
|
||||
qty=5,
|
||||
rate=200,
|
||||
supplier="_Test Supplier",
|
||||
posting_date="2026-06-15",
|
||||
)
|
||||
|
||||
self.assertIsNone(
|
||||
self.get_row(self.run_report(posting_date="2026-06-01"), pr.name),
|
||||
"Receipt dated after the cutoff should be excluded",
|
||||
)
|
||||
self.assertIsNotNone(self.get_row(self.run_report(posting_date="2026-06-30"), pr.name))
|
||||
@@ -1,118 +0,0 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.accounts.report.sales_invoice_trends.sales_invoice_trends import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
FISCAL_YEAR = "_Test Fiscal Year 2026"
|
||||
POSTING_DATE = "2026-06-01"
|
||||
|
||||
|
||||
class TestSalesInvoiceTrends(ERPNextTestSuite):
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"fiscal_year": FISCAL_YEAR,
|
||||
"based_on": "Item",
|
||||
"period": "Yearly",
|
||||
}
|
||||
)
|
||||
filters.update(extra)
|
||||
columns, data = execute(filters)
|
||||
labels = [c.split(":")[0] if isinstance(c, str) else c.get("label") for c in columns]
|
||||
return labels, data
|
||||
|
||||
def _cell(self, data, key_label, key_value, col_label, labels):
|
||||
"""Return the value at column `col_label` for the row whose first-column
|
||||
value equals `key_value`, or 0 if that row does not exist yet."""
|
||||
key_idx = labels.index(key_label)
|
||||
col_idx = labels.index(col_label)
|
||||
for row in data:
|
||||
if row[key_idx] == key_value:
|
||||
return row[col_idx] or 0
|
||||
return 0
|
||||
|
||||
def test_yearly_item_amount_and_total(self):
|
||||
# Yearly period => a single "<FY> (Qty)"/"(Amt)" bucket, plus Total(Qty)/Total(Amt).
|
||||
labels, before = self.run_report()
|
||||
qty_col = f"{FISCAL_YEAR} (Qty)"
|
||||
amt_col = f"{FISCAL_YEAR} (Amt)"
|
||||
before_qty = self._cell(before, "Item", "_Test Item", qty_col, labels)
|
||||
before_amt = self._cell(before, "Item", "_Test Item", amt_col, labels)
|
||||
before_tot_qty = self._cell(before, "Item", "_Test Item", "Total(Qty)", labels)
|
||||
before_tot_amt = self._cell(before, "Item", "_Test Item", "Total(Amt)", labels)
|
||||
|
||||
create_sales_invoice(item="_Test Item", qty=4, rate=200, posting_date=POSTING_DATE)
|
||||
|
||||
labels, after = self.run_report()
|
||||
self.assertEqual(self._cell(after, "Item", "_Test Item", qty_col, labels) - before_qty, 4)
|
||||
self.assertEqual(self._cell(after, "Item", "_Test Item", amt_col, labels) - before_amt, 800)
|
||||
self.assertEqual(self._cell(after, "Item", "_Test Item", "Total(Qty)", labels) - before_tot_qty, 4)
|
||||
self.assertEqual(self._cell(after, "Item", "_Test Item", "Total(Amt)", labels) - before_tot_amt, 800)
|
||||
|
||||
def test_monthly_lands_in_june_bucket(self):
|
||||
# Monthly period => one bucket per month; a 2026-06-01 invoice hits "Jun (Qty)"/"(Amt)".
|
||||
labels, before = self.run_report(period="Monthly")
|
||||
before_qty = self._cell(before, "Item", "_Test Item", "Jun (Qty)", labels)
|
||||
before_amt = self._cell(before, "Item", "_Test Item", "Jun (Amt)", labels)
|
||||
before_tot = self._cell(before, "Item", "_Test Item", "Total(Amt)", labels)
|
||||
|
||||
create_sales_invoice(item="_Test Item", qty=3, rate=100, posting_date=POSTING_DATE)
|
||||
|
||||
labels, after = self.run_report(period="Monthly")
|
||||
self.assertEqual(self._cell(after, "Item", "_Test Item", "Jun (Qty)", labels) - before_qty, 3)
|
||||
self.assertEqual(self._cell(after, "Item", "_Test Item", "Jun (Amt)", labels) - before_amt, 300)
|
||||
self.assertEqual(self._cell(after, "Item", "_Test Item", "Total(Amt)", labels) - before_tot, 300)
|
||||
# Nothing should leak into an unrelated month.
|
||||
self.assertEqual(self._cell(after, "Item", "_Test Item", "Jan (Amt)", labels), 0)
|
||||
|
||||
def test_quarterly_lands_in_apr_jun_bucket(self):
|
||||
# Quarterly period over a Jan-Dec fiscal year => Apr-Jun is the 2nd quarter; June lands there.
|
||||
labels, before = self.run_report(period="Quarterly")
|
||||
before_qty = self._cell(before, "Item", "_Test Item", "Apr-Jun (Qty)", labels)
|
||||
before_amt = self._cell(before, "Item", "_Test Item", "Apr-Jun (Amt)", labels)
|
||||
|
||||
create_sales_invoice(item="_Test Item", qty=5, rate=50, posting_date=POSTING_DATE)
|
||||
|
||||
labels, after = self.run_report(period="Quarterly")
|
||||
self.assertEqual(self._cell(after, "Item", "_Test Item", "Apr-Jun (Qty)", labels) - before_qty, 5)
|
||||
self.assertEqual(self._cell(after, "Item", "_Test Item", "Apr-Jun (Amt)", labels) - before_amt, 250)
|
||||
# Jan-Mar quarter must stay untouched.
|
||||
self.assertEqual(self._cell(after, "Item", "_Test Item", "Jan-Mar (Amt)", labels), 0)
|
||||
|
||||
def test_based_on_customer_total(self):
|
||||
# based_on=Customer => first column is "Customer"; the customer's Total(Amt) reflects the sale.
|
||||
labels, before = self.run_report(based_on="Customer")
|
||||
before_tot_qty = self._cell(before, "Customer", "_Test Customer", "Total(Qty)", labels)
|
||||
before_tot_amt = self._cell(before, "Customer", "_Test Customer", "Total(Amt)", labels)
|
||||
|
||||
create_sales_invoice(
|
||||
customer="_Test Customer", item="_Test Item", qty=2, rate=300, posting_date=POSTING_DATE
|
||||
)
|
||||
|
||||
labels, after = self.run_report(based_on="Customer")
|
||||
self.assertEqual(
|
||||
self._cell(after, "Customer", "_Test Customer", "Total(Qty)", labels) - before_tot_qty, 2
|
||||
)
|
||||
self.assertEqual(
|
||||
self._cell(after, "Customer", "_Test Customer", "Total(Amt)", labels) - before_tot_amt, 600
|
||||
)
|
||||
|
||||
def test_group_by_item_under_customer(self):
|
||||
# based_on=Customer + group_by=Item inserts an "Item" breakdown column before the period
|
||||
# buckets; the per-item detail row carries the item key and the amount for that customer/item.
|
||||
labels, before = self.run_report(based_on="Customer", group_by="Item")
|
||||
# In group_by mode the detail rows key off the group_by column ("Item"), so snapshot by item.
|
||||
before_amt = self._cell(before, "Item", "_Test Item", "Total(Amt)", labels)
|
||||
|
||||
create_sales_invoice(
|
||||
customer="_Test Customer", item="_Test Item", qty=6, rate=100, posting_date=POSTING_DATE
|
||||
)
|
||||
|
||||
labels, after = self.run_report(based_on="Customer", group_by="Item")
|
||||
self.assertIn("Item", labels)
|
||||
self.assertEqual(self._cell(after, "Item", "_Test Item", "Total(Amt)", labels) - before_amt, 600)
|
||||
@@ -15,6 +15,8 @@ def execute(filters=None):
|
||||
|
||||
columns = get_columns(filters)
|
||||
|
||||
filters.get("date")
|
||||
|
||||
data = []
|
||||
|
||||
if not filters.get("shareholder"):
|
||||
@@ -22,7 +24,7 @@ def execute(filters=None):
|
||||
else:
|
||||
share_type, no_of_shares, rate, amount = 1, 2, 3, 4
|
||||
|
||||
all_shares = get_all_shares(filters.get("shareholder"), filters.get("date"), filters.get("company"))
|
||||
all_shares = get_all_shares(filters.get("shareholder"))
|
||||
for share_entry in all_shares:
|
||||
row = False
|
||||
for datum in data:
|
||||
@@ -61,47 +63,5 @@ def get_columns(filters):
|
||||
return columns
|
||||
|
||||
|
||||
def get_all_shares(shareholder, date, company=None):
|
||||
"""Share movements for the shareholder up to (and including) `date`, signed by direction:
|
||||
shares received are positive, shares transferred/sold out are negative.
|
||||
|
||||
The shareholder and company predicates are pushed into the query so only the
|
||||
relevant transfers are fetched instead of scanning the whole table."""
|
||||
share_transfer = frappe.qb.DocType("Share Transfer")
|
||||
query = (
|
||||
frappe.qb.from_(share_transfer)
|
||||
.select(
|
||||
share_transfer.share_type,
|
||||
share_transfer.no_of_shares,
|
||||
share_transfer.rate,
|
||||
share_transfer.amount,
|
||||
share_transfer.from_shareholder,
|
||||
share_transfer.to_shareholder,
|
||||
)
|
||||
.where((share_transfer.docstatus == 1) & (share_transfer.date <= date))
|
||||
.where(
|
||||
(share_transfer.to_shareholder == shareholder) | (share_transfer.from_shareholder == shareholder)
|
||||
)
|
||||
.orderby(share_transfer.date)
|
||||
)
|
||||
|
||||
if company:
|
||||
query = query.where(share_transfer.company == company)
|
||||
|
||||
transfers = query.run(as_dict=True)
|
||||
|
||||
shares = []
|
||||
for transfer in transfers:
|
||||
if transfer.to_shareholder == shareholder:
|
||||
shares.append(transfer)
|
||||
elif transfer.from_shareholder == shareholder:
|
||||
shares.append(
|
||||
frappe._dict(
|
||||
share_type=transfer.share_type,
|
||||
no_of_shares=-transfer.no_of_shares,
|
||||
rate=transfer.rate,
|
||||
amount=-transfer.amount,
|
||||
)
|
||||
)
|
||||
|
||||
return shares
|
||||
def get_all_shares(shareholder):
|
||||
return frappe.get_doc("Shareholder", shareholder).share_balance
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.report.share_balance.share_balance import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
COMPANY = "_Test Company"
|
||||
|
||||
|
||||
class TestShareBalanceReport(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
self.share_type = create_share_type("_Test Share Balance Equity")
|
||||
self.shareholder = create_shareholder("_Test Share Balance Holder", COMPANY)
|
||||
|
||||
def test_date_filter_is_mandatory(self):
|
||||
self.assertRaises(frappe.ValidationError, execute, frappe._dict({"shareholder": self.shareholder}))
|
||||
|
||||
def test_no_shareholder_returns_empty_data(self):
|
||||
# `shareholder` is optional; without it the report yields no rows.
|
||||
columns, data = execute(frappe._dict({"date": "2026-06-01", "company": COMPANY}))
|
||||
self.assertEqual(data, [])
|
||||
self.assertEqual(len(columns), 5)
|
||||
|
||||
def test_balance_after_issue(self):
|
||||
create_share_transfer(
|
||||
transfer_type="Issue",
|
||||
to_shareholder=self.shareholder,
|
||||
share_type=self.share_type,
|
||||
from_no=1,
|
||||
to_no=100,
|
||||
no_of_shares=100,
|
||||
rate=10,
|
||||
date="2026-06-01",
|
||||
)
|
||||
|
||||
row = self.get_row(date="2026-06-05")
|
||||
self.assertEqual(row[0], self.shareholder)
|
||||
self.assertEqual(row[1], self.share_type)
|
||||
self.assertEqual(row[2], 100) # no_of_shares
|
||||
self.assertEqual(row[3], 10) # average rate
|
||||
self.assertEqual(row[4], 1000) # amount = 100 * 10
|
||||
|
||||
def test_company_filter_scopes_transfers(self):
|
||||
# the transfer is booked under `_Test Company`
|
||||
create_share_transfer(
|
||||
transfer_type="Issue",
|
||||
to_shareholder=self.shareholder,
|
||||
share_type=self.share_type,
|
||||
from_no=1,
|
||||
to_no=100,
|
||||
no_of_shares=100,
|
||||
rate=10,
|
||||
date="2026-06-01",
|
||||
)
|
||||
|
||||
# matching company: the holding shows up
|
||||
self.assertEqual(self.get_row(date="2026-06-05")[2], 100)
|
||||
|
||||
# a different company must not surface this shareholder's transfer
|
||||
other_company_data = execute(
|
||||
frappe._dict(
|
||||
{"date": "2026-06-05", "company": "_Test Company 1", "shareholder": self.shareholder}
|
||||
)
|
||||
)[1]
|
||||
self.assertEqual(other_company_data, [])
|
||||
|
||||
def test_balance_increases_on_second_issue(self):
|
||||
create_share_transfer(
|
||||
transfer_type="Issue",
|
||||
to_shareholder=self.shareholder,
|
||||
share_type=self.share_type,
|
||||
from_no=1,
|
||||
to_no=100,
|
||||
no_of_shares=100,
|
||||
rate=10,
|
||||
date="2026-06-01",
|
||||
)
|
||||
create_share_transfer(
|
||||
transfer_type="Issue",
|
||||
to_shareholder=self.shareholder,
|
||||
share_type=self.share_type,
|
||||
from_no=101,
|
||||
to_no=200,
|
||||
no_of_shares=100,
|
||||
rate=20,
|
||||
date="2026-06-10",
|
||||
)
|
||||
|
||||
# The report groups by share type, summing shares and amount and
|
||||
# recomputing the average rate: (1000 + 2000) / 200 = 15.
|
||||
row = self.get_row(date="2026-06-15")
|
||||
self.assertEqual(row[2], 200)
|
||||
self.assertEqual(row[3], 15)
|
||||
self.assertEqual(row[4], 3000)
|
||||
|
||||
def test_balance_reduces_after_transfer_out(self):
|
||||
other_holder = create_shareholder("_Test Share Balance Holder 2", COMPANY)
|
||||
create_share_transfer(
|
||||
transfer_type="Issue",
|
||||
to_shareholder=self.shareholder,
|
||||
share_type=self.share_type,
|
||||
from_no=1,
|
||||
to_no=100,
|
||||
no_of_shares=100,
|
||||
rate=10,
|
||||
date="2026-06-01",
|
||||
)
|
||||
create_share_transfer(
|
||||
transfer_type="Transfer",
|
||||
from_shareholder=self.shareholder,
|
||||
to_shareholder=other_holder,
|
||||
share_type=self.share_type,
|
||||
from_no=1,
|
||||
to_no=40,
|
||||
no_of_shares=40,
|
||||
rate=10,
|
||||
date="2026-06-10",
|
||||
)
|
||||
|
||||
row = self.get_row(date="2026-06-15")
|
||||
self.assertEqual(row[2], 60) # 100 issued - 40 transferred out
|
||||
self.assertEqual(row[4], 600)
|
||||
|
||||
other_row = self.get_row(date="2026-06-15", shareholder=other_holder)
|
||||
self.assertEqual(other_row[2], 40)
|
||||
self.assertEqual(other_row[4], 400)
|
||||
|
||||
def test_as_on_date_before_issue_shows_no_holding(self):
|
||||
# the report is as-on `date`: before any share transfer, the shareholder holds nothing
|
||||
create_share_transfer(
|
||||
transfer_type="Issue",
|
||||
to_shareholder=self.shareholder,
|
||||
share_type=self.share_type,
|
||||
from_no=1,
|
||||
to_no=100,
|
||||
no_of_shares=100,
|
||||
rate=10,
|
||||
date="2026-06-01",
|
||||
)
|
||||
|
||||
data = execute(
|
||||
frappe._dict({"date": "2026-05-01", "company": COMPANY, "shareholder": self.shareholder})
|
||||
)[1]
|
||||
self.assertEqual(data, [])
|
||||
|
||||
def test_as_on_date_reflects_holding_up_to_that_date(self):
|
||||
# two issues on different dates; an as-on date between them sees only the first
|
||||
create_share_transfer(
|
||||
transfer_type="Issue",
|
||||
to_shareholder=self.shareholder,
|
||||
share_type=self.share_type,
|
||||
from_no=1,
|
||||
to_no=100,
|
||||
no_of_shares=100,
|
||||
rate=10,
|
||||
date="2026-06-01",
|
||||
)
|
||||
create_share_transfer(
|
||||
transfer_type="Issue",
|
||||
to_shareholder=self.shareholder,
|
||||
share_type=self.share_type,
|
||||
from_no=101,
|
||||
to_no=200,
|
||||
no_of_shares=100,
|
||||
rate=20,
|
||||
date="2026-06-10",
|
||||
)
|
||||
|
||||
self.assertEqual(self.get_row(date="2026-06-05")[2], 100) # only the first issue
|
||||
self.assertEqual(self.get_row(date="2026-06-15")[2], 200) # both issues
|
||||
|
||||
def get_row(self, date, shareholder=None):
|
||||
filters = frappe._dict(
|
||||
{"date": date, "company": COMPANY, "shareholder": shareholder or self.shareholder}
|
||||
)
|
||||
data = execute(filters)[1]
|
||||
holdings = [r for r in data if r[1] == self.share_type]
|
||||
self.assertEqual(len(holdings), 1, f"Expected one row for share type, got: {data}")
|
||||
return holdings[0]
|
||||
|
||||
|
||||
def create_share_type(title):
|
||||
if not frappe.db.exists("Share Type", title):
|
||||
frappe.get_doc({"doctype": "Share Type", "title": title}).insert()
|
||||
return title
|
||||
|
||||
|
||||
def create_shareholder(title, company):
|
||||
shareholder = frappe.get_doc({"doctype": "Shareholder", "title": title, "company": company}).insert()
|
||||
return shareholder.name
|
||||
|
||||
|
||||
def create_share_transfer(**kwargs):
|
||||
kwargs.setdefault("company", COMPANY)
|
||||
kwargs.setdefault("asset_account", "Cash - _TC")
|
||||
kwargs.setdefault("equity_or_liability_account", "Creditors - _TC")
|
||||
transfer = frappe.get_doc({"doctype": "Share Transfer", **kwargs})
|
||||
transfer.submit()
|
||||
return transfer
|
||||
@@ -1,171 +0,0 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.report.share_ledger.share_ledger import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
COMPANY = "_Test Company"
|
||||
|
||||
# The report returns legacy positional columns (no fieldnames); name the indices once
|
||||
# here so a column reorder needs a single edit instead of silently shifting assertions.
|
||||
COL_SHAREHOLDER = 0
|
||||
COL_DATE = 1
|
||||
COL_TRANSFER_TYPE = 2
|
||||
COL_SHARE_TYPE = 3
|
||||
COL_NO_OF_SHARES = 4
|
||||
COL_RATE = 5
|
||||
COL_AMOUNT = 6
|
||||
COL_COMPANY = 7
|
||||
COL_SHARE_TRANSFER = 8
|
||||
|
||||
|
||||
class TestShareLedger(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
self.shareholder = self.create_shareholder("_Test Share Ledger Holder")
|
||||
# Issue 100 shares on 2026-06-01, then another 50 on 2026-06-10.
|
||||
self.first = self.issue_shares(date="2026-06-01", from_no=1, to_no=100, rate=10)
|
||||
self.second = self.issue_shares(date="2026-06-10", from_no=101, to_no=150, rate=12)
|
||||
|
||||
def test_ledger_lists_all_transfers_upto_date(self):
|
||||
data = self.run_report(shareholder=self.shareholder, date="2026-06-30")
|
||||
|
||||
self.assertEqual(len(data), 2)
|
||||
|
||||
first_row, second_row = data
|
||||
self.assertEqual(first_row[COL_SHAREHOLDER], self.shareholder)
|
||||
self.assertEqual(first_row[COL_DATE], frappe.utils.getdate("2026-06-01"))
|
||||
self.assertEqual(first_row[COL_TRANSFER_TYPE], "Issue")
|
||||
self.assertEqual(first_row[COL_SHARE_TYPE], "Equity")
|
||||
self.assertEqual(first_row[COL_NO_OF_SHARES], 100)
|
||||
self.assertEqual(first_row[COL_RATE], 10)
|
||||
self.assertEqual(first_row[COL_AMOUNT], 1000)
|
||||
self.assertEqual(first_row[COL_COMPANY], COMPANY)
|
||||
self.assertEqual(first_row[COL_SHARE_TRANSFER], self.first)
|
||||
|
||||
self.assertEqual(second_row[COL_DATE], frappe.utils.getdate("2026-06-10"))
|
||||
self.assertEqual(second_row[COL_NO_OF_SHARES], 50)
|
||||
self.assertEqual(second_row[COL_RATE], 12)
|
||||
self.assertEqual(second_row[COL_AMOUNT], 600)
|
||||
self.assertEqual(second_row[COL_SHARE_TRANSFER], self.second)
|
||||
|
||||
def test_running_balance_of_shares(self):
|
||||
data = self.run_report(shareholder=self.shareholder, date="2026-06-30")
|
||||
|
||||
# The ledger records each transfer's raw no_of_shares (always positive); it does
|
||||
# not sign by direction. With only incoming "Issue" rows here, summing them is a
|
||||
# valid running total. (Directional balances are the Share Balance report's job.)
|
||||
running = 0
|
||||
balances = []
|
||||
for row in data:
|
||||
running += row[COL_NO_OF_SHARES]
|
||||
balances.append(running)
|
||||
|
||||
self.assertEqual(balances, [100, 150])
|
||||
|
||||
def test_as_on_date_between_transfers_shows_only_first(self):
|
||||
data = self.run_report(shareholder=self.shareholder, date="2026-06-05")
|
||||
|
||||
self.assertEqual(len(data), 1)
|
||||
self.assertEqual(data[0][COL_SHARE_TRANSFER], self.first)
|
||||
self.assertEqual(data[0][COL_NO_OF_SHARES], 100)
|
||||
|
||||
def test_transfer_type_label_when_shareholder_is_seller(self):
|
||||
buyer = self.create_shareholder("_Test Share Ledger Buyer")
|
||||
transfer = self.make_transfer(
|
||||
from_shareholder=self.shareholder,
|
||||
to_shareholder=buyer,
|
||||
date="2026-06-15",
|
||||
from_no=1,
|
||||
to_no=40,
|
||||
rate=10,
|
||||
)
|
||||
|
||||
row = self.transfer_row(self.run_report(shareholder=self.shareholder, date="2026-06-30"), transfer)
|
||||
# seller side: the label names the counterparty it went "to"
|
||||
self.assertEqual(row[COL_TRANSFER_TYPE], f"Transfer to {buyer}")
|
||||
|
||||
def test_transfer_type_label_when_shareholder_is_buyer(self):
|
||||
seller = self.create_shareholder("_Test Share Ledger Seller")
|
||||
# the seller must own shares before it can transfer them
|
||||
self.issue_shares(date="2026-06-12", from_no=201, to_no=300, rate=10, shareholder=seller)
|
||||
transfer = self.make_transfer(
|
||||
from_shareholder=seller,
|
||||
to_shareholder=self.shareholder,
|
||||
date="2026-06-15",
|
||||
from_no=201,
|
||||
to_no=240,
|
||||
rate=10,
|
||||
)
|
||||
|
||||
row = self.transfer_row(self.run_report(shareholder=self.shareholder, date="2026-06-30"), transfer)
|
||||
# buyer side: the label names the counterparty it came "from"
|
||||
self.assertEqual(row[COL_TRANSFER_TYPE], f"Transfer from {seller}")
|
||||
|
||||
def test_missing_date_throws(self):
|
||||
self.assertRaises(frappe.ValidationError, execute, frappe._dict(shareholder=self.shareholder))
|
||||
|
||||
def test_missing_shareholder_returns_no_rows(self):
|
||||
data = self.run_report(date="2026-06-30")
|
||||
self.assertEqual(data, [])
|
||||
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict({"company": COMPANY, **extra})
|
||||
return execute(filters)[1]
|
||||
|
||||
def transfer_row(self, data, transfer_name):
|
||||
row = next((r for r in data if r[COL_SHARE_TRANSFER] == transfer_name), None)
|
||||
self.assertIsNotNone(row, f"Share Transfer {transfer_name} missing from ledger")
|
||||
return row
|
||||
|
||||
def create_shareholder(self, title):
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Shareholder",
|
||||
"title": title,
|
||||
"company": COMPANY,
|
||||
}
|
||||
).insert()
|
||||
return doc.name
|
||||
|
||||
def issue_shares(self, date, from_no, to_no, rate, shareholder=None):
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Share Transfer",
|
||||
"transfer_type": "Issue",
|
||||
"date": date,
|
||||
"to_shareholder": shareholder or self.shareholder,
|
||||
"share_type": "Equity",
|
||||
"from_no": from_no,
|
||||
"to_no": to_no,
|
||||
"no_of_shares": to_no - from_no + 1,
|
||||
"rate": rate,
|
||||
"company": COMPANY,
|
||||
"asset_account": "Cash - _TC",
|
||||
"equity_or_liability_account": "Creditors - _TC",
|
||||
}
|
||||
)
|
||||
doc.submit()
|
||||
return doc.name
|
||||
|
||||
def make_transfer(self, from_shareholder, to_shareholder, date, from_no, to_no, rate):
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Share Transfer",
|
||||
"transfer_type": "Transfer",
|
||||
"date": date,
|
||||
"from_shareholder": from_shareholder,
|
||||
"to_shareholder": to_shareholder,
|
||||
"share_type": "Equity",
|
||||
"from_no": from_no,
|
||||
"to_no": to_no,
|
||||
"no_of_shares": to_no - from_no + 1,
|
||||
"rate": rate,
|
||||
"company": COMPANY,
|
||||
"asset_account": "Cash - _TC",
|
||||
"equity_or_liability_account": "Creditors - _TC",
|
||||
}
|
||||
)
|
||||
doc.submit()
|
||||
return doc.name
|
||||
@@ -17,7 +17,7 @@
|
||||
"generate_csv": 0,
|
||||
"idx": 4,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2026-07-01 17:32:21.801141",
|
||||
"modified": "2026-06-22 13:38:42.740436",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Trial Balance",
|
||||
@@ -37,6 +37,6 @@
|
||||
"role": "Auditor"
|
||||
}
|
||||
],
|
||||
"snapshot_report": 0,
|
||||
"synced_report": 0,
|
||||
"timeout": 0
|
||||
}
|
||||
|
||||
@@ -583,7 +583,7 @@ def hide_group_accounts(data):
|
||||
return non_group_accounts_data
|
||||
|
||||
|
||||
def execute_snapshot_report(filters):
|
||||
def execute_synced_report(filters):
|
||||
from frappe.database.duckdb.database import get_latest_sync
|
||||
|
||||
if conn := get_latest_sync("GL Entry"):
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
from erpnext.accounts.report.voucher_wise_balance.voucher_wise_balance import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestVoucherWiseBalance(ERPNextTestSuite):
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"from_date": "2026-01-01",
|
||||
"to_date": "2026-12-31",
|
||||
}
|
||||
)
|
||||
filters.update(extra)
|
||||
return execute(filters)[1]
|
||||
|
||||
def find_row(self, data, voucher_no):
|
||||
for row in data:
|
||||
if row.get("voucher_no") == voucher_no:
|
||||
return row
|
||||
return None
|
||||
|
||||
def test_balanced_voucher_not_flagged(self):
|
||||
jv = make_journal_entry(
|
||||
"Sales - _TC", "_Test Bank - _TC", 1000, submit=True, posting_date="2026-06-01"
|
||||
)
|
||||
|
||||
data = self.run_report()
|
||||
self.assertIsNone(
|
||||
self.find_row(data, jv.name),
|
||||
msg="A balanced voucher (debit == credit) must not be flagged.",
|
||||
)
|
||||
|
||||
def test_imbalanced_voucher_flagged(self):
|
||||
jv = make_journal_entry(
|
||||
"Sales - _TC", "_Test Bank - _TC", 1000, submit=True, posting_date="2026-06-01"
|
||||
)
|
||||
|
||||
# Tamper one GL Entry: drop the debit side so debit != credit for this voucher.
|
||||
gle_name = frappe.db.get_value(
|
||||
"GL Entry",
|
||||
{"voucher_no": jv.name, "is_cancelled": 0, "debit": [">", 0]},
|
||||
"name",
|
||||
)
|
||||
self.assertIsNotNone(gle_name, msg="Expected a debit GL Entry for the journal entry.")
|
||||
frappe.db.set_value("GL Entry", gle_name, {"debit": 400, "debit_in_account_currency": 400})
|
||||
|
||||
data = self.run_report()
|
||||
row = self.find_row(data, jv.name)
|
||||
self.assertIsNotNone(row, msg="An imbalanced voucher must be flagged by the report.")
|
||||
|
||||
self.assertEqual(row.get("voucher_type"), "Journal Entry")
|
||||
self.assertEqual(row.get("credit"), 1000)
|
||||
self.assertEqual(row.get("debit"), 400)
|
||||
self.assertNotEqual(
|
||||
row.get("debit"), row.get("credit"), msg="Flagged rows must have debit != credit."
|
||||
)
|
||||
@@ -12,6 +12,7 @@ from frappe.utils import cint, flt, parse_json
|
||||
import erpnext
|
||||
from erpnext.stock.get_item_details import (
|
||||
NOT_APPLICABLE_TAX,
|
||||
ItemDetailsCtx,
|
||||
_get_item_tax_template,
|
||||
_get_item_tax_template_from_item_group,
|
||||
get_item_tax_map,
|
||||
@@ -349,7 +350,7 @@ def set_balance_in_account_currency(
|
||||
|
||||
|
||||
def set_child_tax_template_and_map(item, child_item, parent_doc) -> None:
|
||||
ctx = frappe._dict(
|
||||
ctx = ItemDetailsCtx(
|
||||
{
|
||||
"item_code": item.item_code,
|
||||
"posting_date": parent_doc.transaction_date,
|
||||
|
||||
@@ -1,329 +0,0 @@
|
||||
{
|
||||
"app": "erpnext",
|
||||
"charts": [],
|
||||
"content": "[]",
|
||||
"creation": "2026-06-14 12:44:31.994274",
|
||||
"custom_blocks": [],
|
||||
"docstatus": 0,
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "database",
|
||||
"idx": 0,
|
||||
"indicator_color": "green",
|
||||
"is_hidden": 0,
|
||||
"label": "Accounts Setup",
|
||||
"link_type": "DocType",
|
||||
"links": [],
|
||||
"modified": "2026-06-14 13:43:50.138704",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"module_onboarding": "Accounting Onboarding",
|
||||
"name": "Accounts Setup",
|
||||
"number_cards": [],
|
||||
"owner": "Administrator",
|
||||
"public": 1,
|
||||
"quick_lists": [],
|
||||
"roles": [],
|
||||
"sequence_id": 55.0,
|
||||
"shortcuts": [],
|
||||
"sidebar_items": [
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "database",
|
||||
"indent": 1,
|
||||
"keep_closed": 0,
|
||||
"label": "Setup",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Chart of Accounts",
|
||||
"link_to": "Account",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Chart of Cost Centers",
|
||||
"link_to": "Cost Center",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Account Category",
|
||||
"link_to": "Account Category",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Accounting Dimension",
|
||||
"link_to": "Accounting Dimension",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Currency",
|
||||
"link_to": "Currency",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Currency Exchange",
|
||||
"link_to": "Currency Exchange",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Finance Book",
|
||||
"link_to": "Finance Book",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Mode of Payment",
|
||||
"link_to": "Mode of Payment",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Payment Term",
|
||||
"link_to": "Payment Term",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Journal Entry Template",
|
||||
"link_to": "Journal Entry Template",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Terms and Conditions",
|
||||
"link_to": "Terms and Conditions",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Company",
|
||||
"link_to": "Company",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Fiscal Year",
|
||||
"link_to": "Fiscal Year",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Sales Taxes",
|
||||
"link_to": "Sales Taxes and Charges Template",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "lock-keyhole-open",
|
||||
"indent": 1,
|
||||
"keep_closed": 0,
|
||||
"label": "Opening & Closing",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "COA Importer",
|
||||
"link_to": "Chart of Accounts Importer",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Opening Invoice Tool",
|
||||
"link_to": "Opening Invoice Creation Tool",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Accounting Period",
|
||||
"link_to": "Accounting Period",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "FX Revaluation",
|
||||
"link_to": "Exchange Rate Revaluation",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Period Closing Voucher",
|
||||
"link_to": "Period Closing Voucher",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "settings",
|
||||
"indent": 1,
|
||||
"keep_closed": 0,
|
||||
"label": "Settings",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Accounts Settings",
|
||||
"link_to": "Accounts Settings",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Currency Exchange Settings",
|
||||
"link_to": "Currency Exchange Settings",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"standard": 1,
|
||||
"title": "Accounts Setup",
|
||||
"type": "Workspace"
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
{
|
||||
"app": "erpnext",
|
||||
"charts": [],
|
||||
"content": "[]",
|
||||
"creation": "2026-06-11 11:51:22.767176",
|
||||
"custom_blocks": [],
|
||||
"docstatus": 0,
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "circle-dollar-sign",
|
||||
"idx": 0,
|
||||
"indicator_color": "green",
|
||||
"is_hidden": 0,
|
||||
"label": "Banking",
|
||||
"link_type": "DocType",
|
||||
"links": [],
|
||||
"modified": "2026-06-14 13:43:50.924019",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Banking",
|
||||
"number_cards": [],
|
||||
"owner": "Administrator",
|
||||
"public": 1,
|
||||
"quick_lists": [],
|
||||
"roles": [],
|
||||
"sequence_id": 49.0,
|
||||
"shortcuts": [],
|
||||
"sidebar_items": [
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "book-open-check",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Bank Clearance",
|
||||
"link_to": "Bank Clearance",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "tool",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Bank Reconciliation",
|
||||
"link_to": "Bank Reconciliation Tool",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "clipboard-check",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Reconciliation Statement",
|
||||
"link_to": "Bank Reconciliation Statement",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "split",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Unreconcile Payment",
|
||||
"link_to": "Unreconcile Payment",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "link",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Process Payment Reconciliation",
|
||||
"link_to": "Process Payment Reconciliation",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "database",
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Setup",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Bank",
|
||||
"link_to": "Bank",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Bank Account",
|
||||
"link_to": "Bank Account",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Bank Account Type",
|
||||
"link_to": "Bank Account Type",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Bank Account Subtype",
|
||||
"link_to": "Bank Account Subtype",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Bank Guarantee",
|
||||
"link_to": "Bank Guarantee",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Plaid Settings",
|
||||
"link_to": "Plaid Settings",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "scroll-text",
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Dunning",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Dunning",
|
||||
"link_to": "Dunning",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Dunning Type",
|
||||
"link_to": "Dunning Type",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"standard": 1,
|
||||
"title": "Banking",
|
||||
"type": "Workspace"
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
{
|
||||
"app": "erpnext",
|
||||
"charts": [],
|
||||
"content": "[]",
|
||||
"creation": "2026-06-14 14:38:20.315394",
|
||||
"custom_blocks": [],
|
||||
"docstatus": 0,
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "accounting",
|
||||
"idx": 0,
|
||||
"indicator_color": "green",
|
||||
"is_hidden": 0,
|
||||
"label": "Budgeting",
|
||||
"link_type": "DocType",
|
||||
"links": [],
|
||||
"modified": "2026-07-02 04:24:48.116724",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Budgeting",
|
||||
"number_cards": [],
|
||||
"owner": "Administrator",
|
||||
"public": 1,
|
||||
"quick_lists": [],
|
||||
"roles": [],
|
||||
"sequence_id": 57.0,
|
||||
"shortcuts": [],
|
||||
"sidebar_items": [
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"default_workspace": 0,
|
||||
"icon": "briefcase-business",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Budget",
|
||||
"link_to": "Budget",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"default_workspace": 0,
|
||||
"icon": "badge-cent",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Cost Center",
|
||||
"link_to": "Cost Center",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"default_workspace": 0,
|
||||
"icon": "accounting",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Accounting Dimension",
|
||||
"link_to": "Accounting Dimension",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"default_workspace": 0,
|
||||
"icon": "notepad-text",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Cost Center Allocation",
|
||||
"link_to": "Cost Center Allocation",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"default_workspace": 0,
|
||||
"icon": "sheet",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Budget Variance",
|
||||
"link_to": "Budget Variance Report",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"standard": 1,
|
||||
"title": "Budgeting",
|
||||
"type": "Workspace"
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "sheet",
|
||||
"icon": "table",
|
||||
"idx": 1,
|
||||
"indicator_color": "",
|
||||
"is_hidden": 0,
|
||||
@@ -266,10 +266,9 @@
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-06-14 13:44:08.095321",
|
||||
"modified": "2026-05-18 09:49:45.138296",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"module_onboarding": "Accounting Onboarding",
|
||||
"name": "Financial Reports",
|
||||
"number_cards": [],
|
||||
"owner": "Administrator",
|
||||
@@ -280,417 +279,6 @@
|
||||
"roles": [],
|
||||
"sequence_id": 5.0,
|
||||
"shortcuts": [],
|
||||
"sidebar_items": [
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "accounting",
|
||||
"indent": 1,
|
||||
"keep_closed": 0,
|
||||
"label": "Financial Reports",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Balance Sheet",
|
||||
"link_to": "Balance Sheet",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Profit and Loss",
|
||||
"link_to": "Profit and Loss Statement",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Cash Flow",
|
||||
"link_to": "Cash Flow",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Trial Balance",
|
||||
"link_to": "Trial Balance",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Consolidated Report",
|
||||
"link_to": "Consolidated Financial Statement",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Custom Financial Statement",
|
||||
"link_to": "Custom Financial Statement",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Financial Report Template",
|
||||
"link_to": "Financial Report Template",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "book-text",
|
||||
"indent": 1,
|
||||
"keep_closed": 0,
|
||||
"label": "Ledgers",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "General Ledger",
|
||||
"link_to": "General Ledger",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Customer Ledger",
|
||||
"link_to": "Customer Ledger Summary",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Supplier Ledger",
|
||||
"link_to": "Supplier Ledger Summary",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Registers",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Accounts Receivable",
|
||||
"link_to": "Accounts Receivable",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Accounts Payable",
|
||||
"link_to": "Accounts Payable",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "AR Summary",
|
||||
"link_to": "Accounts Receivable Summary",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "AP Summary",
|
||||
"link_to": "Accounts Payable Summary",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Sales Register",
|
||||
"link_to": "Sales Register",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Purchase Register",
|
||||
"link_to": "Purchase Register",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Item-wise sales Register",
|
||||
"link_to": "Item-wise Sales Register",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Item-wise Purchase Register",
|
||||
"link_to": "Item-wise Purchase Register",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "dollar-sign",
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Profitability",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Gross Profit",
|
||||
"link_to": "Gross Profit",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Profitability Analysis",
|
||||
"link_to": "Profitability Analysis",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Sales Invoice Trends",
|
||||
"link_to": "Sales Invoice Trends",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Purchase Invoice Trends",
|
||||
"link_to": "Purchase Invoice Trends",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "scroll-text",
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Other Reports",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Trial Balance for Party",
|
||||
"link_to": "Trial Balance for Party",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Payment Period Based On Invoice Date",
|
||||
"link_to": "Payment Period Based On Invoice Date",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Sales Partners Commission",
|
||||
"link_to": "Sales Partners Commission",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Customer Credit Balance",
|
||||
"link_to": "Customer Credit Balance",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Sales Payment Summary",
|
||||
"link_to": "Sales Payment Summary",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Address And Contacts",
|
||||
"link_to": "Address And Contacts",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "UAE VAT 201",
|
||||
"link_to": "UAE VAT 201",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"standard": 1,
|
||||
"title": "Financial Reports",
|
||||
"type": "Workspace"
|
||||
}
|
||||
|
||||
@@ -587,10 +587,9 @@
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-06-14 13:44:08.471142",
|
||||
"modified": "2026-01-23 11:05:47.246213",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"module_onboarding": "Accounting Onboarding",
|
||||
"name": "Invoicing",
|
||||
"number_cards": [
|
||||
{
|
||||
@@ -618,354 +617,6 @@
|
||||
"roles": [],
|
||||
"sequence_id": 2.0,
|
||||
"shortcuts": [],
|
||||
"sidebar_items": [
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "home",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Home",
|
||||
"link_to": "Invoicing",
|
||||
"link_type": "Workspace",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "chart",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Dashboard",
|
||||
"link_to": "Accounts",
|
||||
"link_type": "Dashboard",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "list-tree",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Chart of Accounts",
|
||||
"link_to": "Account",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "arrow-left-to-line",
|
||||
"indent": 1,
|
||||
"keep_closed": 0,
|
||||
"label": "Receivables",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Customer",
|
||||
"link_to": "Customer",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Sales Invoice",
|
||||
"link_to": "Sales Invoice",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Credit Note",
|
||||
"link_to": "Sales Invoice",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"route_options": "{\"is_return\": 1}",
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Accounts Receivable",
|
||||
"link_to": "Accounts Receivable",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "arrow-right-from-line",
|
||||
"indent": 1,
|
||||
"keep_closed": 0,
|
||||
"label": "Payables",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Supplier",
|
||||
"link_to": "Supplier",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Purchase Invoice",
|
||||
"link_to": "Purchase Invoice",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Debit Note",
|
||||
"link_to": "Purchase Invoice",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"route_options": "{\"is_return\": 1}",
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Accounts Payable",
|
||||
"link_to": "Accounts Payable",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "money-coins-1",
|
||||
"indent": 1,
|
||||
"keep_closed": 0,
|
||||
"label": "Payments",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Payment Entry",
|
||||
"link_to": "Payment Entry",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Journal Entry",
|
||||
"link_to": "Journal Entry",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Payment Request",
|
||||
"link_to": "Payment Request",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Payment Order",
|
||||
"link_to": "Payment Order",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Payment Reconciliation",
|
||||
"link_to": "Payment Reconciliation",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Unreconcile Payment",
|
||||
"link_to": "Unreconcile Payment",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Process Payment Reconciliation",
|
||||
"link_to": "Process Payment Reconciliation",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Repost Accounting Ledger",
|
||||
"link_to": "Repost Accounting Ledger",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Repost Payment Ledger",
|
||||
"link_to": "Repost Payment Ledger",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "sheet",
|
||||
"indent": 1,
|
||||
"keep_closed": 0,
|
||||
"label": "Reports",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "General Ledger",
|
||||
"link_to": "General Ledger",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Trial Balance",
|
||||
"link_to": "Trial Balance",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Financial Reports",
|
||||
"link_to": "Financial Reports",
|
||||
"link_type": "Workspace",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "settings",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Settings",
|
||||
"link_to": "Accounts Settings",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"standard": 1,
|
||||
"title": "Invoicing",
|
||||
"type": "Workspace"
|
||||
}
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
{
|
||||
"app": "erpnext",
|
||||
"charts": [],
|
||||
"content": "[]",
|
||||
"creation": "2026-06-11 11:51:21.886461",
|
||||
"custom_blocks": [],
|
||||
"docstatus": 0,
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "receipt-text",
|
||||
"idx": 0,
|
||||
"indicator_color": "green",
|
||||
"is_hidden": 0,
|
||||
"label": "Payments",
|
||||
"link_type": "DocType",
|
||||
"links": [],
|
||||
"modified": "2026-06-14 13:43:50.184761",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"module_onboarding": "Accounting Onboarding",
|
||||
"name": "Payments",
|
||||
"number_cards": [],
|
||||
"owner": "Administrator",
|
||||
"public": 1,
|
||||
"quick_lists": [],
|
||||
"roles": [],
|
||||
"sequence_id": 47.0,
|
||||
"shortcuts": [],
|
||||
"sidebar_items": [
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "chart",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Dashboard",
|
||||
"link_to": "Payments",
|
||||
"link_type": "Dashboard",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "money-coins-1",
|
||||
"indent": 1,
|
||||
"keep_closed": 0,
|
||||
"label": "Payments",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Payment Entry",
|
||||
"link_to": "Payment Entry",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Journal Entry",
|
||||
"link_to": "Journal Entry",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Payment Request",
|
||||
"link_to": "Payment Request",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Payment Order",
|
||||
"link_to": "Payment Order",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Payment Reconciliation",
|
||||
"link_to": "Payment Reconciliation",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Unreconcile Payment",
|
||||
"link_to": "Unreconcile Payment",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Process Payment Reconciliation",
|
||||
"link_to": "Process Payment Reconciliation",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Repost Accounting Ledger",
|
||||
"link_to": "Repost Accounting Ledger",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Repost Payment Ledger",
|
||||
"link_to": "Repost Payment Ledger",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "sheet",
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Reports",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Accounts Receivable",
|
||||
"link_to": "Accounts Receivable",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Accounts Payable",
|
||||
"link_to": "Accounts Payable",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "General Ledger",
|
||||
"link_to": "General Ledger",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Trial Balance",
|
||||
"link_to": "Trial Balance",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Financial Reports",
|
||||
"link_to": "Financial Reports",
|
||||
"link_type": "Workspace",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"standard": 1,
|
||||
"title": "Payments",
|
||||
"type": "Workspace"
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
{
|
||||
"app": "erpnext",
|
||||
"charts": [],
|
||||
"content": "[]",
|
||||
"creation": "2026-06-11 11:51:22.831729",
|
||||
"custom_blocks": [],
|
||||
"docstatus": 0,
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "money-coins-1",
|
||||
"idx": 0,
|
||||
"indicator_color": "green",
|
||||
"is_hidden": 0,
|
||||
"label": "Share Management",
|
||||
"link_type": "DocType",
|
||||
"links": [],
|
||||
"modified": "2026-06-14 13:43:51.040978",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Share Management",
|
||||
"number_cards": [],
|
||||
"owner": "Administrator",
|
||||
"public": 1,
|
||||
"quick_lists": [],
|
||||
"roles": [],
|
||||
"sequence_id": 50.0,
|
||||
"shortcuts": [],
|
||||
"sidebar_items": [
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "customer",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Shareholder",
|
||||
"link_to": "Shareholder",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "move-horizontal",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Share Transfer",
|
||||
"link_to": "Share Transfer",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "list",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Share Ledger",
|
||||
"link_to": "Share Ledger",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "notepad-text",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Share Balance",
|
||||
"link_to": "Share Balance",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"standard": 1,
|
||||
"title": "Share Management",
|
||||
"type": "Workspace"
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
{
|
||||
"app": "erpnext",
|
||||
"charts": [],
|
||||
"content": "[]",
|
||||
"creation": "2026-06-14 14:08:36.817393",
|
||||
"custom_blocks": [],
|
||||
"docstatus": 0,
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "accounting",
|
||||
"idx": 0,
|
||||
"indicator_color": "green",
|
||||
"is_hidden": 0,
|
||||
"label": "Subscriptions",
|
||||
"link_type": "DocType",
|
||||
"links": [],
|
||||
"modified": "2026-06-14 14:08:36.999272",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Subscriptions",
|
||||
"number_cards": [],
|
||||
"owner": "Administrator",
|
||||
"public": 1,
|
||||
"quick_lists": [],
|
||||
"roles": [],
|
||||
"sequence_id": 56.0,
|
||||
"shortcuts": [],
|
||||
"sidebar_items": [
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "circle-dollar-sign",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Subscription",
|
||||
"link_to": "Subscription",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "receipt-text",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Subscription Plan",
|
||||
"link_to": "Subscription Plan",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "settings",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Subscription Settings",
|
||||
"link_to": "Subscription Settings",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "database",
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Setup",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Customer",
|
||||
"link_to": "Customer",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Supplier",
|
||||
"link_to": "Supplier",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Item",
|
||||
"link_to": "Item",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"standard": 1,
|
||||
"title": "Subscriptions",
|
||||
"type": "Workspace"
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
{
|
||||
"app": "erpnext",
|
||||
"charts": [],
|
||||
"content": "[]",
|
||||
"creation": "2026-06-11 11:51:22.649582",
|
||||
"custom_blocks": [],
|
||||
"docstatus": 0,
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "money-coins-1",
|
||||
"idx": 0,
|
||||
"indicator_color": "green",
|
||||
"is_hidden": 0,
|
||||
"label": "Taxes",
|
||||
"link_type": "DocType",
|
||||
"links": [],
|
||||
"modified": "2026-06-14 13:43:50.894825",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"module_onboarding": "Accounting Onboarding",
|
||||
"name": "Taxes",
|
||||
"number_cards": [],
|
||||
"owner": "Administrator",
|
||||
"public": 1,
|
||||
"quick_lists": [],
|
||||
"roles": [],
|
||||
"sequence_id": 48.0,
|
||||
"shortcuts": [],
|
||||
"sidebar_items": [
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "panel-bottom-close",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Sales Tax Template",
|
||||
"link_to": "Sales Taxes and Charges Template",
|
||||
"link_type": "DocType",
|
||||
"navigate_to_tab": "",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "panel-top-close",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Purchase Tax Template",
|
||||
"link_to": "Purchase Taxes and Charges Template",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "stock",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Item Tax Template",
|
||||
"link_to": "Item Tax Template",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "database",
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Setup",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "triangle",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Tax Category",
|
||||
"link_to": "Tax Category",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "book-open-text",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Tax Rule",
|
||||
"link_to": "Tax Rule",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "book-text",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Tax Withholding Category",
|
||||
"link_to": "Tax Withholding Category",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Tax Withholding Group",
|
||||
"link_to": "Tax Withholding Group",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "notebook-text",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Deduction Certificate",
|
||||
"link_to": "Lower Deduction Certificate",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "sheet",
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Reports",
|
||||
"link_to": "",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "TDS Computation Summary",
|
||||
"link_to": "TDS Computation Summary",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Tax Withholding Details",
|
||||
"link_to": "Tax Withholding Details",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"standard": 1,
|
||||
"title": "Taxes",
|
||||
"type": "Workspace"
|
||||
}
|
||||
@@ -500,7 +500,7 @@ def get_target_item_details(item_code: str | None = None, company: str | None =
|
||||
item_group_defaults = get_item_group_defaults(item.name, company)
|
||||
brand_defaults = get_brand_defaults(item.name, company)
|
||||
out.cost_center = get_default_cost_center(
|
||||
frappe._dict({"item_code": item.name, "company": company}),
|
||||
ItemDetailsCtx({"item_code": item.name, "company": company}),
|
||||
item_defaults,
|
||||
item_group_defaults,
|
||||
brand_defaults,
|
||||
|
||||
@@ -199,10 +199,9 @@
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-06-14 13:44:08.417956",
|
||||
"modified": "2025-12-31 16:22:38.132729",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Assets",
|
||||
"module_onboarding": "Asset Onboarding",
|
||||
"name": "Assets",
|
||||
"number_cards": [],
|
||||
"owner": "Administrator",
|
||||
@@ -213,294 +212,6 @@
|
||||
"roles": [],
|
||||
"sequence_id": 7.0,
|
||||
"shortcuts": [],
|
||||
"sidebar_items": [
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "home",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Home",
|
||||
"link_to": "Assets",
|
||||
"link_type": "Workspace",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "chart",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Dashboard",
|
||||
"link_to": "Asset",
|
||||
"link_type": "Dashboard",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "laptop",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Asset",
|
||||
"link_to": "Asset",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "trending-down",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Depreciation Schedule",
|
||||
"link_to": "Asset Depreciation Schedule",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "sprout",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Asset Capitalization",
|
||||
"link_to": "Asset Capitalization",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "move-horizontal",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Asset Movement",
|
||||
"link_to": "Asset Movement",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "getting-started",
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Maintenance",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Asset Maintenance Team",
|
||||
"link_to": "Asset Maintenance Team",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Asset Maintenance",
|
||||
"link_to": "Asset Maintenance",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Asset Maintenance Log",
|
||||
"link_to": "Asset Maintenance Log",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Asset Value Adjustment",
|
||||
"link_to": "Asset Value Adjustment",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Asset Repair",
|
||||
"link_to": "Asset Repair",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "sheet",
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Reports",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Fixed Asset Register",
|
||||
"link_to": "Fixed Asset Register",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Asset Depreciation Ledger",
|
||||
"link_to": "Asset Depreciation Ledger",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Asset Depreciations and Balances",
|
||||
"link_to": "Asset Depreciations and Balances",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Asset Maintenance",
|
||||
"link_to": "Asset Maintenance",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Asset Activity",
|
||||
"link_to": "Asset Activity",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "database",
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Setup",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Item",
|
||||
"link_to": "Item",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Asset Category",
|
||||
"link_to": "Asset Category",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Location",
|
||||
"link_to": "Location",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "settings",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Settings",
|
||||
"link_to": "Accounts Settings",
|
||||
"link_type": "DocType",
|
||||
"navigate_to_tab": "assets_tab",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link",
|
||||
"url": ""
|
||||
}
|
||||
],
|
||||
"standard": 1,
|
||||
"title": "Assets",
|
||||
"type": "Workspace"
|
||||
}
|
||||
|
||||
@@ -1531,11 +1531,11 @@ class TestPurchaseOrder(ERPNextTestSuite):
|
||||
(via the standard item lookup the form uses) without going through
|
||||
the Sales Order → Purchase Order mapping pipeline.
|
||||
"""
|
||||
from erpnext.stock.get_item_details import get_item_details
|
||||
from erpnext.stock.get_item_details import ItemDetailsCtx, get_item_details
|
||||
|
||||
item = make_item("_Test Drop Ship From Master", {"is_stock_item": 1, "delivered_by_supplier": 1})
|
||||
|
||||
ctx = frappe._dict(
|
||||
ctx = ItemDetailsCtx(
|
||||
{
|
||||
"item_code": item.item_code,
|
||||
"doctype": "Purchase Order",
|
||||
|
||||
@@ -55,16 +55,9 @@ class SupplierScorecard(Document):
|
||||
self.update_standing()
|
||||
|
||||
def on_update(self):
|
||||
# Guard against recursion: the save() below re-enters on_update().
|
||||
if self.flags.in_rescore:
|
||||
return
|
||||
if make_all_scorecards(self.name) > 0:
|
||||
# New periods were created; re-save to refresh score and standings.
|
||||
self.flags.in_rescore = True
|
||||
try:
|
||||
self.save()
|
||||
finally:
|
||||
self.flags.in_rescore = False
|
||||
score = make_all_scorecards(self.name)
|
||||
if score > 0:
|
||||
self.save()
|
||||
|
||||
def validate_standings(self):
|
||||
# Standings must form a continuous chain of bands covering 0 to 100 with no gaps or overlaps
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.buying.doctype.purchase_order.mapper import make_purchase_invoice
|
||||
from erpnext.buying.doctype.purchase_order.test_purchase_order import (
|
||||
create_pr_against_po,
|
||||
create_purchase_order,
|
||||
)
|
||||
from erpnext.buying.report.item_wise_purchase_history.item_wise_purchase_history import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestItemWisePurchaseHistory(ERPNextTestSuite):
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"from_date": "2026-01-01",
|
||||
"to_date": "2026-12-31",
|
||||
**extra,
|
||||
}
|
||||
)
|
||||
return execute(filters)
|
||||
|
||||
def po_row(self, po_name, **extra):
|
||||
data = self.run_report(**extra)[1]
|
||||
return next(row for row in data if row["purchase_order"] == po_name)
|
||||
|
||||
def test_purchase_order_line_shown_with_values(self):
|
||||
po = create_purchase_order(qty=10, rate=500, transaction_date="2026-06-01")
|
||||
|
||||
row = self.po_row(po.name)
|
||||
self.assertEqual(row["item_code"], "_Test Item")
|
||||
self.assertEqual(row["quantity"], 10)
|
||||
self.assertEqual(row["rate"], 500)
|
||||
self.assertEqual(row["amount"], 5000)
|
||||
self.assertEqual(row["supplier"], "_Test Supplier")
|
||||
|
||||
def test_draft_purchase_order_excluded(self):
|
||||
po = create_purchase_order(transaction_date="2026-06-01", do_not_submit=True)
|
||||
|
||||
names = {row["purchase_order"] for row in self.run_report()[1]}
|
||||
self.assertNotIn(po.name, names)
|
||||
|
||||
def test_date_range_filters_on_transaction_date(self):
|
||||
po = create_purchase_order(transaction_date="2026-06-01")
|
||||
|
||||
in_range = {
|
||||
row["purchase_order"] for row in self.run_report(from_date="2026-05-01", to_date="2026-07-01")[1]
|
||||
}
|
||||
self.assertIn(po.name, in_range)
|
||||
|
||||
out_of_range = {
|
||||
row["purchase_order"] for row in self.run_report(from_date="2026-01-01", to_date="2026-03-01")[1]
|
||||
}
|
||||
self.assertNotIn(po.name, out_of_range)
|
||||
|
||||
def test_item_code_filter(self):
|
||||
po = create_purchase_order(
|
||||
transaction_date="2026-06-01",
|
||||
rm_items=[
|
||||
{"item_code": "_Test Item", "qty": 5, "rate": 500, "warehouse": "_Test Warehouse - _TC"},
|
||||
{"item_code": "_Test Item 2", "qty": 3, "rate": 200, "warehouse": "_Test Warehouse - _TC"},
|
||||
],
|
||||
)
|
||||
|
||||
rows = self.run_report(item_code="_Test Item 2")[1]
|
||||
self.assertEqual({row["item_code"] for row in rows}, {"_Test Item 2"})
|
||||
# the filtered-out line of the same order must not leak in
|
||||
self.assertTrue(all(row["purchase_order"] == po.name for row in rows))
|
||||
|
||||
def test_item_group_filter(self):
|
||||
# _Test Item is in _Test Item Group; _Test FG Item is in _Test Item Group Desktops
|
||||
po_test_group = create_purchase_order(item_code="_Test Item", transaction_date="2026-06-01")
|
||||
po_other_group = create_purchase_order(item_code="_Test FG Item", transaction_date="2026-06-01")
|
||||
|
||||
names = {row["purchase_order"] for row in self.run_report(item_group="_Test Item Group")[1]}
|
||||
self.assertIn(po_test_group.name, names)
|
||||
self.assertNotIn(po_other_group.name, names)
|
||||
|
||||
def test_supplier_filter(self):
|
||||
create_purchase_order(supplier="_Test Supplier", transaction_date="2026-06-01")
|
||||
create_purchase_order(supplier="_Test Supplier 1", transaction_date="2026-06-01")
|
||||
|
||||
suppliers = {row["supplier"] for row in self.run_report(supplier="_Test Supplier")[1]}
|
||||
self.assertEqual(suppliers, {"_Test Supplier"})
|
||||
|
||||
def test_received_quantity_reflects_receipt(self):
|
||||
po = create_purchase_order(qty=10, rate=500, transaction_date="2026-06-01")
|
||||
create_pr_against_po(po.name, received_qty=4)
|
||||
|
||||
self.assertEqual(self.po_row(po.name)["received_qty"], 4)
|
||||
|
||||
def test_billed_amount_reflects_invoice(self):
|
||||
po = create_purchase_order(qty=10, rate=500, transaction_date="2026-06-01")
|
||||
pi = make_purchase_invoice(po.name)
|
||||
pi.insert()
|
||||
pi.submit()
|
||||
|
||||
self.assertEqual(self.po_row(po.name)["billed_amt"], 5000)
|
||||
|
||||
def test_amounts_reported_in_company_currency(self):
|
||||
# a USD order must report rate/amount converted to the company's currency (base_* fields)
|
||||
po = create_purchase_order(
|
||||
do_not_save=True,
|
||||
currency="USD",
|
||||
qty=10,
|
||||
rate=100,
|
||||
transaction_date="2026-06-01",
|
||||
)
|
||||
po.conversion_rate = 80
|
||||
po.insert()
|
||||
po.submit()
|
||||
|
||||
row = self.po_row(po.name)
|
||||
self.assertEqual(row["rate"], 8000) # 100 USD * 80
|
||||
self.assertEqual(row["amount"], 80000) # 10 * 100 USD * 80
|
||||
|
||||
def test_chart_aggregates_amount_per_item(self):
|
||||
create_purchase_order(item_code="_Test Item", qty=2, rate=500, transaction_date="2026-06-01")
|
||||
create_purchase_order(item_code="_Test Item", qty=3, rate=500, transaction_date="2026-06-01")
|
||||
|
||||
chart = self.run_report(item_code="_Test Item")[3]
|
||||
labels = chart["data"]["labels"]
|
||||
values = chart["data"]["datasets"][0]["values"]
|
||||
self.assertIn("_Test Item", labels)
|
||||
# 2*500 + 3*500 aggregated for the item
|
||||
self.assertEqual(values[labels.index("_Test Item")], 2500)
|
||||
@@ -341,6 +341,17 @@
|
||||
"onboard": 1,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"dependencies": "",
|
||||
"hidden": 0,
|
||||
"is_query_report": 1,
|
||||
"label": "Item Wise Consumption",
|
||||
"link_count": 0,
|
||||
"link_to": "Item Wise Consumption",
|
||||
"link_type": "Report",
|
||||
"onboard": 1,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"dependencies": "",
|
||||
"hidden": 0,
|
||||
@@ -501,10 +512,9 @@
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-06-14 13:43:50.509039",
|
||||
"modified": "2026-01-02 14:55:59.078773",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"module_onboarding": "Buying Onboarding",
|
||||
"name": "Buying",
|
||||
"number_cards": [
|
||||
{
|
||||
@@ -528,403 +538,6 @@
|
||||
"roles": [],
|
||||
"sequence_id": 5.0,
|
||||
"shortcuts": [],
|
||||
"sidebar_items": [
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "home",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Home",
|
||||
"link_to": "Buying",
|
||||
"link_type": "Workspace",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "chart",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Dashboard",
|
||||
"link_to": "Buying",
|
||||
"link_type": "Dashboard",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "notepad-text",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Material Request",
|
||||
"link_to": "Material Request",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "git-pull-request-arrow",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Request for Quotation",
|
||||
"link_to": "Request for Quotation",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "book-open-text",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Supplier Quotation",
|
||||
"link_to": "Supplier Quotation",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "receipt-text",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Purchase Order",
|
||||
"link_to": "Purchase Order",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "liabilities",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Purchase Invoice",
|
||||
"link_to": "Purchase Invoice",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "database",
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Setup",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Supplier",
|
||||
"link_to": "Supplier",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Supplier Group",
|
||||
"link_to": "Supplier Group",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Item",
|
||||
"link_to": "Item",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Price List",
|
||||
"link_to": "Price List",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Address",
|
||||
"link_to": "Address",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Contacts",
|
||||
"link_to": "Contact",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Supplier Scorecard",
|
||||
"link_to": "Supplier Scorecard",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Supplier Scorecard Criteria",
|
||||
"link_to": "Supplier Scorecard Criteria",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Supplier Scorecard Variable",
|
||||
"link_to": "Supplier Scorecard Variable",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Supplier Scorecard Standing",
|
||||
"link_to": "Supplier Scorecard Standing",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "sheet",
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Reports",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Purchase Analytics",
|
||||
"link_to": "Purchase Analytics",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Purchase Order Analysis",
|
||||
"link_to": "Purchase Order Analysis",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Requested Items to Order and Receive",
|
||||
"link_to": "Requested Items to Order and Receive",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Items To Be Requested",
|
||||
"link_to": "Items To Be Requested",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Item-wise Purchase History",
|
||||
"link_to": "Item-wise Purchase History",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Purchase Receipt Trends ",
|
||||
"link_to": "Purchase Receipt Trends",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Purchase Invoice Trends",
|
||||
"link_to": "Purchase Invoice Trends",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Purchase Order Trends",
|
||||
"link_to": "Purchase Order Trends",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Procurement Tracker",
|
||||
"link_to": "Procurement Tracker",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Item Wise Consumption",
|
||||
"link_to": "Item Wise Consumption",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Supplier Quotation Comparison",
|
||||
"link_to": "Supplier Quotation Comparison",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Supplier Addresses And Contacts",
|
||||
"link_to": "Address And Contacts",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "settings",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Settings",
|
||||
"link_to": "Buying Settings",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"standard": 1,
|
||||
"title": "Buying",
|
||||
"type": "Workspace"
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ from erpnext.controllers.sales_and_purchase_return import validate_return
|
||||
from erpnext.setup.utils import get_exchange_rate
|
||||
from erpnext.stock.doctype.item.item import get_uom_conv_factor
|
||||
from erpnext.stock.get_item_details import (
|
||||
ItemDetailsCtx,
|
||||
get_item_details,
|
||||
)
|
||||
from erpnext.utilities.regional import temporary_flag
|
||||
@@ -781,7 +782,7 @@ class AccountsController(TransactionBase):
|
||||
|
||||
for item in self.get("items"):
|
||||
if item.get("item_code"):
|
||||
ctx: frappe._dict = frappe._dict(parent_dict.copy())
|
||||
ctx: ItemDetailsCtx = ItemDetailsCtx(parent_dict.copy())
|
||||
ctx.update(item.as_dict())
|
||||
|
||||
ctx.update(
|
||||
|
||||
@@ -25,7 +25,7 @@ from pypika import Order
|
||||
|
||||
import erpnext
|
||||
from erpnext.accounts.utils import build_qb_match_conditions
|
||||
from erpnext.stock.get_item_details import _get_item_tax_template
|
||||
from erpnext.stock.get_item_details import ItemDetailsCtx, _get_item_tax_template
|
||||
from erpnext.stock.utils import get_combine_datetime
|
||||
from erpnext.utilities.query import get_filter_conditions_qb
|
||||
|
||||
@@ -1056,7 +1056,7 @@ def get_tax_template(doctype: str, txt: str, searchfield: str, start: int, page_
|
||||
valid_from = filters.get("valid_from")
|
||||
valid_from = valid_from[1] if isinstance(valid_from, list) else valid_from
|
||||
|
||||
ctx = frappe._dict(
|
||||
ctx = ItemDetailsCtx(
|
||||
{
|
||||
"item_code": filters.get("item_code"),
|
||||
"posting_date": valid_from,
|
||||
|
||||
@@ -167,8 +167,7 @@ status_map = {
|
||||
"Pick List": [
|
||||
["Draft", None],
|
||||
["Open", "eval:self.docstatus == 1"],
|
||||
["Completed", "is_fully_transferred"],
|
||||
["Partially Transferred", "is_partially_transferred"],
|
||||
["Completed", "stock_entry_exists"],
|
||||
[
|
||||
"Partly Delivered",
|
||||
"eval:self.purpose == 'Delivery' and self.delivery_status == 'Partly Delivered'",
|
||||
|
||||
@@ -21,6 +21,7 @@ from erpnext.controllers.accounts_controller import (
|
||||
from erpnext.deprecation_dumpster import deprecated
|
||||
from erpnext.stock.get_item_details import (
|
||||
NOT_APPLICABLE_TAX,
|
||||
ItemDetailsCtx,
|
||||
_get_item_tax_template,
|
||||
get_item_tax_map,
|
||||
)
|
||||
@@ -98,7 +99,7 @@ class calculate_taxes_and_totals:
|
||||
for item in self.doc.items:
|
||||
if item.item_code and item.get("item_tax_template"):
|
||||
item_doc = frappe.get_cached_doc("Item", item.item_code)
|
||||
ctx = frappe._dict(
|
||||
ctx = ItemDetailsCtx(
|
||||
{
|
||||
"net_rate": item.net_rate or item.rate,
|
||||
"base_net_rate": item.base_net_rate or item.base_rate,
|
||||
|
||||
@@ -154,26 +154,15 @@ def create_customer(customer_data: dict | None = None):
|
||||
customer.set(field, customer_data.get(field))
|
||||
customer.insert(ignore_permissions=True)
|
||||
customer_name = customer.name
|
||||
except Exception:
|
||||
frappe.db.rollback()
|
||||
frappe.log_error(frappe.get_traceback(), "Error while creating customer against Frappe CRM Deal")
|
||||
return
|
||||
|
||||
# Link contacts/address under a savepoint so a failure here does NOT discard the Customer just
|
||||
# created (a full rollback would; MariaDB kept it pre-migration). Linking is best-effort.
|
||||
frappe.db.savepoint("crm_customer_links")
|
||||
try:
|
||||
contacts = frappe.parse_json(customer_data.get("contacts"))
|
||||
create_contacts(contacts, customer_name, "Customer", customer_name)
|
||||
create_address("Customer", customer_name, customer_data.get("address"))
|
||||
return customer_name
|
||||
except Exception:
|
||||
frappe.db.rollback(save_point="crm_customer_links")
|
||||
frappe.log_error(frappe.get_traceback(), "Error while linking contacts/address to new Customer")
|
||||
# keep the Customer, but preserve the pre-existing contract of returning None on a linking failure
|
||||
# so CRM callers still see the failure signal
|
||||
return
|
||||
|
||||
return customer_name
|
||||
frappe.db.rollback()
|
||||
frappe.log_error(frappe.get_traceback(), "Error while creating customer against Frappe CRM Deal")
|
||||
pass
|
||||
|
||||
|
||||
def validate_frappe_crm_sync():
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_days, today
|
||||
|
||||
from erpnext.crm.report.lead_owner_efficiency.lead_owner_efficiency import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestLeadOwnerEfficiency(ERPNextTestSuite):
|
||||
"""Groups leads by their owner and counts the opportunity/quotation/order funnel
|
||||
derived from those leads."""
|
||||
|
||||
def setUp(self):
|
||||
# a unique owner keeps the per-owner counts isolated from other tests' leads
|
||||
self.owner = self.make_user()
|
||||
|
||||
def make_user(self):
|
||||
email = f"lead_owner_{frappe.generate_hash(length=8)}@example.com"
|
||||
frappe.get_doc(
|
||||
{"doctype": "User", "email": email, "first_name": "Lead Owner", "send_welcome_email": 0}
|
||||
).insert()
|
||||
return email
|
||||
|
||||
def make_lead(self):
|
||||
return frappe.get_doc(
|
||||
{
|
||||
"doctype": "Lead",
|
||||
"lead_name": f"Lead {frappe.generate_hash(length=6)}",
|
||||
"lead_owner": self.owner,
|
||||
"company": "_Test Company",
|
||||
}
|
||||
).insert()
|
||||
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict({"from_date": add_days(today(), -1), "to_date": today()})
|
||||
filters.update(extra)
|
||||
return execute(filters)[1]
|
||||
|
||||
def owner_row(self, data):
|
||||
return next((r for r in data if r["lead_owner"] == self.owner), None)
|
||||
|
||||
def test_lead_count_grouped_by_owner(self):
|
||||
self.make_lead()
|
||||
self.make_lead()
|
||||
|
||||
row = self.owner_row(self.run_report())
|
||||
self.assertIsNotNone(row, "Lead owner missing from report")
|
||||
self.assertEqual(row["lead_count"], 2)
|
||||
self.assertEqual(row["opp_count"], 0)
|
||||
self.assertEqual(row["opp_lead"], 0.0)
|
||||
|
||||
def test_opportunity_from_lead_is_counted(self):
|
||||
lead = self.make_lead()
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Opportunity",
|
||||
"opportunity_from": "Lead",
|
||||
"party_name": lead.name,
|
||||
"company": "_Test Company",
|
||||
"currency": "INR",
|
||||
}
|
||||
).insert()
|
||||
|
||||
row = self.owner_row(self.run_report())
|
||||
self.assertEqual(row["lead_count"], 1)
|
||||
self.assertEqual(row["opp_count"], 1)
|
||||
# one opportunity from one lead -> 100% opp/lead conversion
|
||||
self.assertEqual(row["opp_lead"], 100.0)
|
||||
@@ -2,11 +2,11 @@
|
||||
"app": "erpnext",
|
||||
"charts": [
|
||||
{
|
||||
"chart_name": "Territory Wise Sales",
|
||||
"label": "Territory Wise Sales"
|
||||
"chart_name": "Won Opportunities",
|
||||
"label": "Won Opportunities"
|
||||
}
|
||||
],
|
||||
"content": "[{\"id\":\"4jhDsfZ7EP\",\"type\":\"header\",\"data\":{\"text\":\"This module is scheduled for deprecation and will be completely removed in version 17, please use <a href=\\\"http://frappe.io/crm\\\">Frappe CRM</a> instead.\",\"col\":12}},{\"id\":\"Cj2TyhgiWy\",\"type\":\"chart\",\"data\":{\"chart_name\":\"Territory Wise Sales\",\"col\":12}},{\"id\":\"LAKRmpYMRA\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"69RN0XsiJK\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Lead\",\"col\":3}},{\"id\":\"t6PQ0vY-Iw\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Opportunity\",\"col\":3}},{\"id\":\"VOFE0hqXRD\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Customer\",\"col\":3}},{\"id\":\"0ik53fuemG\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Sales Analytics\",\"col\":3}},{\"id\":\"wdROEmB_XG\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Dashboard\",\"col\":3}},{\"id\":\"-I9HhcgUKE\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"ttpROKW9vk\",\"type\":\"header\",\"data\":{\"text\":\"<span class=\\\"h4\\\"><b>Reports & Masters</b></span>\",\"col\":12}},{\"id\":\"-76QPdbBHy\",\"type\":\"card\",\"data\":{\"card_name\":\"Sales Pipeline\",\"col\":4}},{\"id\":\"_YmGwzVWRr\",\"type\":\"card\",\"data\":{\"card_name\":\"Masters\",\"col\":4}},{\"id\":\"Bma1PxoXk3\",\"type\":\"card\",\"data\":{\"card_name\":\"Reports\",\"col\":4}},{\"id\":\"80viA0R83a\",\"type\":\"card\",\"data\":{\"card_name\":\"Campaign\",\"col\":4}},{\"id\":\"Buo5HtKRFN\",\"type\":\"card\",\"data\":{\"card_name\":\"Settings\",\"col\":4}},{\"id\":\"sLS_x4FMK2\",\"type\":\"card\",\"data\":{\"card_name\":\"Maintenance\",\"col\":4}}]",
|
||||
"content": "[{\"id\":\"4jhDsfZ7EP\",\"type\":\"header\",\"data\":{\"text\":\"This module is scheduled for deprecation and will be completely removed in version 17, please use <a href=\\\"https://frappe.io/crm\\\">Frappe CRM</a> instead.\",\"col\":12}},{\"id\":\"-bzBQ_IbL9\",\"type\":\"chart\",\"data\":{\"chart_name\":\"Won Opportunities\",\"col\":12}},{\"id\":\"LdM1QgUnqU\",\"type\":\"number_card\",\"data\":{\"number_card_name\":\"New Lead (Last 1 Month)\",\"col\":4}},{\"id\":\"X23-SXBcYG\",\"type\":\"number_card\",\"data\":{\"number_card_name\":\"New Opportunity (Last 1 Month)\",\"col\":4}},{\"id\":\"3rm7fH52M-\",\"type\":\"number_card\",\"data\":{\"number_card_name\":\"Won Opportunity (Last 1 Month)\",\"col\":4}},{\"id\":\"K6a2Kh5Zav\",\"type\":\"spacer\",\"data\":{\"col\":12}}]",
|
||||
"creation": "2020-01-23 14:48:30.183272",
|
||||
"custom_blocks": [],
|
||||
"docstatus": 0,
|
||||
@@ -18,14 +18,6 @@
|
||||
"is_hidden": 0,
|
||||
"label": "CRM",
|
||||
"links": [
|
||||
{
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "Reports",
|
||||
"link_count": 0,
|
||||
"onboard": 0,
|
||||
"type": "Card Break"
|
||||
},
|
||||
{
|
||||
"dependencies": "Lead",
|
||||
"hidden": 0,
|
||||
@@ -123,14 +115,6 @@
|
||||
"onboard": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "Maintenance",
|
||||
"link_count": 0,
|
||||
"onboard": 0,
|
||||
"type": "Card Break"
|
||||
},
|
||||
{
|
||||
"dependencies": "",
|
||||
"hidden": 0,
|
||||
@@ -164,183 +148,6 @@
|
||||
"onboard": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "Sales Pipeline",
|
||||
"link_count": 7,
|
||||
"onboard": 0,
|
||||
"type": "Card Break"
|
||||
},
|
||||
{
|
||||
"dependencies": "",
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "Lead",
|
||||
"link_count": 0,
|
||||
"link_to": "Lead",
|
||||
"link_type": "DocType",
|
||||
"onboard": 1,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"dependencies": "",
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "Opportunity",
|
||||
"link_count": 0,
|
||||
"link_to": "Opportunity",
|
||||
"link_type": "DocType",
|
||||
"onboard": 1,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"dependencies": "",
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "Customer",
|
||||
"link_count": 0,
|
||||
"link_to": "Customer",
|
||||
"link_type": "DocType",
|
||||
"onboard": 1,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"dependencies": "",
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "Contract",
|
||||
"link_count": 0,
|
||||
"link_to": "Contract",
|
||||
"link_type": "DocType",
|
||||
"onboard": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"dependencies": "",
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "Appointment",
|
||||
"link_count": 0,
|
||||
"link_to": "Appointment",
|
||||
"link_type": "DocType",
|
||||
"onboard": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"dependencies": "",
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "Newsletter",
|
||||
"link_count": 0,
|
||||
"link_to": "Newsletter",
|
||||
"link_type": "DocType",
|
||||
"onboard": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"dependencies": "",
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "Communication",
|
||||
"link_count": 0,
|
||||
"link_to": "Communication",
|
||||
"link_type": "DocType",
|
||||
"onboard": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "Settings",
|
||||
"link_count": 2,
|
||||
"onboard": 0,
|
||||
"type": "Card Break"
|
||||
},
|
||||
{
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "CRM Settings",
|
||||
"link_count": 0,
|
||||
"link_to": "CRM Settings",
|
||||
"link_type": "DocType",
|
||||
"onboard": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"dependencies": "",
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "SMS Settings",
|
||||
"link_count": 0,
|
||||
"link_to": "SMS Settings",
|
||||
"link_type": "DocType",
|
||||
"onboard": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "Campaign",
|
||||
"link_count": 5,
|
||||
"onboard": 0,
|
||||
"type": "Card Break"
|
||||
},
|
||||
{
|
||||
"dependencies": "",
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "Campaign",
|
||||
"link_count": 0,
|
||||
"link_to": "Campaign",
|
||||
"link_type": "DocType",
|
||||
"onboard": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"dependencies": "",
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "Email Campaign",
|
||||
"link_count": 0,
|
||||
"link_to": "Email Campaign",
|
||||
"link_type": "DocType",
|
||||
"onboard": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"dependencies": "",
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "SMS Center",
|
||||
"link_count": 0,
|
||||
"link_to": "SMS Center",
|
||||
"link_type": "DocType",
|
||||
"onboard": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"dependencies": "",
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "SMS Log",
|
||||
"link_count": 0,
|
||||
"link_to": "SMS Log",
|
||||
"link_type": "DocType",
|
||||
"onboard": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"dependencies": "",
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "Email Group",
|
||||
"link_count": 0,
|
||||
"link_to": "Email Group",
|
||||
"link_type": "DocType",
|
||||
"onboard": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
@@ -421,11 +228,24 @@
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-06-14 13:44:08.297053",
|
||||
"modified": "2026-01-03 15:05:23.983099",
|
||||
"modified_by": "Administrator",
|
||||
"module": "CRM",
|
||||
"name": "CRM",
|
||||
"number_cards": [],
|
||||
"number_cards": [
|
||||
{
|
||||
"label": "New Lead (Last 1 Month)",
|
||||
"number_card_name": "New Lead (Last 1 Month)"
|
||||
},
|
||||
{
|
||||
"label": "New Opportunity (Last 1 Month)",
|
||||
"number_card_name": "New Opportunity (Last 1 Month)"
|
||||
},
|
||||
{
|
||||
"label": "Won Opportunity (Last 1 Month)",
|
||||
"number_card_name": "Won Opportunity (Last 1 Month)"
|
||||
}
|
||||
],
|
||||
"owner": "Administrator",
|
||||
"parent_page": "",
|
||||
"public": 1,
|
||||
@@ -433,552 +253,7 @@
|
||||
"restrict_to_domain": "",
|
||||
"roles": [],
|
||||
"sequence_id": 17.0,
|
||||
"shortcuts": [
|
||||
{
|
||||
"color": "Blue",
|
||||
"format": "{} Open",
|
||||
"label": "Lead",
|
||||
"link_to": "Lead",
|
||||
"stats_filter": "{\"status\":\"Open\"}",
|
||||
"type": "DocType"
|
||||
},
|
||||
{
|
||||
"color": "Blue",
|
||||
"format": "{} Assigned",
|
||||
"label": "Opportunity",
|
||||
"link_to": "Opportunity",
|
||||
"stats_filter": "{\"_assign\": [\"like\", '%' + frappe.session.user + '%']}",
|
||||
"type": "DocType"
|
||||
},
|
||||
{
|
||||
"label": "Customer",
|
||||
"link_to": "Customer",
|
||||
"type": "DocType"
|
||||
},
|
||||
{
|
||||
"label": "Sales Analytics",
|
||||
"link_to": "Sales Analytics",
|
||||
"report_ref_doctype": "Sales Order",
|
||||
"type": "Report"
|
||||
},
|
||||
{
|
||||
"label": "Dashboard",
|
||||
"link_to": "CRM",
|
||||
"type": "Dashboard"
|
||||
}
|
||||
],
|
||||
"sidebar_items": [
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "chart",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Home",
|
||||
"link_to": "CRM",
|
||||
"link_type": "Dashboard",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "users-round",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Lead",
|
||||
"link_to": "Lead",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "lightbulb",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Opportunity",
|
||||
"link_to": "Opportunity",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "customer",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Customer",
|
||||
"link_to": "Customer",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "sheet",
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Reports",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Sales Analytics",
|
||||
"link_to": "Sales Analytics",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Lead Details",
|
||||
"link_to": "Lead Details",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Sales Pipeline Analytics",
|
||||
"link_to": "Sales Pipeline Analytics",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Opportunity Summary by Sales Stage",
|
||||
"link_to": "Opportunity Summary by Sales Stage",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Sales Funnel",
|
||||
"link_to": "sales-funnel",
|
||||
"link_type": "Page",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Prospects Engaged But Not Converted",
|
||||
"link_to": "Prospects Engaged But Not Converted",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "First Response Time for Opportunity",
|
||||
"link_to": "First Response Time for Opportunity",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Campaign Efficiency",
|
||||
"link_to": "Campaign Efficiency",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Lead Owner Efficiency",
|
||||
"link_to": "Lead Owner Efficiency",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "getting-started",
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Maintenance",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Maintenance Schedule",
|
||||
"link_to": "Maintenance Schedule",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Maintenance Visit",
|
||||
"link_to": "Maintenance Visit",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Warranty Claim",
|
||||
"link_to": "Warranty Claim",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "funnel",
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Sales Pipeline",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Lead",
|
||||
"link_to": "Lead",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Opportunity",
|
||||
"link_to": "Opportunity",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Customer",
|
||||
"link_to": "Customer",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Contract",
|
||||
"link_to": "Contract",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Appointment",
|
||||
"link_to": "Appointment",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Communication",
|
||||
"link_to": "Communication",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "sell",
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Campaign",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Campaign",
|
||||
"link_to": "Campaign",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Email Campaign",
|
||||
"link_to": "Email Campaign",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "SMS Center",
|
||||
"link_to": "SMS Center",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "SMS Log",
|
||||
"link_to": "SMS Log",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Email Group",
|
||||
"link_to": "Email Group",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "database",
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Setup",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Territory",
|
||||
"link_to": "Territory",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Customer Group",
|
||||
"link_to": "Customer Group",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Contact",
|
||||
"link_to": "Contact",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Prospect",
|
||||
"link_to": "Prospect",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Sales Person",
|
||||
"link_to": "Sales Person",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Sales Stage",
|
||||
"link_to": "Sales Stage",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Lead Source",
|
||||
"link_to": "UTM Source",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "settings",
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Settings",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "CRM Settings",
|
||||
"link_to": "CRM Settings",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "SMS Settings",
|
||||
"link_to": "SMS Settings",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"standard": 1,
|
||||
"shortcuts": [],
|
||||
"title": "CRM",
|
||||
"type": "Workspace"
|
||||
}
|
||||
|
||||
@@ -215,14 +215,7 @@ def sync_transactions(bank, bank_account):
|
||||
result = []
|
||||
if transactions:
|
||||
for transaction in reversed(transactions):
|
||||
# per-transaction savepoint: a failed insert/submit must not discard the Bank
|
||||
# Transactions already synced this run (MariaDB keeps them) nor poison the txn on Postgres
|
||||
frappe.db.savepoint("plaid_sync_txn")
|
||||
try:
|
||||
result += new_bank_transaction(transaction)
|
||||
except Exception:
|
||||
frappe.db.rollback(save_point="plaid_sync_txn")
|
||||
raise
|
||||
result += new_bank_transaction(transaction)
|
||||
|
||||
if result:
|
||||
last_transaction_date = frappe.db.get_value("Bank Transaction", result.pop(), "date")
|
||||
|
||||
@@ -8,7 +8,7 @@ app_email = "hello@frappe.io"
|
||||
app_license = "GNU General Public License (v3)"
|
||||
source_link = "https://github.com/frappe/erpnext"
|
||||
app_logo_url = "/assets/erpnext/images/erpnext-logo.svg"
|
||||
app_home = "/desk/home"
|
||||
app_home = "/desk"
|
||||
|
||||
add_to_apps_screen = [
|
||||
{
|
||||
@@ -507,7 +507,6 @@ 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",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: frappe\n"
|
||||
"Report-Msgid-Bugs-To: hello@frappe.io\n"
|
||||
"POT-Creation-Date: 2026-06-28 10:20+0000\n"
|
||||
"PO-Revision-Date: 2026-06-29 20:08\n"
|
||||
"PO-Revision-Date: 2026-07-01 20:39\n"
|
||||
"Last-Translator: hello@frappe.io\n"
|
||||
"Language-Team: Persian\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
@@ -14218,7 +14218,7 @@ msgstr "آدرس فعلی"
|
||||
#. Label of the current_accommodation_type (Select) field in DocType 'Employee'
|
||||
#: erpnext/setup/doctype/employee/employee.json
|
||||
msgid "Current Address Is"
|
||||
msgstr "آدرس فعلی است"
|
||||
msgstr "آدرس فعلی"
|
||||
|
||||
#. Label of the current_amount (Currency) field in DocType 'Stock
|
||||
#. Reconciliation Item'
|
||||
@@ -17767,7 +17767,7 @@ msgstr "سود سهام پرداخت شده"
|
||||
#. Option for the 'Marital Status' (Select) field in DocType 'Employee'
|
||||
#: erpnext/setup/doctype/employee/employee.json
|
||||
msgid "Divorced"
|
||||
msgstr "جدا شده"
|
||||
msgstr "طلاق گرفته"
|
||||
|
||||
#. Option for the 'Status' (Select) field in DocType 'Lead'
|
||||
#: erpnext/crm/doctype/lead/lead.json
|
||||
@@ -30241,7 +30241,7 @@ msgstr "متخصص بازاریابی"
|
||||
#. Option for the 'Marital Status' (Select) field in DocType 'Employee'
|
||||
#: erpnext/setup/doctype/employee/employee.json
|
||||
msgid "Married"
|
||||
msgstr "متاهل"
|
||||
msgstr "متأهل"
|
||||
|
||||
#: erpnext/setup/setup_wizard/data/marketing_source.txt:7
|
||||
msgid "Mass Mailing"
|
||||
@@ -34688,7 +34688,7 @@ msgstr ""
|
||||
#. Option for the 'Current Address Is' (Select) field in DocType 'Employee'
|
||||
#: erpnext/setup/doctype/employee/employee.json
|
||||
msgid "Owned"
|
||||
msgstr "مالکیت"
|
||||
msgstr "ملکی"
|
||||
|
||||
#: erpnext/accounts/report/sales_payment_summary/sales_payment_summary.js:29
|
||||
#: erpnext/accounts/report/sales_payment_summary/sales_payment_summary.py:24
|
||||
@@ -37162,7 +37162,7 @@ msgstr "آدرس دائمی"
|
||||
#. 'Employee'
|
||||
#: erpnext/setup/doctype/employee/employee.json
|
||||
msgid "Permanent Address Is"
|
||||
msgstr "آدرس دائمی است"
|
||||
msgstr "آدرس دائمی"
|
||||
|
||||
#: erpnext/accounts/doctype/bank_statement_import_log/bank_statement_import_log.py:73
|
||||
#: erpnext/accounts/doctype/bank_statement_import_log/bank_statement_import_log.py:77
|
||||
@@ -44208,7 +44208,7 @@ msgstr "اجاره"
|
||||
#. Option for the 'Current Address Is' (Select) field in DocType 'Employee'
|
||||
#: erpnext/setup/doctype/employee/employee.json
|
||||
msgid "Rented"
|
||||
msgstr "اجاره شده"
|
||||
msgstr "استیجاری"
|
||||
|
||||
#. Label of the reorder_level (Float) field in DocType 'Material Request Item'
|
||||
#: erpnext/stock/doctype/material_request_item/material_request_item.json
|
||||
@@ -50940,7 +50940,7 @@ msgstr ""
|
||||
#. Option for the 'Marital Status' (Select) field in DocType 'Employee'
|
||||
#: erpnext/setup/doctype/employee/employee.json
|
||||
msgid "Single"
|
||||
msgstr "تنها"
|
||||
msgstr "مجرد"
|
||||
|
||||
#. Option for the 'Bank Entry Type' (Select) field in DocType 'Bank Transaction
|
||||
#. Rule'
|
||||
@@ -60982,7 +60982,7 @@ msgstr "هنگام تهیه فاکتور خرید از سفارش خرید، ب
|
||||
#. Option for the 'Marital Status' (Select) field in DocType 'Employee'
|
||||
#: erpnext/setup/doctype/employee/employee.json
|
||||
msgid "Widowed"
|
||||
msgstr "بیوه"
|
||||
msgstr "همسر فوت شده"
|
||||
|
||||
#. Label of the width (Float) field in DocType 'Shipment Parcel'
|
||||
#. Label of the width (Float) field in DocType 'Shipment Parcel Template'
|
||||
@@ -61884,7 +61884,7 @@ msgstr "تراز صفر"
|
||||
|
||||
#: erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py:353
|
||||
msgid "Zero Balance Journal: {0}"
|
||||
msgstr ""
|
||||
msgstr "دفتر تراز صفر: {0}"
|
||||
|
||||
#: erpnext/regional/report/uae_vat_201/uae_vat_201.py:78
|
||||
msgid "Zero Rated"
|
||||
@@ -62008,7 +62008,7 @@ msgstr "fieldname"
|
||||
|
||||
#: erpnext/setup/doctype/item_group/item_group.py:49
|
||||
msgid "for tax category {0}"
|
||||
msgstr ""
|
||||
msgstr "برای دسته بندی مالیاتی {0}"
|
||||
|
||||
#. Option for the 'Service Provider' (Select) field in DocType 'Currency
|
||||
#. Exchange Settings'
|
||||
@@ -62375,7 +62375,7 @@ msgstr ""
|
||||
|
||||
#: erpnext/public/js/utils/sales_common.js:336
|
||||
msgid "{0} cannot be greater than 100"
|
||||
msgstr ""
|
||||
msgstr "{0} نمیتواند بزرگتر از ۱۰۰ باشد"
|
||||
|
||||
#: erpnext/accounts/doctype/cost_center_allocation/cost_center_allocation.py:136
|
||||
msgid "{0} cannot be used as a Main Cost Center because it has been used as child in Cost Center Allocation {1}"
|
||||
@@ -62444,7 +62444,7 @@ msgstr "{0} با موفقیت ارسال شد"
|
||||
|
||||
#: erpnext/controllers/buying_controller.py:289
|
||||
msgid "{0} has submitted assets linked to it. You need to cancel the assets to create purchase return."
|
||||
msgstr ""
|
||||
msgstr "{0} داراییهای مرتبط با آن را ارسال کرده است. برای ایجاد بازگشت خرید، باید داراییها را لغو کنید."
|
||||
|
||||
#: erpnext/projects/doctype/project/project_dashboard.html:15
|
||||
msgid "{0} hours"
|
||||
@@ -62456,7 +62456,7 @@ msgstr "{0} در ردیف {1}"
|
||||
|
||||
#: erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py:66
|
||||
msgid "{0} is a child company."
|
||||
msgstr ""
|
||||
msgstr "{0} یک شرکت فرزند است."
|
||||
|
||||
#: erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py:465
|
||||
msgid "{0} is a child table and will be deleted automatically with its parent"
|
||||
@@ -62539,7 +62539,7 @@ msgstr "{0} در {1} فعال نیست"
|
||||
|
||||
#: erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py:649
|
||||
msgid "{0} is not running. Cannot trigger events for this document"
|
||||
msgstr ""
|
||||
msgstr "{0} در حال اجرا نیست. نمیتوان رویدادها را برای این سند فعال کرد"
|
||||
|
||||
#: erpnext/stock/doctype/material_request/material_request.py:478
|
||||
msgid "{0} is not the default supplier for any items."
|
||||
@@ -62547,7 +62547,7 @@ msgstr "{0} تامین کننده پیشفرض هیچ موردی نیست."
|
||||
|
||||
#: erpnext/accounts/doctype/payment_entry/payment_entry.py:2686
|
||||
msgid "{0} is on hold until {1}"
|
||||
msgstr ""
|
||||
msgstr "{0} تا زمان {1} در حالت انتظار است"
|
||||
|
||||
#: erpnext/accounts/doctype/pos_opening_entry/pos_opening_entry.py:68
|
||||
msgid "{0} is open. Close the POS or cancel the existing POS Opening Entry to create a new POS Opening Entry."
|
||||
@@ -62722,11 +62722,11 @@ msgstr ""
|
||||
#: erpnext/accounts/doctype/party_link/party_link.py:53
|
||||
#: erpnext/accounts/doctype/party_link/party_link.py:63
|
||||
msgid "{0} {1} is already linked with another {2}"
|
||||
msgstr ""
|
||||
msgstr "{0} {1} از قبل به {2} دیگری لینک شده است"
|
||||
|
||||
#: erpnext/accounts/doctype/party_link/party_link.py:40
|
||||
msgid "{0} {1} is already linked with {2} {3}"
|
||||
msgstr ""
|
||||
msgstr "{0} {1} از قبل به {2} {3} لینک شده است"
|
||||
|
||||
#: erpnext/accounts/doctype/payment_entry/payment_entry.py:711
|
||||
msgid "{0} {1} is associated with {2}, but Party Account is {3}"
|
||||
@@ -62767,7 +62767,7 @@ msgstr "{0} {1} فعال نیست"
|
||||
|
||||
#: erpnext/accounts/doctype/bank_transaction/bank_transaction.py:452
|
||||
msgid "{0} {1} is not affecting bank account {2}"
|
||||
msgstr ""
|
||||
msgstr "{0} {1} تاثیری بر حساب بانکی {2} ندارد"
|
||||
|
||||
#: erpnext/accounts/doctype/payment_entry/payment_entry.py:688
|
||||
msgid "{0} {1} is not associated with {2} {3}"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: frappe\n"
|
||||
"Report-Msgid-Bugs-To: hello@frappe.io\n"
|
||||
"POT-Creation-Date: 2026-06-28 10:20+0000\n"
|
||||
"PO-Revision-Date: 2026-06-29 20:08\n"
|
||||
"PO-Revision-Date: 2026-07-01 20:39\n"
|
||||
"Last-Translator: hello@frappe.io\n"
|
||||
"Language-Team: Swedish\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
@@ -8227,7 +8227,7 @@ msgstr "Saldo Historik per Parti"
|
||||
#: erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py:183
|
||||
#: erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.py:86
|
||||
msgid "Batchwise Valuation"
|
||||
msgstr "Partivis Värdering"
|
||||
msgstr "Partibaserad Värdering"
|
||||
|
||||
#. Label of the section_break_3 (Section Break) field in DocType 'Stock
|
||||
#. Reconciliation Item'
|
||||
@@ -9319,7 +9319,7 @@ msgstr "Beräkna Uppskatade Ankomst Tider"
|
||||
#. Settings'
|
||||
#: erpnext/selling/doctype/selling_settings/selling_settings.json
|
||||
msgid "Calculate Product Bundle price based on child Item's rates"
|
||||
msgstr "Beräkna Artikel Paket pris baserat på priser för underordnade artiklar"
|
||||
msgstr "Beräkna Artikel Paket pris baserat på priser för paket artiklar"
|
||||
|
||||
#. Description of the 'Hidden Line (Internal Use Only)' (Check) field in
|
||||
#. DocType 'Financial Report Row'
|
||||
@@ -15680,7 +15680,7 @@ msgstr "Avdraget från"
|
||||
#. Deduction Certificate'
|
||||
#: erpnext/regional/doctype/lower_deduction_certificate/lower_deduction_certificate.json
|
||||
msgid "Deductee Details"
|
||||
msgstr "Avdragsberättigad Detaljer"
|
||||
msgstr "Avdragstagare Detaljer"
|
||||
|
||||
#. Label of a Workspace Sidebar Item
|
||||
#: erpnext/workspace_sidebar/taxes.json
|
||||
@@ -17908,7 +17908,7 @@ msgstr "Uppdatera inte Varianter vid Spara"
|
||||
#. Settings'
|
||||
#: erpnext/stock/doctype/stock_settings/stock_settings.json
|
||||
msgid "Do not use Batch-wise Valuation"
|
||||
msgstr "Använd inte Partivis Värdering"
|
||||
msgstr "Använd inte Partibaserad Värdering"
|
||||
|
||||
#: erpnext/assets/doctype/asset/asset.js:957
|
||||
msgid "Do you really want to restore this scrapped asset?"
|
||||
@@ -19193,9 +19193,9 @@ msgid "Enabling this will do the following:\n"
|
||||
msgstr "Om du aktiverar detta kommer följande att hända:\n"
|
||||
"<ul style=\"padding-left:16px\">\n"
|
||||
"<li>Pris Kolumn i alla Artikel Paket tabeller redigerbar.</li>\n"
|
||||
"<li>Beräkna priser för alla <a href=\"/desk/product-bundle\" rel=\"noopener noreferrer\">artikel paket</a> i Artikel tabell, baserat på priser för dess underordnade artiklar, som anges i Artikel Paket tabell. </li>\n"
|
||||
"<li>Beräkna priser för alla <a href=\"/desk/product-bundle\" rel=\"noopener noreferrer\">Artikel Paket</a> i Artikel tabell, baserat på priser för paket artiklar, som anges i Artikel Paket tabell. </li>\n"
|
||||
"</ul>\n"
|
||||
"Observera: Om detta är aktiverat kommer uppdatering av pris för artikel paket i artikel tabell inte att ändra dess pris. Det kommer att återställas till det pris som baseras på dess underordnade artiklar när dokumentet sparas."
|
||||
"Observera: Om detta är aktiverat kommer uppdatering av pris för artikel paket i artikel tabell inte att ändra deras pris. Det kommer att återställas till pris som baseras på paket artiklar när dokument sparas."
|
||||
|
||||
#. Label of the encashment_date (Date) field in DocType 'Employee'
|
||||
#: erpnext/setup/doctype/employee/employee.json
|
||||
@@ -33834,7 +33834,7 @@ msgstr "Öppning Faktura Verktyg"
|
||||
#: erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py:832
|
||||
#: erpnext/accounts/doctype/sales_invoice/services/gl_composer.py:642
|
||||
msgid "Opening Invoice has rounding adjustment of {0}.<br><br> '{1}' account is required to post these values. Please set it in Company: {2}.<br><br> Or, '{3}' can be enabled to not post any rounding adjustment."
|
||||
msgstr "Öppning Fakturan har avrundning justering på {0}.<br><br> '{1}' konto erfordras för att bokföra dessa värden. Ange det i Bolag: {2}.<br><br> Eller så kan '{3}' aktiveras för att inte bokföra någon avrundning justering."
|
||||
msgstr "Öppning Faktura har avrundning justering på {0}.<br><br> '{1}' konto erfordras för att bokföra dessa värden. Ange det i Bolag: {2}.<br><br> Eller så kan '{3}' aktiveras för att inte bokföra någon avrundning justering."
|
||||
|
||||
#: erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool_dashboard.html:8
|
||||
msgid "Opening Invoices"
|
||||
@@ -59194,7 +59194,7 @@ msgstr "Använd <strong>Python</strong> filter för att hämta Konton"
|
||||
#. Label of the use_batchwise_valuation (Check) field in DocType 'Batch'
|
||||
#: erpnext/stock/doctype/batch/batch.json
|
||||
msgid "Use Batch-wise Valuation"
|
||||
msgstr "Använd Partivis Värdering"
|
||||
msgstr "Använd Partibaserad Värdering"
|
||||
|
||||
#. Label of the use_csv_sniffer (Check) field in DocType 'Bank Statement
|
||||
#. Import'
|
||||
@@ -60825,7 +60825,7 @@ msgstr "Garanti Utgång (Serienummer)"
|
||||
#: erpnext/stock/doctype/serial_no/serial_no.json
|
||||
#: erpnext/support/doctype/warranty_claim/warranty_claim.json
|
||||
msgid "Warranty Expiry Date"
|
||||
msgstr "Garanti Utgångsdatum"
|
||||
msgstr "Garanti Utgång Datum"
|
||||
|
||||
#. Label of the warranty_period (Int) field in DocType 'Serial No'
|
||||
#: erpnext/stock/doctype/serial_no/serial_no.json
|
||||
|
||||
@@ -16,7 +16,7 @@ from frappe.website.website_generator import WebsiteGenerator
|
||||
import erpnext
|
||||
from erpnext.setup.utils import get_exchange_rate
|
||||
from erpnext.stock.doctype.item.item import get_item_details
|
||||
from erpnext.stock.get_item_details import get_conversion_factor, get_price_list_rate
|
||||
from erpnext.stock.get_item_details import ItemDetailsCtx, get_conversion_factor, get_price_list_rate
|
||||
|
||||
form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
|
||||
|
||||
@@ -739,7 +739,7 @@ class BOM(WebsiteGenerator):
|
||||
)
|
||||
)
|
||||
|
||||
def check_recursion(self):
|
||||
def check_recursion(self, bom_list=None):
|
||||
"""Check whether recursion occurs in any bom"""
|
||||
bom_list = self.traverse_tree()
|
||||
child_items = frappe.get_all(
|
||||
@@ -861,30 +861,21 @@ class BOM(WebsiteGenerator):
|
||||
|
||||
self.append("items", row)
|
||||
|
||||
def traverse_tree(self):
|
||||
"""Return this BOM and every descendant BOM. The whole sub-tree is fetched in one recursive
|
||||
CTE (frappe.qb) instead of a query-per-node walk; the only caller (check_recursion) uses the
|
||||
result purely as a membership set. Portable across postgres and mariadb 10.2+."""
|
||||
bom_item = frappe.qb.DocType("BOM Item")
|
||||
tree = frappe.qb.Table("bom_tree")
|
||||
def traverse_tree(self, bom_list=None):
|
||||
count = 0
|
||||
if not bom_list:
|
||||
bom_list = []
|
||||
|
||||
seed = (
|
||||
frappe.qb.from_(bom_item)
|
||||
.select(bom_item.bom_no.as_("bom"))
|
||||
.where((bom_item.parent == self.name) & (bom_item.bom_no != "") & (bom_item.parenttype == "BOM"))
|
||||
)
|
||||
recursion = (
|
||||
frappe.qb.from_(bom_item)
|
||||
.join(tree)
|
||||
.on(bom_item.parent == tree.bom)
|
||||
.select(bom_item.bom_no)
|
||||
.where((bom_item.bom_no != "") & (bom_item.parenttype == "BOM"))
|
||||
)
|
||||
descendants = (
|
||||
frappe.qb.with_(seed + recursion, "bom_tree", recursive=True).from_(tree).select(tree.bom)
|
||||
).run(pluck=True)
|
||||
if self.name not in bom_list:
|
||||
bom_list.append(self.name)
|
||||
|
||||
return [self.name, *descendants]
|
||||
while count < len(bom_list):
|
||||
for child_bom in _get_bom_children(bom_list[count]):
|
||||
if child_bom not in bom_list:
|
||||
bom_list.append(child_bom)
|
||||
count += 1
|
||||
bom_list.reverse()
|
||||
return bom_list
|
||||
|
||||
def company_currency(self):
|
||||
return erpnext.get_company_currency(self.company)
|
||||
@@ -1081,7 +1072,7 @@ def _get_price_list_item_rate(args, bom_doc):
|
||||
if not bom_doc.buying_price_list:
|
||||
frappe.throw(_("Please select Price List"))
|
||||
|
||||
ctx = frappe._dict(
|
||||
ctx = ItemDetailsCtx(
|
||||
{
|
||||
"doctype": "BOM",
|
||||
"price_list": bom_doc.buying_price_list,
|
||||
|
||||
@@ -67,33 +67,29 @@ def update_cost_in_level(doc: "BOMUpdateLog", bom_list: list[str], batch_name: i
|
||||
frappe.db.commit() # nosemgrep
|
||||
|
||||
|
||||
def get_ancestor_boms(new_bom: str) -> list:
|
||||
"""Return every ancestor BOM of `new_bom` (BOMs that consume it, transitively) in one recursive
|
||||
CTE built with frappe.qb -- portable across postgres and mariadb 10.2+. `UNION` makes it
|
||||
cycle-safe (it stops once no new BOM is reached); a BOM that is its own ancestor is rejected."""
|
||||
def get_ancestor_boms(new_bom: str, bom_list: list | None = None) -> list:
|
||||
"Recursively get all ancestors of BOM."
|
||||
|
||||
bom_list = bom_list or []
|
||||
bom_item = frappe.qb.DocType("BOM Item")
|
||||
tree = frappe.qb.Table("ancestor_boms")
|
||||
|
||||
seed = (
|
||||
parents = (
|
||||
frappe.qb.from_(bom_item)
|
||||
.select(bom_item.parent.as_("bom"))
|
||||
.where((bom_item.bom_no == new_bom) & (bom_item.docstatus < 2) & (bom_item.parenttype == "BOM"))
|
||||
)
|
||||
recursion = (
|
||||
frappe.qb.from_(bom_item)
|
||||
.join(tree)
|
||||
.on(bom_item.bom_no == tree.bom)
|
||||
.select(bom_item.parent)
|
||||
.where((bom_item.docstatus < 2) & (bom_item.parenttype == "BOM"))
|
||||
.where((bom_item.bom_no == new_bom) & (bom_item.docstatus < 2) & (bom_item.parenttype == "BOM"))
|
||||
.run(as_dict=True)
|
||||
)
|
||||
ancestors = (
|
||||
frappe.qb.with_(seed + recursion, "ancestor_boms", recursive=True).from_(tree).select(tree.bom)
|
||||
).run(pluck=True)
|
||||
|
||||
if new_bom in ancestors:
|
||||
frappe.throw(_("BOM recursion: {0} cannot be an ancestor of itself").format(new_bom))
|
||||
for d in parents:
|
||||
if new_bom == d.parent:
|
||||
frappe.throw(_("BOM recursion: {0} cannot be child of {1}").format(new_bom, d.parent))
|
||||
|
||||
return ancestors
|
||||
if d.parent not in tuple(bom_list):
|
||||
bom_list.append(d.parent)
|
||||
|
||||
get_ancestor_boms(d.parent, bom_list)
|
||||
|
||||
return bom_list
|
||||
|
||||
|
||||
def update_new_bom_in_bom_items(unit_cost: float, current_bom: str, new_bom: str) -> None:
|
||||
|
||||
@@ -149,16 +149,6 @@ class RequiredItemsService:
|
||||
|
||||
self.recompute_material_transferred_for_manufacturing(transferred_items)
|
||||
|
||||
def refresh_material_transferred_for_manufacturing(self):
|
||||
"""Recompute material_transferred_for_manufacturing only, without touching per-row
|
||||
transferred_qty or stock reservations. Used to get a status decision (Not Started vs
|
||||
In Process) based on fresh data, ahead of the fuller update_required_items() pass.
|
||||
"""
|
||||
if self.doc.skip_transfer:
|
||||
return
|
||||
transferred_items = self._material_transfer_qty_by_item(is_return=0)
|
||||
self.recompute_material_transferred_for_manufacturing(transferred_items)
|
||||
|
||||
def recompute_material_transferred_for_manufacturing(self, transferred_items):
|
||||
"""Set material_transferred_for_manufacturing based on actual item-level transfers, not fg_completed_qty."""
|
||||
# When fg_completed_qty > 0 (direct stock entries, excess transfer), preserve the
|
||||
|
||||
@@ -87,12 +87,6 @@ class StatusService:
|
||||
|
||||
def update_status(self, status=None):
|
||||
"""Update status of work order if unknown"""
|
||||
if self.doc.docstatus == 1:
|
||||
# Refresh material_transferred_for_manufacturing before deciding status so pick-list-
|
||||
# driven transfers (where this qty is derived from item transfers, not fg_completed_qty)
|
||||
# are reflected immediately, instead of only after the next status update call.
|
||||
self.doc.refresh_material_transferred_for_manufacturing()
|
||||
|
||||
if self.doc.status != "Closed":
|
||||
if status not in ["Stopped", "Closed"]:
|
||||
status = self.get_status(status)
|
||||
|
||||
@@ -1003,9 +1003,6 @@ class WorkOrder(Document):
|
||||
def update_transferred_qty_for_required_items(self):
|
||||
return RequiredItemsService(self).update_transferred_qty_for_required_items()
|
||||
|
||||
def refresh_material_transferred_for_manufacturing(self):
|
||||
return RequiredItemsService(self).refresh_material_transferred_for_manufacturing()
|
||||
|
||||
def update_returned_qty(self):
|
||||
return RequiredItemsService(self).update_returned_qty()
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
# For license information, please see license.txt
|
||||
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
|
||||
@@ -16,47 +14,29 @@ def execute(filters=None):
|
||||
|
||||
|
||||
def get_data(filters, data):
|
||||
children_map = fetch_exploded_bom_items(filters.bom)
|
||||
build_exploded_rows(filters.bom, children_map, data)
|
||||
get_exploded_items(filters.bom, data)
|
||||
|
||||
|
||||
def fetch_exploded_bom_items(root_bom):
|
||||
"""Every BOM Item in the exploded tree of `root_bom`, grouped by its parent BOM, in one
|
||||
recursive CTE -- replaces a query-per-node walk with a single query. UNION keeps it cycle-safe
|
||||
and fetches each sub-BOM's items only once even when it is reused across the tree."""
|
||||
bom_item = frappe.qb.DocType("BOM Item")
|
||||
tree = frappe.qb.Table("exploded_bom")
|
||||
fields = [
|
||||
bom_item.parent,
|
||||
bom_item.qty,
|
||||
bom_item.bom_no,
|
||||
bom_item.item_code,
|
||||
bom_item.item_name,
|
||||
bom_item.description,
|
||||
bom_item.uom,
|
||||
bom_item.idx,
|
||||
bom_item.is_phantom_item,
|
||||
]
|
||||
seed = frappe.qb.from_(bom_item).select(*fields).where(bom_item.parent == root_bom)
|
||||
recursion = (
|
||||
frappe.qb.from_(bom_item)
|
||||
.join(tree)
|
||||
.on(bom_item.parent == tree.bom_no)
|
||||
.select(*fields)
|
||||
.where(tree.bom_no != "")
|
||||
def get_exploded_items(bom, data, indent=0, qty=1):
|
||||
exploded_items = frappe.get_all(
|
||||
"BOM Item",
|
||||
filters={"parent": bom},
|
||||
fields=[
|
||||
"qty",
|
||||
"bom_no",
|
||||
"qty",
|
||||
"item_code",
|
||||
"item_name",
|
||||
"description",
|
||||
"uom",
|
||||
"idx",
|
||||
"is_phantom_item",
|
||||
],
|
||||
order_by="idx ASC",
|
||||
)
|
||||
rows = (
|
||||
frappe.qb.with_(seed + recursion, "exploded_bom", recursive=True).from_(tree).select(tree.star)
|
||||
).run(as_dict=True)
|
||||
|
||||
children_map = defaultdict(list)
|
||||
for row in rows:
|
||||
children_map[row.parent].append(row)
|
||||
return children_map
|
||||
|
||||
|
||||
def build_exploded_rows(bom, children_map, data, indent=0, qty=1):
|
||||
for item in sorted(children_map.get(bom, []), key=lambda row: row.idx):
|
||||
for item in exploded_items:
|
||||
item["indent"] = indent
|
||||
data.append(
|
||||
{
|
||||
"item_code": item.item_code,
|
||||
@@ -71,7 +51,7 @@ def build_exploded_rows(bom, children_map, data, indent=0, qty=1):
|
||||
}
|
||||
)
|
||||
if item.bom_no:
|
||||
build_exploded_rows(item.bom_no, children_map, data, indent + 1, item.qty)
|
||||
get_exploded_items(item.bom_no, data, indent=indent + 1, qty=item.qty)
|
||||
|
||||
|
||||
def get_columns():
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
|
||||
from erpnext.manufacturing.report.bom_explorer.bom_explorer import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestBOMExplorer(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
# the tests look up `_Test FG Item`'s BOM, which comes from the BOM fixtures;
|
||||
# load them so the file also passes when run in isolation
|
||||
self.load_test_records("BOM")
|
||||
|
||||
def run_report(self, bom):
|
||||
filters = frappe._dict({"bom": bom})
|
||||
return execute(filters)[1]
|
||||
|
||||
def top_level_rows_by_item(self, data):
|
||||
# key only the direct (indent 0) components, so an item that also appears in a
|
||||
# deeper sub-assembly can't overwrite the top-level row we assert against
|
||||
return {row["item_code"]: row for row in data if row["indent"] == 0}
|
||||
|
||||
def test_default_bom_lists_components_at_top_level(self):
|
||||
bom = frappe.db.get_value("BOM", {"item": "_Test FG Item", "is_active": 1, "is_default": 1})
|
||||
self.assertIsNotNone(bom, "Default active BOM for _Test FG Item not found")
|
||||
|
||||
data = self.run_report(bom)
|
||||
rows_by_item = self.top_level_rows_by_item(data)
|
||||
|
||||
self.assertIn("_Test Item", rows_by_item)
|
||||
self.assertIn("_Test Item Home Desktop 100", rows_by_item)
|
||||
|
||||
for item_code in ("_Test Item", "_Test Item Home Desktop 100"):
|
||||
row = rows_by_item[item_code]
|
||||
self.assertEqual(row["indent"], 0)
|
||||
self.assertEqual(row["bom_level"], 0)
|
||||
|
||||
def test_qty_matches_bom_item_qty(self):
|
||||
bom = frappe.db.get_value("BOM", {"item": "_Test FG Item", "is_active": 1, "is_default": 1})
|
||||
data = self.run_report(bom)
|
||||
rows_by_item = self.top_level_rows_by_item(data)
|
||||
|
||||
for bom_item in frappe.get_all(
|
||||
"BOM Item", filters={"parent": bom}, fields=["item_code", "qty", "uom"]
|
||||
):
|
||||
row = rows_by_item[bom_item.item_code]
|
||||
self.assertEqual(row["qty"], bom_item.qty)
|
||||
self.assertEqual(row["uom"], bom_item.uom)
|
||||
|
||||
def test_nested_bom_shows_deeper_level(self):
|
||||
# Sub-assembly: "sub" is itself a BOM containing "leaf".
|
||||
parent_bom = create_nested_bom(
|
||||
{"parent": {"sub": {"leaf": {}}, "flat": {}}},
|
||||
prefix="_Test explorer ",
|
||||
)
|
||||
|
||||
data = self.run_report(parent_bom.name)
|
||||
rows_by_item = {row["item_code"]: row for row in data}
|
||||
|
||||
sub_item = "_Test explorer sub"
|
||||
leaf_item = "_Test explorer leaf"
|
||||
flat_item = "_Test explorer flat"
|
||||
|
||||
self.assertIn(sub_item, rows_by_item)
|
||||
self.assertIn(flat_item, rows_by_item)
|
||||
self.assertIn(leaf_item, rows_by_item)
|
||||
|
||||
# Direct components of the parent sit at level 0.
|
||||
self.assertEqual(rows_by_item[flat_item]["indent"], 0)
|
||||
self.assertEqual(rows_by_item[sub_item]["indent"], 0)
|
||||
|
||||
# The sub-assembly row carries its own BOM reference.
|
||||
self.assertTrue(rows_by_item[sub_item]["bom"])
|
||||
|
||||
# The leaf belongs to the sub-assembly, so it is exploded one level deeper.
|
||||
self.assertEqual(rows_by_item[leaf_item]["indent"], 1)
|
||||
self.assertEqual(rows_by_item[leaf_item]["bom_level"], 1)
|
||||
@@ -1,117 +0,0 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
import frappe
|
||||
|
||||
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
|
||||
from erpnext.manufacturing.report.bom_operations_time.bom_operations_time import execute
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
OPERATION = "_Test BOM Ops Time Operation"
|
||||
WORKSTATION = "_Test BOM Ops Time Workstation"
|
||||
OTHER_OPERATION = "_Test BOM Ops Time Operation 2"
|
||||
OTHER_WORKSTATION = "_Test BOM Ops Time Workstation 2"
|
||||
TIME_IN_MINS = 45
|
||||
|
||||
|
||||
class TestBOMOperationsTime(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
ensure_workstation_and_operation(WORKSTATION, OPERATION)
|
||||
self.rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100}).name
|
||||
self.fg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
self.bom = build_bom_with_operation(self.fg_item, self.rm_item, OPERATION, WORKSTATION)
|
||||
|
||||
def run_report(self, **filters):
|
||||
return execute(frappe._dict(filters))[1]
|
||||
|
||||
def bom_names(self, rows):
|
||||
return {row.name for row in rows}
|
||||
|
||||
def build_other_bom(self):
|
||||
"""A submitted BOM for a different item, built on a different workstation."""
|
||||
ensure_workstation_and_operation(OTHER_WORKSTATION, OTHER_OPERATION)
|
||||
other_fg = make_item(properties={"is_stock_item": 1}).name
|
||||
return build_bom_with_operation(other_fg, self.rm_item, OTHER_OPERATION, OTHER_WORKSTATION)
|
||||
|
||||
def test_operation_row_appears_with_expected_values(self):
|
||||
rows = self.run_report(bom_id=[self.bom.name])
|
||||
|
||||
self.assertEqual(len(rows), 1)
|
||||
row = rows[0]
|
||||
self.assertEqual(row.name, self.bom.name)
|
||||
self.assertEqual(row.item, self.fg_item)
|
||||
self.assertEqual(row.operation, OPERATION)
|
||||
self.assertEqual(row.workstation, WORKSTATION)
|
||||
self.assertEqual(row.time_in_mins, TIME_IN_MINS)
|
||||
|
||||
def test_item_code_filter_includes_matching_and_excludes_other(self):
|
||||
other_bom = self.build_other_bom()
|
||||
|
||||
# no bom_id here, so the item_code filter alone must scope the result
|
||||
names = self.bom_names(self.run_report(item_code=self.fg_item))
|
||||
self.assertIn(self.bom.name, names)
|
||||
self.assertNotIn(other_bom.name, names)
|
||||
|
||||
# reverse direction: filtering the other item drops our BOM
|
||||
other_names = self.bom_names(self.run_report(item_code=other_bom.item))
|
||||
self.assertIn(other_bom.name, other_names)
|
||||
self.assertNotIn(self.bom.name, other_names)
|
||||
|
||||
def test_workstation_filter_includes_matching_and_excludes_other(self):
|
||||
other_bom = self.build_other_bom()
|
||||
|
||||
# no bom_id here, so the workstation filter alone must scope the result
|
||||
names = self.bom_names(self.run_report(workstation=WORKSTATION))
|
||||
self.assertIn(self.bom.name, names)
|
||||
self.assertNotIn(other_bom.name, names)
|
||||
|
||||
# reverse direction: filtering the other workstation drops our BOM
|
||||
other_names = self.bom_names(self.run_report(workstation=OTHER_WORKSTATION))
|
||||
self.assertIn(other_bom.name, other_names)
|
||||
self.assertNotIn(self.bom.name, other_names)
|
||||
|
||||
def test_draft_bom_excluded(self):
|
||||
draft_bom = build_bom_with_operation(
|
||||
make_item(properties={"is_stock_item": 1}).name,
|
||||
self.rm_item,
|
||||
OPERATION,
|
||||
WORKSTATION,
|
||||
do_not_submit=True,
|
||||
)
|
||||
|
||||
rows = self.run_report(bom_id=[draft_bom.name])
|
||||
self.assertEqual(rows, [])
|
||||
|
||||
|
||||
def ensure_workstation_and_operation(workstation, operation):
|
||||
if not frappe.db.exists("Workstation", workstation):
|
||||
frappe.get_doc({"doctype": "Workstation", "workstation_name": workstation}).insert(
|
||||
ignore_permissions=True
|
||||
)
|
||||
|
||||
if not frappe.db.exists("Operation", operation):
|
||||
frappe.get_doc({"doctype": "Operation", "name": operation, "workstation": workstation}).insert(
|
||||
ignore_permissions=True
|
||||
)
|
||||
|
||||
|
||||
def build_bom_with_operation(fg_item, rm_item, operation, workstation, do_not_submit=False):
|
||||
bom = make_bom(
|
||||
item=fg_item,
|
||||
raw_materials=[rm_item],
|
||||
with_operations=1,
|
||||
do_not_save=True,
|
||||
)
|
||||
bom.append(
|
||||
"operations",
|
||||
{
|
||||
"operation": operation,
|
||||
"workstation": workstation,
|
||||
"time_in_mins": TIME_IN_MINS,
|
||||
"hour_rate": 100,
|
||||
},
|
||||
)
|
||||
bom.insert(ignore_permissions=True)
|
||||
if not do_not_submit:
|
||||
bom.submit()
|
||||
return bom
|
||||
@@ -1,110 +0,0 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.manufacturing.doctype.work_order.mapper import make_stock_entry
|
||||
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
|
||||
from erpnext.manufacturing.report.bom_variance_report.bom_variance_report import execute
|
||||
from erpnext.stock.doctype.stock_entry import test_stock_entry
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestBOMVarianceReport(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
self.production_item = "_Test FG Item"
|
||||
self.warehouse = "_Test Warehouse - _TC"
|
||||
self.bom_no = frappe.db.get_value(
|
||||
"BOM", {"item": self.production_item, "is_active": 1, "is_default": 1}
|
||||
)
|
||||
self.raw_materials = self.get_bom_raw_materials()
|
||||
|
||||
# allow over-production so a Work Order can produce more than planned; ERPNextTestSuite
|
||||
# rolls this back at tearDown, so no manual restore is needed
|
||||
frappe.db.set_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order", 100)
|
||||
|
||||
def get_bom_raw_materials(self):
|
||||
return {
|
||||
row.item_code: row.qty
|
||||
for row in frappe.get_all(
|
||||
"BOM Item", filters={"parent": self.bom_no}, fields=["item_code", "qty"]
|
||||
)
|
||||
}
|
||||
|
||||
def create_over_produced_work_order(self, ordered_qty=2, produced_qty=3):
|
||||
work_order = make_wo_order_test_record(
|
||||
item=self.production_item,
|
||||
qty=ordered_qty,
|
||||
source_warehouse=self.warehouse,
|
||||
skip_transfer=1,
|
||||
)
|
||||
|
||||
for item_code in self.raw_materials:
|
||||
test_stock_entry.make_stock_entry(
|
||||
item_code=item_code, target=self.warehouse, qty=100, basic_rate=100
|
||||
)
|
||||
|
||||
stock_entry = frappe.get_doc(make_stock_entry(work_order.name, "Manufacture", produced_qty))
|
||||
stock_entry.submit()
|
||||
|
||||
work_order.reload()
|
||||
self.assertEqual(work_order.produced_qty, produced_qty)
|
||||
return work_order
|
||||
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict({"bom_no": self.bom_no, **extra})
|
||||
return execute(filters)[1]
|
||||
|
||||
def test_over_produced_work_order_appears_with_planned_and_actual(self):
|
||||
work_order = self.create_over_produced_work_order(ordered_qty=2, produced_qty=3)
|
||||
|
||||
data = self.run_report(work_order=work_order.name)
|
||||
|
||||
summary_rows = [row for row in data if row.get("work_order") == work_order.name]
|
||||
self.assertEqual(len(summary_rows), 1)
|
||||
|
||||
summary = summary_rows[0]
|
||||
self.assertEqual(summary.get("production_item"), self.production_item)
|
||||
self.assertEqual(summary.get("bom_no"), self.bom_no)
|
||||
self.assertEqual(summary.get("qty"), 2)
|
||||
self.assertEqual(summary.get("produced_qty"), 3)
|
||||
|
||||
raw_material_rows = {
|
||||
row.get("raw_material_code"): row for row in data if row.get("raw_material_code")
|
||||
}
|
||||
for item_code, per_unit_qty in self.raw_materials.items():
|
||||
self.assertIn(item_code, raw_material_rows)
|
||||
# planned/required qty scales with the ordered qty on the work order
|
||||
self.assertEqual(raw_material_rows[item_code].get("required_qty"), per_unit_qty * 2)
|
||||
|
||||
def test_bom_no_filter_returns_over_produced_orders(self):
|
||||
work_order = self.create_over_produced_work_order(ordered_qty=2, produced_qty=3)
|
||||
|
||||
data = self.run_report()
|
||||
|
||||
matched = [row for row in data if row.get("work_order") == work_order.name]
|
||||
self.assertEqual(len(matched), 1)
|
||||
self.assertEqual(matched[0].get("bom_no"), self.bom_no)
|
||||
|
||||
def test_unstarted_work_order_is_excluded(self):
|
||||
work_order = make_wo_order_test_record(
|
||||
item=self.production_item,
|
||||
qty=2,
|
||||
source_warehouse=self.warehouse,
|
||||
skip_transfer=1,
|
||||
)
|
||||
|
||||
data = self.run_report(work_order=work_order.name)
|
||||
|
||||
matched = [row for row in data if row.get("work_order") == work_order.name]
|
||||
self.assertEqual(matched, [])
|
||||
|
||||
def test_work_order_produced_exactly_on_plan_is_excluded(self):
|
||||
# the canonical no-variance case: produced qty equals the planned qty, so the
|
||||
# report (which lists only over-produced orders) must not include it
|
||||
work_order = self.create_over_produced_work_order(ordered_qty=2, produced_qty=2)
|
||||
|
||||
data = self.run_report(work_order=work_order.name)
|
||||
|
||||
matched = [row for row in data if row.get("work_order") == work_order.name]
|
||||
self.assertEqual(matched, [])
|
||||
@@ -120,12 +120,6 @@ def get_columns(filters):
|
||||
"options": "Workstation",
|
||||
"width": "100",
|
||||
},
|
||||
{
|
||||
"label": _("Hour Rate"),
|
||||
"fieldtype": "Currency",
|
||||
"fieldname": "hour_rate",
|
||||
"width": "120",
|
||||
},
|
||||
{
|
||||
"label": _("Operating Cost"),
|
||||
"fieldtype": "Currency",
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils.data import add_to_date, now
|
||||
|
||||
from erpnext.manufacturing.doctype.job_card.mapper import make_corrective_job_card
|
||||
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
|
||||
from erpnext.manufacturing.report.cost_of_poor_quality_report.cost_of_poor_quality_report import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestCostOfPoorQualityReport(ERPNextTestSuite):
|
||||
"""A Job Card appears in this report only when it is submitted (docstatus == 1) and flagged
|
||||
as a corrective job card (is_corrective_job_card == 1). Such a card is created against a
|
||||
corrective Operation (is_corrective_operation == 1); without any corrective operation the
|
||||
report returns no rows at all."""
|
||||
|
||||
def setUp(self):
|
||||
self.load_test_records("BOM")
|
||||
|
||||
def create_corrective_job_card(self, hour_rate=100):
|
||||
"""Produce a submitted corrective Job Card and return (corrective_jc, operation, workstation)."""
|
||||
work_order = make_wo_order_test_record(item="_Test FG Item 2", qty=2)
|
||||
|
||||
job_card = frappe.get_last_doc("Job Card", {"work_order": work_order.name})
|
||||
job_card.append(
|
||||
"time_logs",
|
||||
{"from_time": now(), "to_time": add_to_date(now(), hours=1), "completed_qty": 2},
|
||||
)
|
||||
job_card.submit()
|
||||
|
||||
corrective_operation = frappe.get_doc(
|
||||
doctype="Operation", is_corrective_operation=1, name=frappe.generate_hash()
|
||||
).insert()
|
||||
|
||||
corrective_job_card = make_corrective_job_card(
|
||||
job_card.name, operation=corrective_operation.name, for_operation=job_card.operation
|
||||
)
|
||||
corrective_job_card.hour_rate = hour_rate
|
||||
corrective_job_card.insert()
|
||||
corrective_job_card.append(
|
||||
"time_logs",
|
||||
{
|
||||
"from_time": add_to_date(now(), hours=2),
|
||||
"to_time": add_to_date(now(), hours=2, minutes=30),
|
||||
"completed_qty": 2,
|
||||
},
|
||||
)
|
||||
corrective_job_card.submit()
|
||||
|
||||
return corrective_job_card, corrective_operation.name, corrective_job_card.workstation
|
||||
|
||||
def run_report(self, **filters):
|
||||
return execute(frappe._dict(filters))[1]
|
||||
|
||||
def test_corrective_job_card_is_listed_with_expected_fields(self):
|
||||
corrective_jc, operation, workstation = self.create_corrective_job_card(hour_rate=100)
|
||||
|
||||
rows = self.run_report(company="_Test Company")
|
||||
row = next((r for r in rows if r["name"] == corrective_jc.name), None)
|
||||
|
||||
self.assertIsNotNone(row, "Submitted corrective job card must appear in the report")
|
||||
self.assertEqual(row["work_order"], corrective_jc.work_order)
|
||||
self.assertEqual(row["operation"], operation)
|
||||
self.assertEqual(row["workstation"], workstation)
|
||||
self.assertEqual(row["item_code"], corrective_jc.production_item)
|
||||
self.assertEqual(row["hour_rate"], 100)
|
||||
self.assertEqual(row["total_time_in_mins"], corrective_jc.total_time_in_mins)
|
||||
# operating_cost = hour_rate * total_time_in_mins / 60 (SQL float -> compare approximately)
|
||||
self.assertAlmostEqual(row["operating_cost"], 100 * corrective_jc.total_time_in_mins / 60.0, places=6)
|
||||
|
||||
def test_non_corrective_job_card_is_excluded(self):
|
||||
corrective_jc, _operation, _workstation = self.create_corrective_job_card()
|
||||
|
||||
# The regular (non-corrective) job card the corrective one was raised against must not appear.
|
||||
regular_jc = corrective_jc.for_job_card
|
||||
rows = self.run_report(company="_Test Company")
|
||||
self.assertNotIn(regular_jc, {r["name"] for r in rows})
|
||||
|
||||
def test_operation_filter_scopes_rows(self):
|
||||
corrective_jc, operation, _workstation = self.create_corrective_job_card()
|
||||
|
||||
matching = self.run_report(company="_Test Company", operation=operation)
|
||||
self.assertIn(corrective_jc.name, {r["name"] for r in matching})
|
||||
|
||||
other_operation = frappe.get_doc(
|
||||
doctype="Operation", is_corrective_operation=1, name=frappe.generate_hash()
|
||||
).insert()
|
||||
filtered = self.run_report(company="_Test Company", operation=other_operation.name)
|
||||
self.assertNotIn(corrective_jc.name, {r["name"] for r in filtered})
|
||||
|
||||
def test_workstation_filter_scopes_rows(self):
|
||||
corrective_jc, _operation, workstation = self.create_corrective_job_card()
|
||||
|
||||
matching = self.run_report(company="_Test Company", workstation=workstation)
|
||||
self.assertIn(corrective_jc.name, {r["name"] for r in matching})
|
||||
|
||||
filtered = self.run_report(company="_Test Company", workstation="__non_existent_ws__")
|
||||
self.assertNotIn(corrective_jc.name, {r["name"] for r in filtered})
|
||||
|
||||
def test_work_order_and_name_filters_scope_rows(self):
|
||||
corrective_jc, _operation, _workstation = self.create_corrective_job_card()
|
||||
|
||||
by_work_order = self.run_report(company="_Test Company", work_order=corrective_jc.work_order)
|
||||
self.assertIn(corrective_jc.name, {r["name"] for r in by_work_order})
|
||||
|
||||
by_name = self.run_report(company="_Test Company", name=corrective_jc.name)
|
||||
self.assertEqual({r["name"] for r in by_name}, {corrective_jc.name})
|
||||
|
||||
def test_date_filter_scopes_rows(self):
|
||||
corrective_jc, _operation, _workstation = self.create_corrective_job_card()
|
||||
|
||||
# Time logs sit ~2 hours from now; a window covering today includes the card.
|
||||
within = self.run_report(
|
||||
company="_Test Company",
|
||||
work_order=corrective_jc.work_order,
|
||||
from_date=add_to_date(now(), days=-1),
|
||||
to_date=add_to_date(now(), days=1),
|
||||
)
|
||||
self.assertIn(corrective_jc.name, {r["name"] for r in within})
|
||||
|
||||
# A future-only window excludes it, proving the Job Card Time Log join filters by time.
|
||||
outside = self.run_report(
|
||||
company="_Test Company",
|
||||
work_order=corrective_jc.work_order,
|
||||
from_date=add_to_date(now(), days=5),
|
||||
to_date=add_to_date(now(), days=6),
|
||||
)
|
||||
self.assertNotIn(corrective_jc.name, {r["name"] for r in outside})
|
||||
@@ -1,92 +0,0 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_days, get_datetime, today
|
||||
|
||||
from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation
|
||||
from erpnext.manufacturing.report.downtime_analysis.downtime_analysis import execute
|
||||
from erpnext.setup.doctype.employee.test_employee import make_employee
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestDowntimeAnalysis(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
self.workstation = make_workstation(workstation="_Test Downtime Workstation").name
|
||||
self.other_workstation = make_workstation(workstation="_Test Downtime Workstation 2").name
|
||||
self.operator = make_employee("test_downtime_operator@example.com", company="_Test Company")
|
||||
|
||||
# from_time / to_time are two hours apart -> downtime of 120 minutes (2 hours).
|
||||
self.from_time = get_datetime(f"{today()} 09:00:00")
|
||||
self.to_time = get_datetime(f"{today()} 11:00:00")
|
||||
self.entry = self.make_downtime_entry(self.workstation)
|
||||
|
||||
def make_downtime_entry(self, workstation, **extra):
|
||||
values = {
|
||||
"doctype": "Downtime Entry",
|
||||
"workstation": workstation,
|
||||
"operator": self.operator,
|
||||
"from_time": self.from_time,
|
||||
"to_time": self.to_time,
|
||||
"stop_reason": "Machine malfunction",
|
||||
}
|
||||
values.update(extra)
|
||||
return frappe.get_doc(values).insert()
|
||||
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"from_date": add_days(today(), -1),
|
||||
"to_date": add_days(today(), 1),
|
||||
}
|
||||
)
|
||||
filters.update(extra)
|
||||
return execute(filters)[1]
|
||||
|
||||
def row_for_entry(self, rows, name):
|
||||
return next((row for row in rows if row.get("name") == name), None)
|
||||
|
||||
def test_downtime_is_computed_in_hours(self):
|
||||
# validate() stores downtime in minutes; the report converts it to hours.
|
||||
self.assertEqual(self.entry.downtime, 120)
|
||||
|
||||
row = self.row_for_entry(self.run_report(), self.entry.name)
|
||||
self.assertIsNotNone(row, "Downtime Entry not present in report output")
|
||||
self.assertEqual(row.get("workstation"), self.workstation)
|
||||
self.assertEqual(row.get("operator"), self.operator)
|
||||
self.assertEqual(row.get("stop_reason"), "Machine malfunction")
|
||||
self.assertEqual(row.get("downtime"), 2.0)
|
||||
|
||||
def test_workstation_filter_scopes_rows(self):
|
||||
other = self.make_downtime_entry(self.other_workstation)
|
||||
|
||||
rows = self.run_report(workstation=self.workstation)
|
||||
names = {row.get("name") for row in rows}
|
||||
self.assertIn(self.entry.name, names)
|
||||
self.assertNotIn(other.name, names)
|
||||
self.assertTrue(all(row.get("workstation") == self.workstation for row in rows))
|
||||
|
||||
def test_date_range_excludes_out_of_window_entries(self):
|
||||
# The report filters from_time >= from_date and to_time <= to_date; a window
|
||||
# ending before the entry's from_time must exclude it.
|
||||
rows = self.run_report(from_date=add_days(today(), -10), to_date=add_days(today(), -5))
|
||||
self.assertIsNone(self.row_for_entry(rows, self.entry.name))
|
||||
|
||||
def test_chart_aggregates_downtime_per_workstation(self):
|
||||
self.make_downtime_entry(self.workstation)
|
||||
|
||||
chart = execute(
|
||||
frappe._dict(
|
||||
{
|
||||
"from_date": add_days(today(), -1),
|
||||
"to_date": add_days(today(), 1),
|
||||
"workstation": self.workstation,
|
||||
}
|
||||
)
|
||||
)[3]
|
||||
|
||||
self.assertIn(self.workstation, chart["data"]["labels"])
|
||||
index = chart["data"]["labels"].index(self.workstation)
|
||||
# Two entries of 2 hours each for this workstation -> 4 hours aggregated.
|
||||
self.assertEqual(chart["data"]["datasets"][0]["values"][index], 4.0)
|
||||
@@ -1,101 +0,0 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import flt
|
||||
|
||||
from erpnext.manufacturing.report.exponential_smoothing_forecasting.exponential_smoothing_forecasting import (
|
||||
execute,
|
||||
)
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
FROM_DATE = "2026-06-01"
|
||||
TO_DATE = "2026-08-31"
|
||||
SMOOTHING_CONSTANT = 0.5
|
||||
|
||||
|
||||
class TestExponentialSmoothingForecasting(ERPNextTestSuite):
|
||||
"""Drive real submitted Sales Orders and assert the report buckets the ordered
|
||||
quantities into the correct historical periods and produces a forecast."""
|
||||
|
||||
def setUp(self):
|
||||
# The forecast query has no lower date bound, so it would pick up any committed
|
||||
# Sales Order for the item. A uniquely-named item keeps the buckets scoped to
|
||||
# just this test's orders.
|
||||
self.item = make_item(properties={"is_stock_item": 1}).name
|
||||
|
||||
def test_monthly_qty_forecast_from_sales_orders(self):
|
||||
# Historical demand: distinct calendar months strictly before FROM_DATE.
|
||||
# Monthly period keys are derived from the period's last day (e.g. "mar_2026").
|
||||
history = {"mar_2026": 7, "apr_2026": 4, "may_2026": 9}
|
||||
self.create_sales_orders(
|
||||
{
|
||||
"2026-03-15": history["mar_2026"],
|
||||
"2026-04-15": history["apr_2026"],
|
||||
"2026-05-15": history["may_2026"],
|
||||
}
|
||||
)
|
||||
|
||||
columns, row = self.run_report()
|
||||
fields = {col["fieldname"] for col in columns}
|
||||
|
||||
# For Monthly periodicity only future periods are exposed as columns, each as a
|
||||
# forecast_ field. Historical demand lives in the row data (keyed by month) but is
|
||||
# not surfaced as its own column.
|
||||
self.assertIn("forecast_jun_2026", fields, "expected future forecast column")
|
||||
self.assertNotIn("jun_2026", fields, "future period must not expose raw demand column")
|
||||
self.assertNotIn("mar_2026", fields, "historical month is not a Monthly report column")
|
||||
|
||||
# Historical buckets must exactly reflect the ordered quantities.
|
||||
for key, qty in history.items():
|
||||
self.assertEqual(flt(row.get(key)), flt(qty), f"bucket {key} mismatch")
|
||||
|
||||
# The forecast seeds at the average of the non-zero historical months and then
|
||||
# smooths through them in order: F = F + a*(actual - F). Asserting the exact
|
||||
# analytical value pins the smoothing formula (Jun 2026 works out to ~7.2083).
|
||||
expected_avg = sum(history.values()) / len(history)
|
||||
self.assertAlmostEqual(flt(row.get("avg")), expected_avg, places=6)
|
||||
|
||||
forecast = expected_avg
|
||||
for month in ("mar_2026", "apr_2026", "may_2026"):
|
||||
forecast = forecast + SMOOTHING_CONSTANT * (history[month] - forecast)
|
||||
self.assertAlmostEqual(flt(row.get("forecast_jun_2026")), forecast, places=6)
|
||||
|
||||
def test_ignores_documents_outside_range_and_other_docstatus(self):
|
||||
self.create_sales_orders({"2026-05-10": 6})
|
||||
# A draft SO and a future-dated SO must not contribute to historical demand.
|
||||
make_sales_order(item_code=self.item, qty=100, transaction_date="2026-05-20", do_not_submit=True)
|
||||
make_sales_order(item_code=self.item, qty=100, transaction_date=FROM_DATE)
|
||||
|
||||
_columns, row = self.run_report()
|
||||
self.assertEqual(flt(row.get("may_2026")), 6.0)
|
||||
|
||||
def create_sales_orders(self, date_to_qty):
|
||||
for transaction_date, qty in date_to_qty.items():
|
||||
make_sales_order(item_code=self.item, qty=qty, transaction_date=transaction_date)
|
||||
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"based_on_document": "Sales Order",
|
||||
"based_on_field": "Qty",
|
||||
"no_of_years": 3,
|
||||
"periodicity": "Monthly",
|
||||
"from_date": FROM_DATE,
|
||||
"to_date": TO_DATE,
|
||||
"smoothing_constant": SMOOTHING_CONSTANT,
|
||||
"item_code": self.item,
|
||||
}
|
||||
)
|
||||
filters.update(extra)
|
||||
|
||||
columns, data = execute(filters)[:2]
|
||||
item_row = next(
|
||||
(r for r in data if r.get("item_code") == self.item),
|
||||
None,
|
||||
)
|
||||
self.assertIsNotNone(item_row, f"{self.item} row missing from report output")
|
||||
return columns, item_row
|
||||
@@ -1,87 +0,0 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_days, today
|
||||
|
||||
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
|
||||
from erpnext.manufacturing.report.job_card_summary.job_card_summary import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestJobCardSummary(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
# `_Test FG Item 2` has a default active BOM with operations, so submitting a
|
||||
# Work Order for it auto-creates Job Cards (one per operation).
|
||||
self.work_order = make_wo_order_test_record(item="_Test FG Item 2", qty=2)
|
||||
self.job_cards = frappe.get_all(
|
||||
"Job Card",
|
||||
filters={"work_order": self.work_order.name},
|
||||
fields=["name", "operation", "workstation", "production_item", "status"],
|
||||
)
|
||||
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"from_date": add_days(today(), -1),
|
||||
"to_date": add_days(today(), 1),
|
||||
}
|
||||
)
|
||||
filters.update(extra)
|
||||
return execute(filters)[1]
|
||||
|
||||
def rows_for_work_order(self, rows):
|
||||
return [row for row in rows if row.get("work_order") == self.work_order.name]
|
||||
|
||||
def test_job_cards_are_listed(self):
|
||||
self.assertTrue(self.job_cards, "Work Order did not produce any Job Cards")
|
||||
|
||||
rows = self.rows_for_work_order(self.run_report())
|
||||
self.assertEqual(len(rows), len(self.job_cards))
|
||||
|
||||
reported_names = {row.get("name") for row in rows}
|
||||
self.assertEqual(reported_names, {jc.name for jc in self.job_cards})
|
||||
|
||||
# Fresh (unsubmitted) job cards are reported as Open, and each row carries the
|
||||
# operation / workstation / production item pulled from the Job Card.
|
||||
for jc in self.job_cards:
|
||||
row = next(row for row in rows if row.get("name") == jc.name)
|
||||
self.assertEqual(row.get("status"), "Open")
|
||||
self.assertEqual(row.get("operation"), jc.operation)
|
||||
self.assertEqual(row.get("workstation"), jc.workstation)
|
||||
self.assertEqual(row.get("production_item"), jc.production_item)
|
||||
|
||||
def test_operation_filter_scopes_rows(self):
|
||||
self.assertTrue(self.job_cards, "Work Order did not produce any Job Cards")
|
||||
operation = self.job_cards[0].operation
|
||||
matching = {jc.name for jc in self.job_cards if jc.operation == operation}
|
||||
|
||||
rows = self.rows_for_work_order(self.run_report(operation=operation))
|
||||
self.assertEqual({row.get("name") for row in rows}, matching)
|
||||
|
||||
def test_status_filter(self):
|
||||
self.assertTrue(self.job_cards, "Work Order did not produce any Job Cards")
|
||||
|
||||
# The status filter matches the Job Card's *stored* status, so derive the
|
||||
# expected set from that rather than assuming fresh cards are literally "Open".
|
||||
stored_status = self.job_cards[0].status
|
||||
expected = {jc.name for jc in self.job_cards if jc.status == stored_status}
|
||||
|
||||
rows = self.rows_for_work_order(self.run_report(status=stored_status))
|
||||
self.assertEqual({row.get("name") for row in rows}, expected)
|
||||
# any non-completed card is displayed as "Open" regardless of its stored status
|
||||
for row in rows:
|
||||
self.assertEqual(row.get("status"), "Open")
|
||||
|
||||
# None of the freshly created job cards are Completed yet.
|
||||
completed_rows = self.rows_for_work_order(self.run_report(status="Completed"))
|
||||
self.assertEqual(completed_rows, [])
|
||||
|
||||
def test_date_filter_excludes_out_of_range(self):
|
||||
# Job Card posting_date defaults to today; a past-only window should exclude them.
|
||||
rows = self.rows_for_work_order(
|
||||
self.run_report(from_date=add_days(today(), -10), to_date=add_days(today(), -5))
|
||||
)
|
||||
self.assertEqual(rows, [])
|
||||
@@ -50,11 +50,11 @@ def get_data(filters: Filters) -> Data:
|
||||
.groupby(se.work_order)
|
||||
)
|
||||
|
||||
if filters.get("item"):
|
||||
query = query.where(wo.production_item == filters.item)
|
||||
if "item" in filters:
|
||||
query.where(wo.production_item == filters.item)
|
||||
|
||||
if filters.get("work_order"):
|
||||
query = query.where(wo.name == filters.work_order)
|
||||
if "work_order" in filters:
|
||||
query.where(wo.name == filters.work_order)
|
||||
|
||||
data = query.run(as_dict=True)
|
||||
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import nowdate
|
||||
|
||||
from erpnext.manufacturing.doctype.work_order.mapper import make_stock_entry
|
||||
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
|
||||
from erpnext.manufacturing.report.process_loss_report.process_loss_report import execute
|
||||
from erpnext.stock.doctype.stock_entry import test_stock_entry
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestProcessLossReport(ERPNextTestSuite):
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"from_date": nowdate(),
|
||||
"to_date": nowdate(),
|
||||
}
|
||||
)
|
||||
filters.update(extra)
|
||||
return execute(filters)[1]
|
||||
|
||||
def find_row(self, data, work_order):
|
||||
for row in data:
|
||||
if row.get("name") == work_order:
|
||||
return row
|
||||
return None
|
||||
|
||||
def make_manufactured_work_order(self, planned_qty, produced_qty):
|
||||
"""Create a submitted WO and manufacture `produced_qty` of `planned_qty`.
|
||||
|
||||
The difference is booked as process loss on the Manufacture stock entry,
|
||||
which propagates to the work order's `process_loss_qty`.
|
||||
"""
|
||||
wo_order = make_wo_order_test_record(production_item="_Test FG Item", qty=planned_qty)
|
||||
|
||||
test_stock_entry.make_stock_entry(
|
||||
item_code="_Test Item", target="Stores - _TC", qty=100, basic_rate=100
|
||||
)
|
||||
test_stock_entry.make_stock_entry(
|
||||
item_code="_Test Item Home Desktop 100", target="Stores - _TC", qty=100, basic_rate=100
|
||||
)
|
||||
|
||||
transfer = frappe.get_doc(
|
||||
make_stock_entry(wo_order.name, "Material Transfer for Manufacture", planned_qty)
|
||||
)
|
||||
for d in transfer.get("items"):
|
||||
d.s_warehouse = "Stores - _TC"
|
||||
transfer.insert()
|
||||
transfer.submit()
|
||||
|
||||
manufacture = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", planned_qty))
|
||||
# Reduce the finished good qty below fg_completed_qty so the difference is
|
||||
# recorded as process loss.
|
||||
process_loss_qty = planned_qty - produced_qty
|
||||
if process_loss_qty:
|
||||
for d in manufacture.get("items"):
|
||||
if d.is_finished_item:
|
||||
d.qty = produced_qty
|
||||
d.transfer_qty = produced_qty * (d.conversion_factor or 1)
|
||||
manufacture.insert()
|
||||
manufacture.submit()
|
||||
|
||||
wo_order.reload()
|
||||
return wo_order
|
||||
|
||||
def test_work_order_with_process_loss_is_listed(self):
|
||||
wo_order = self.make_manufactured_work_order(planned_qty=5, produced_qty=4)
|
||||
|
||||
self.assertEqual(wo_order.process_loss_qty, 1)
|
||||
self.assertEqual(wo_order.produced_qty, 4)
|
||||
|
||||
data = self.run_report()
|
||||
row = self.find_row(data, wo_order.name)
|
||||
|
||||
self.assertIsNotNone(row, "Work order with process loss should appear in the report")
|
||||
self.assertEqual(row.production_item, "_Test FG Item")
|
||||
self.assertEqual(row.qty_to_manufacture, 5)
|
||||
self.assertEqual(row.produced_qty, 4)
|
||||
self.assertEqual(row.process_loss_qty, 1)
|
||||
|
||||
# total_pl_value = process_loss_qty * (total_fg_value / qty_to_manufacture)
|
||||
expected_pl_value = row.process_loss_qty * (row.total_fg_value / row.qty_to_manufacture)
|
||||
self.assertAlmostEqual(row.total_pl_value, expected_pl_value)
|
||||
self.assertGreater(row.total_pl_value, 0)
|
||||
|
||||
def test_work_order_without_process_loss_is_not_listed(self):
|
||||
wo_order = self.make_manufactured_work_order(planned_qty=5, produced_qty=5)
|
||||
|
||||
self.assertEqual(wo_order.process_loss_qty, 0)
|
||||
self.assertEqual(wo_order.produced_qty, 5)
|
||||
|
||||
data = self.run_report()
|
||||
self.assertIsNone(
|
||||
self.find_row(data, wo_order.name),
|
||||
"Work order that produced the full planned qty should not appear (no loss)",
|
||||
)
|
||||
|
||||
def test_item_filter_scopes_rows(self):
|
||||
wo_order = self.make_manufactured_work_order(planned_qty=5, produced_qty=4)
|
||||
|
||||
# a matching production item includes the row, a non-matching one excludes it
|
||||
self.assertIsNotNone(self.find_row(self.run_report(item="_Test FG Item"), wo_order.name))
|
||||
self.assertIsNone(self.find_row(self.run_report(item="_Test FG Item 2"), wo_order.name))
|
||||
|
||||
def test_work_order_filter_scopes_rows(self):
|
||||
wo_order = self.make_manufactured_work_order(planned_qty=5, produced_qty=4)
|
||||
|
||||
# the matching work order is included, a different work order name is excluded
|
||||
self.assertIsNotNone(self.find_row(self.run_report(work_order=wo_order.name), wo_order.name))
|
||||
self.assertIsNone(self.find_row(self.run_report(work_order=f"{wo_order.name}-XX"), wo_order.name))
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _, scrub
|
||||
from frappe.utils import get_datetime, getdate, today
|
||||
from frappe.utils import getdate, today
|
||||
|
||||
from erpnext.stock.report.stock_analytics.stock_analytics import (
|
||||
get_period,
|
||||
@@ -31,9 +31,7 @@ def get_columns(period_columns):
|
||||
|
||||
def get_work_orders(filters):
|
||||
from_date = filters.get("from_date")
|
||||
# `creation` and `actual_end_date` are datetime columns, so a bare date upper
|
||||
# bound would coerce to midnight and drop records created later on the last day.
|
||||
to_date = get_datetime(filters.get("to_date")).replace(hour=23, minute=59, second=59)
|
||||
to_date = filters.get("to_date")
|
||||
|
||||
WorkOrder = frappe.qb.DocType("Work Order")
|
||||
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import get_first_day, get_last_day, today
|
||||
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestProductionAnalytics(ERPNextTestSuite):
|
||||
def run_report(self, **extra):
|
||||
from erpnext.manufacturing.report.production_analytics.production_analytics import execute
|
||||
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"from_date": get_first_day(today()),
|
||||
"to_date": get_last_day(today()),
|
||||
"range": "Monthly",
|
||||
}
|
||||
)
|
||||
filters.update(extra)
|
||||
columns, data, _msg, _chart = execute(filters)
|
||||
return columns, data
|
||||
|
||||
def get_period_count(self, columns, data, status, period_label):
|
||||
"""Return the count for a status row under the period column resolved by label."""
|
||||
period_fieldname = next(col["fieldname"] for col in columns if col.get("label") == period_label)
|
||||
# the report stores the translated status label, so translate before matching
|
||||
row = next(row for row in data if row["status"] == _(status))
|
||||
return row[period_fieldname]
|
||||
|
||||
def test_submitted_work_order_increments_status_count(self):
|
||||
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
|
||||
|
||||
# pin the reporting window once so both runs use the same period even if the
|
||||
# test happens to straddle a month boundary
|
||||
from_date, to_date = get_first_day(today()), get_last_day(today())
|
||||
|
||||
# The current month is the period a newly created Work Order falls into (bucketed by creation date).
|
||||
cols_before, data_before = self.run_report(from_date=from_date, to_date=to_date)
|
||||
period_label = cols_before[-1]["label"]
|
||||
before = self.get_period_count(cols_before, data_before, "Not Started", period_label)
|
||||
|
||||
wo = make_wo_order_test_record(production_item="_Test FG Item", qty=10, company="_Test Company")
|
||||
self.assertEqual(wo.docstatus, 1)
|
||||
# A freshly submitted Work Order with no material transfer has status "Not Started".
|
||||
self.assertEqual(wo.status, "Not Started")
|
||||
|
||||
cols_after, data_after = self.run_report(from_date=from_date, to_date=to_date)
|
||||
after = self.get_period_count(cols_after, data_after, "Not Started", period_label)
|
||||
|
||||
self.assertEqual(after, before + 1)
|
||||
|
||||
def test_report_shape(self):
|
||||
columns, data = self.run_report()
|
||||
|
||||
# First column is the Status column, followed by one column per period.
|
||||
self.assertEqual(columns[0]["fieldname"], "status")
|
||||
self.assertGreaterEqual(len(columns), 2)
|
||||
|
||||
# One row per known Work Order status.
|
||||
statuses = {row["status"] for row in data}
|
||||
for status in ("Not Started", "Overdue", "Pending", "Completed", "Closed", "Stopped"):
|
||||
self.assertIn(_(status), statuses)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user