Compare commits

..

47 Commits

Author SHA1 Message Date
Mihir Kandoi
4a2c60e098 feat(selling): show the latest quotation revision as latest (#59485) 2026-09-26 13:41:48 +00:00
Mihir Kandoi
1fe6c2e92b fix(stock): compare the invoice rate against the receipt's net amount (#59476) 2026-09-26 11:38:55 +00:00
Mihir Kandoi
8a200aa92c chore: remove stale duplicates of five standard records (#59473) 2026-09-26 11:10:17 +00:00
Mihir Kandoi
6b30cef7ac refactor: move doc_events hooks into their controllers (#59472) 2026-09-26 08:53:04 +00:00
Mihir Kandoi
c62e06bd4c feat(stock): hide records linked to company-restricted masters (#59462) 2026-09-26 03:47:00 +00:00
Mihir Kandoi
d125b84968 fix(stock): leave the batch out of the sample limit alert when there is none (#59466) 2026-09-26 03:21:45 +00:00
Mihir Kandoi
db292cd61c fix: scope reports to the user's permitted companies (#59464)
* fix: scope reports to the user's permitted companies

These reports read transactions, BOMs, ledgers or bins from every
company, either because they have no company filter or because an
empty optional filter drops the condition. A user with a Company user
permission therefore saw other companies' documents, and the masters
used in them. The queries now keep to the user's permitted companies
through get_allowed_companies_condition; users without a Company user
permission see the same rows as before.

* test: reports keep to the user's permitted companies

* fix: scope stock and rate figures in master reports to permitted companies

The master-listing reports now hide restricted masters, but their stock,
valuation, purchase, BOM and consumption figures still summed bins and
transactions from every company. Bins are now kept to warehouses of the
user's permitted companies through get_allowed_warehouses_condition,
and the rate and consumption queries through
get_allowed_companies_condition.

* test: Item Balance keeps to the user's permitted warehouses

* fix: hide other companies' source documents in batch traceability

Serial No and Batch Traceability starts from the batches or serial
numbers the user types in, and showed each one's source document even
when that document belongs to a company the user is not permitted to.
Those starting rows are now dropped for users with a Company user
permission.

* test: batch traceability hides other companies' source documents
2026-09-26 08:45:35 +05:30
Mihir Kandoi
1eae08bd22 fix: hide company-restricted masters in reports (#59463)
* fix: hide company-restricted masters in master-listing reports

Script reports query tables directly, so the Item, Customer and
Supplier permission hooks never ran and a user with a Company user
permission saw masters restricted to other companies. Reports that
list masters now apply the same restriction through
get_allowed_masters_condition, which the Item Price hook reuses.

* test: master-listing reports hide company-restricted masters

* fix: hide company-restricted masters in query reports

Item Balance (Simple), Items To Be Requested and Customers Without Any
Sales Transactions kept their SQL in the report JSON, where the company
restriction cannot be applied. They are now script reports with the
same columns and rows, filtered through get_allowed_masters_condition.

* test: converted query reports hide company-restricted masters

* fix: hide company-restricted bundles in Available Stock for Packing Items

* fix: apply company restriction to chosen and child batches in Batch Split Tree

A batch chosen in the Batch filter skipped the restriction, and child
batches from a split carry the finished good, which can be a different,
restricted item. The chosen batch now goes through the same condition,
and restricted batches are dropped from the tree with their children.

* test: Batch Split Tree hides a chosen batch of a restricted item
2026-09-26 08:45:35 +05:30
Mihir Kandoi
e78b1e4c2a fix(manufacturing): open a blanket order without marking it unsaved (#59461) 2026-09-25 18:01:25 +00:00
Shllokkk
65b8a78aa3 Merge pull request #59426 from Shllokkk/pos-closing-default-closing-amount
fix: default closing amount for payment modes not in POS opening entry
2026-09-25 21:05:41 +05:30
Mihir Kandoi
9a22514c93 fix(manufacturing): release plan reservations when the plan completes (#59449) 2026-09-25 14:13:48 +00:00
Shllokkk
8e378d923d Merge pull request #59448 from Shllokkk/bank-statement-import-validate-import-file
fix: replace removed validate_import_file call in Bank Statement Import
2026-09-25 19:04:32 +05:30
Mihir Kandoi
213284cfa2 fix(manufacturing): keep a closed production plan closed until it is reopened (#59454) 2026-09-25 13:05:57 +00:00
Shllokkk
c8511e01eb Merge pull request #59447 from Shllokkk/bank-statement-import-company-account-filter
fix: show only company bank accounts in Bank Statement Import
2026-09-25 18:03:29 +05:30
Mihir Kandoi
fd8e6230f3 fix(manufacturing): explode phantom BOM rows by their stock qty (#59445) 2026-09-25 18:02:33 +05:30
Shllokkk
8cdff3bfba fix: replace removed validate_import_file call in Bank Statement Import 2026-09-25 17:55:46 +05:30
Shllokkk
48720781ce fix: show only company bank accounts in Bank Statement Import 2026-09-25 17:54:32 +05:30
Pandiyan P
a710111db9 fix(manufacturing): offset plan reservations across warehouses (#59429)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-09-25 12:22:03 +00:00
Mihir Kandoi
1d1562a68e test(manufacturing): secondary items across split job cards (#59436) 2026-09-25 09:24:04 +00:00
Mihir Kandoi
12d342cc62 fix: use Text Editor for fields that store HTML (#59432) 2026-09-25 09:11:22 +00:00
Sudharsanan Ashok
75efa2d1cf fix(accounts): filter purchase register by supplier group from supplier master (#59430) 2026-09-25 14:36:02 +05:30
Mihir Kandoi
f6bd6733c8 fix: use Text Editor for manufacturing descriptions copied from items (#59434) 2026-09-25 09:02:15 +00:00
Mihir Kandoi
dbada3f461 fix: count only matched batches on work order reservations (#59424) 2026-09-25 08:47:42 +00:00
Nishka Gosalia
f45b22b79c Merge pull request #59228 from nishkagosalia/work-order-ux-cleanup
fix(UX): Work order UX cleanup
2026-09-25 13:25:51 +05:30
Mihir Kandoi
4dd9f3bf67 fix: release the work order reservation before previewing a stock entry (#59425) 2026-09-25 13:23:34 +05:30
Mihir Kandoi
63ae699e41 fix(selling): validate Proforma Invoice lines in the controller (#59402)
* fix(selling): validate Proforma Invoice lines in the controller

The Sales Order check and the line rate and amount lived only in
make_proforma_invoice, so a proforma inserted through the REST API could
be submitted against a cancelled Sales Order, with lines from another
order or a stale amount. validate() now enforces them for every path.

* test(selling): Proforma Invoice lines validated on direct insert
2026-09-25 13:18:39 +05:30
Mihir Kandoi
3e5da36b59 fix(selling): Proforma Invoice fixes (#59401)
* fix(selling): block amending a cancelled Proforma Invoice

A proforma is only created from its Sales Order, but a cancelled one
showed Amend to Administrator, and the amended copy could be saved
outside that path.

* test(selling): reject an amended Proforma Invoice

* fix(selling): keep the Proforma tab on a cancelled Sales Order

Cancelling a Sales Order first needs its proformas cancelled, and the
tab then vanished, hiding the cancelled proformas that the list keeps
for audit. Show the list read-only instead.

* fix(selling): tidy the Proforma Invoice print

Print the customer address under the customer name, and drop the
Against Sales Order row and the not-a-demand-for-payment footer.

* feat(selling): editable item description on Proforma Invoice

The create dialog now shows each line's Sales Order description for
editing. The proforma line stores it, falling back to the Sales Order
description, and the print shows it under the item name.

* test(selling): edited and default Proforma Invoice line description

* fix(selling): block deleting a proformed Sales Order row via Update Items

Deleting the row left the issued proforma pointing at a Sales Order
line that no longer exists.

* test(selling): Update Items keeps a row with an issued proforma

* fix(selling): decode entities before comparing proforma description

strip_html kept entities, so an item named R&D with the description
R&amp;D printed its name twice. Jinja's striptags also unescapes.

* fix(selling): check issued proformas for all deleted rows in one query

The check ran one query per deleted Sales Order row inside the
validation loop.

* fix(selling): default the proforma PDF to the Proforma Invoice format

With no format picked in the dialog and none set in Selling Settings,
the PDF used the Sales Order's default print format, so it was headed
Sales Order and lacked the proforma number and date.
2026-09-25 13:18:38 +05:30
Mihir Kandoi
4f1f676b24 feat(selling): revise quotations without cancelling (#59378)
* feat(selling): create quotation revisions without cancelling

Create > Revision copies a submitted quotation into a draft named after the original with an -R<n> suffix. The next index comes from the existing revisions, as BOM does. The original lists its revisions under Connections.

* test(selling): quotation revisions

* feat(selling): mark quotations inactive

An Is Active flag, like BOM's, lets users retire an older quotation version. Inactive quotations cannot be mapped to a Sales Order or Sales Invoice, show a red Inactive indicator, and no longer count as an active offer for their Opportunity or Lead.

* test(selling): inactive quotations

* feat(selling): block setting inactive quotations as lost

Setting a quotation as Lost also marks its Opportunity as Lost, which is wrong while a newer version of the quotation is still open.

* feat(selling): deactivate older quotation versions on revision submit

Submitting an active revision clears Is Active on the other submitted versions of the same quotation, so only the latest one can be ordered. This mirrors how a new default BOM takes the default from the item's other BOMs.

* test(selling): older quotation versions deactivate on revision submit

* feat(selling): set all quotation versions as lost together

Setting a quotation as Lost now also sets its other submitted versions as Lost, except ones already ordered. Lost Quotations counts only the active version, so a revised deal is counted once, and the list shows Lost ahead of Inactive.

* test(selling): lost quotation versions

* feat(selling): revise only the active quotation version

Create > Revision is hidden on inactive quotations and make_revision rejects them, so new revisions branch from the current version.

* test(selling): inactive quotation cannot be revised

* feat(selling): set only the latest quotation version as lost

Set as Lost moves from the active version to the latest submitted one, active or not. Setting it as Lost makes it the active version and deactivates the others, so Lost Quotations still counts the deal once. Revision Of is indexed, since the latest version is looked up on every form load.

* test(selling): only the latest quotation version can be set as lost

* feat(selling): do not revise lost or ordered quotations

Create > Revision follows the same rule as Sales Order and Update Items: it is hidden on Lost and Ordered quotations, and make_revision rejects them.

* test(selling): lost quotation cannot be revised

* feat(selling): revise only the latest quotation version

Create > Revision now needs the quotation to be both active and the latest submitted version, so an older version that was re-activated by hand cannot branch the revision history. The revise rules move into Quotation.validate_can_be_revised.

* test(selling): only the latest quotation version can be revised

* feat(selling): order quotation versions by transaction date

The latest version is the one with the latest Transaction Date. Creation time only breaks ties between versions dated the same day.

* test(selling): latest quotation version follows transaction date

* feat(selling): block quotation revisions dated before the latest version

A revision cannot have a Transaction Date earlier than any other submitted version of the same quotation. Otherwise the revision becomes active on submit while an older version stays the latest, and no version can be revised.

* test(selling): quotation revision dated before the latest version

* fix(selling): keep a submitted quotation revision the latest version

The revision date check compared dates only, so of two revisions dated the same day, the one created first could be submitted last. It then became the active version while the other stayed the latest, and neither could be revised. The check now uses the same date-then-creation order as the latest version.

* test(selling): older quotation revision submitted after a newer one

* fix(selling): lock the original quotation while naming a revision

Two revisions of the same quotation inserted at once could read the same existing names and pick the same -R suffix. Locking the original quotation row makes the second insert wait and take the next number.

* perf(selling): update other quotation versions in one query

Deactivating or setting the other versions as Lost wrote one row at a time. It now reads the matching versions once and updates them with a single query.

* test(selling): copy the margin test quotation once

Copying an unsaved quotation a second time turns the unset Is Active into 0, so insert kept the quotation inactive and the Sales Order mapper rejected it. One copy leaves the field unset, and insert applies its default.

* perf(selling): clear the cache of updated quotation versions only

A filter-based set_value clears the cache of every Quotation. bulk_update keeps the single query, and the cache is then cleared for the updated versions only.

* fix(selling): block submitting a sales order against an inactive quotation

The Is Active check ran only in the quotation mappers, so a Sales Order with quotation rows added another way could still be submitted against an inactive quotation. The check now also runs when the Sales Order is submitted. Cancelling an existing Sales Order still works.

* test(selling): sales order against an inactive quotation

* feat(selling): lock is active on lost quotations

Is Active is read-only once a quotation is Lost, and the server rejects changes to it. Set as Lost now writes Is Active together with the status, so marking an inactive latest version as Lost still makes it active.

* test(selling): is active locked on a lost quotation

* feat(selling): show is active on submitted quotations only

Is Active only affects submitted quotations, so the checkbox is hidden on new, draft and cancelled ones.

* feat(selling): set any quotation version as lost

Set as Lost is back on every submitted version, active or not. When other versions would also be set as Lost, the user confirms first.

* test(selling): older quotation version set as lost

* feat(selling): revise any quotation version

Create > Revision is back on every submitted version that is not Lost or Ordered, active or not. When newer versions exist, the user confirms first.

* test(selling): older quotation version revised

* fix(selling): keep the quotation create menu primary

The Create menu turned primary only when the Sales Order button was added, so a quotation offering only Revision showed a plain menu.

* feat(selling): list every quotation version under connections

Connections found versions through revision_of, which only matches on the original, so revisions listed nothing. A Quotation count method now adds the other draft and submitted versions by name, so every version links to the rest.

* test(selling): quotation versions listed under connections

* fix(selling): label the quotation connections group versions

The group lists the original and the other revisions, so Versions fits better than Revisions.

* fix(selling): block setting a quotation as lost when a version is ordered

Set as Lost checked only the chosen version for Sales Orders. An older open version could be set as Lost while a newer version was ordered, which counted a won deal as lost.

* test(selling): quotation version set as lost with an ordered version

* fix(selling): set other quotation versions as lost before updating the opportunity

Opportunity and Lead recompute their status from active quotations. When an older version was set as Lost, the newer active version was still open at that point, so the Opportunity stayed at Quotation.

* test(selling): opportunity lost when an older quotation version is set as lost

* fix(selling): keep quotation revisions in the same company and off lost quotations

A revision could move to another company, so updates to its other versions reached quotations of a different company. A draft revision made before the quotation was set as Lost could also still be submitted and reopen the deal.

* test(selling): quotation revision company and lost checks

* fix(selling): list only readable quotation versions under connections

The Connections list named every other version without a permission check. It now uses get_list, as frappe does when it returns linked names.

* feat(selling): keep the party of the original on quotation revisions

A revision must stay with the party of the original quotation. Quotations for a Lead are the exception, so a revision can move to the Customer made from that Lead.

* test(selling): quotation revision party checks

* fix(selling): update opportunity and lead when a quotation is deactivated

Ticking or unticking Is Active by hand left the Opportunity and Lead status unchanged, even though they no longer count inactive quotations. The change now updates them the same way submit and cancel do.

* test(selling): opportunity reopened when its quotation is deactivated

* fix(selling): block submitting a sales order against a lost quotation

A draft Sales Order made before its quotation was set as Lost could still be submitted, which turned the Lost quotation into Ordered.

* test(selling): sales order against a lost quotation

* fix(selling): limit lead quotation revisions to the lead's customer

A revision of a quotation for a Lead could move to any party. It can now only stay with that Lead or move to the Customer created from it.

* test(selling): lead quotation revision moved to its customer

* fix(selling): validate items in the quotation connections count

The whitelisted count method parsed items without checking the result, so other JSON values failed deep inside frappe's counter. It now rejects anything but a list of DocType names.

* fix(selling): name the quotation revision button new version

Create > Revision is now Create > New Version, and its confirmation says version too.
2026-09-25 07:18:50 +00:00
Shllokkk
ec775eb782 fix: default closing amount for payment modes not in POS opening entry 2026-09-25 12:37:11 +05:30
Pandiyan P
d687024b88 fix(manufacturing): refresh planned qty after work order status update (#59419) 2026-09-25 12:27:59 +05:30
Mihir Kandoi
e10129ef5e feat(buying): add Remarks field to Purchase Order (#59420)
Co-authored-by: 5zorro <25825485+5zorro@users.noreply.github.com>
2026-09-25 12:23:31 +05:30
Mihir Kandoi
387c6aad48 fix: create stock entry bundles before updating the work order (#59417) 2026-09-25 12:04:04 +05:30
ruthra kumar
a8d452983a refactor: remove mapreduce connection from pcv (#59416) 2026-09-25 10:53:38 +05:30
ruthra kumar
b0c4e3432f refactor: remove mapreduce connection from pcv 2026-09-25 10:32:16 +05:30
Mihir Kandoi
56e9e6f8ed fix: keep customer shipping address on drop ship purchase order (#59415) 2026-09-25 10:29:15 +05:30
nishkagosalia
e4caad8729 fix: status indicator 2026-09-25 10:26:16 +05:30
Mihir Kandoi
53b92732c5 fix: deadlock when setting item default income account (#59414) 2026-09-25 04:51:10 +00:00
Mihir Kandoi
b5be0aa534 fix: show month names in user language in Customer Acquisition and Loyalty report (#59410) 2026-09-25 10:15:48 +05:30
Pandiyan P
da6dadcdd4 perf: bulk load item details for sales forecasts (#59382)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-09-25 01:58:16 +00:00
Pandiyan P
8e757abe57 perf: reuse putaway rule lookups for repeated items (#59380) 2026-09-25 07:02:43 +05:30
Diptanil Saha
9cc2c2272a fix: set correct transaction type on args copy while evaulating item wise pricing rule (#59406) 2026-09-25 04:14:21 +05:30
Mihir Kandoi
712cb5d5b3 fix(buying): resolve scorecard variable paths in modules not yet imported (#59396) 2026-09-24 16:46:18 +00:00
Mihir Kandoi
60db07a27d fix(stock): count same-instant rows in dimension negative stock check (#59398) 2026-09-24 16:44:29 +00:00
Mihir Kandoi
af9e5c61e0 feat(subcontracting): subcontracting inward reports (#59395) 2026-09-24 16:36:01 +00:00
Mihir Kandoi
66ca3af4c8 fix(subcontracting): use per-unit BOM qty for process loss receipt (#59397) 2026-09-24 16:29:58 +00:00
Mihir Kandoi
b78c2bc509 fix(stock): drop unused serial balance from Stock Ledger (#59392) 2026-09-24 14:50:53 +00:00
nishkagosalia
8d085e50a4 fix(UX): Work order UX cleanup 2026-09-22 10:10:50 +05:30
171 changed files with 3886 additions and 1902 deletions

View File

@@ -7,6 +7,7 @@ frappe.ui.form.on("Bank Statement Import", {
return {
filters: {
company: doc.company,
is_company_account: 1,
},
};
});

View File

@@ -72,7 +72,7 @@ class BankStatementImport(DataImport):
self.template_warnings = ""
if self.import_file and not self.import_file.lower().endswith(".txt"):
self.validate_import_file()
self.get_importer()
self.validate_google_sheets_url()
def start_import(self):

View File

@@ -1,8 +0,0 @@
from frappe import _
def get_data():
return {
"non_standard_fieldnames": {"MapReduce Job": "document_name"},
"transactions": [{"label": _("Job"), "items": ["MapReduce Job"]}],
}

View File

@@ -151,7 +151,7 @@ function refresh_payments(payments, frm) {
mode_of_payment: p.mode_of_payment,
opening_amount: 0,
expected_amount: p.amount,
closing_amount: 0,
closing_amount: p.amount,
});
}
});

View File

@@ -438,6 +438,7 @@ def apply_pricing_rule(args: str | dict, doc: str | dict | Document | None = Non
for item in item_list:
args_copy = copy.deepcopy(args)
args_copy.update(item)
set_transaction_type(args_copy)
data = get_pricing_rule_for_item(args_copy, doc=doc)
out.append(data)

View File

@@ -34,6 +34,7 @@ from erpnext.accounts.party import get_due_date, get_party_account
from erpnext.accounts.utils import (
get_account_currency,
get_fiscal_year,
pre_submit_validation,
refresh_subscription_status,
update_voucher_outstanding,
)
@@ -311,6 +312,8 @@ class PurchaseInvoice(BuyingController):
if self.on_hold:
self.validate_invoice_hold()
pre_submit_validation(self, check_prev_docstatus=True)
def set_percentage_received(self):
total_billed_qty = 0.0
total_received_qty = 0.0

View File

@@ -2278,6 +2278,25 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
frappe.db.set_single_value("Buying Settings", "maintain_same_rate", 1)
@ERPNextTestSuite.change_settings(
"Buying Settings", {"maintain_same_rate": 0, "set_landed_cost_based_on_purchase_invoice_rate": 1}
)
def test_adjust_incoming_rate_keeps_discounted_pr_value(self):
pr = make_purchase_receipt(qty=10, rate=100, do_not_submit=True)
pr.apply_discount_on = "Net Total"
pr.additional_discount_percentage = 10
pr.submit()
pi = create_purchase_invoice_from_receipt(pr.name)
pi.submit()
pr.reload()
stock_value_difference = frappe.db.get_value(
"Stock Ledger Entry", {"voucher_no": pr.name, "is_cancelled": 0}, "stock_value_difference"
)
self.assertEqual(pr.items[0].amount_difference_with_purchase_invoice, 0)
self.assertEqual(stock_value_difference, 900)
def test_item_less_defaults(self):
pi = frappe.new_doc("Purchase Invoice")
pi.supplier = "_Test Supplier"

View File

@@ -22,7 +22,11 @@ from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger
)
from erpnext.accounts.doctype.tax_withholding_entry.tax_withholding_entry import SalesTaxWithholding
from erpnext.accounts.party import get_due_date, get_party_account
from erpnext.accounts.utils import refresh_subscription_status, update_voucher_outstanding
from erpnext.accounts.utils import (
pre_submit_validation,
refresh_subscription_status,
update_voucher_outstanding,
)
from erpnext.controllers.accounts_controller import validate_account_head
from erpnext.controllers.selling_controller import SellingController
from erpnext.setup.doctype.company.company import update_company_current_month_sales
@@ -386,6 +390,7 @@ class SalesInvoice(SellingController):
self.reset_default_field_value("set_warehouse", "items", "warehouse")
self.validate_subcontracted_sales_order()
self.validate_scio_self_rm_qty()
pre_submit_validation(self, check_prev_docstatus=True, check_credit_limit=True)
def validate_update_stock_for_pick_list_reference(self):
if self.update_stock or self.is_return:

View File

@@ -1,20 +0,0 @@
{
"action": "Create Entry",
"action_label": "Create Sales Invoice",
"creation": "2026-02-20 13:42:38.439574",
"docstatus": 0,
"doctype": "Onboarding Step",
"idx": 2,
"is_complete": 0,
"is_single": 0,
"is_skipped": 0,
"modified": "2026-02-23 22:16:40.931428",
"modified_by": "Administrator",
"name": "Create Sales Invoice",
"owner": "Administrator",
"reference_document": "Sales Invoice",
"show_form_tour": 0,
"show_full_form": 0,
"title": "Create Sales Invoice",
"validate_action": 1
}

View File

@@ -1,22 +0,0 @@
{
"align_labels_right": 0,
"creation": "2011-12-21 11:08:55",
"custom_format": 1,
"disabled": 0,
"doc_type": "Sales Invoice",
"docstatus": 0,
"doctype": "Print Format",
"html": "<style>\n\t.print-format table, .print-format tr, \n\t.print-format td, .print-format div, .print-format p {\n\t\tfont-family: Tahoma, sans-serif;\n\t\tline-height: 150%;\n\t\tvertical-align: middle;\n\t}\n\t@media screen {\n\t\t.print-format {\n\t\t\twidth: 4in;\n\t\t\tpadding: 0.25in;\n\t\t\tmin-height: 8in;\n\t\t}\n\t}\n</style>\n\n{% if letter_head %}\n {{ letter_head }}\n{% endif %}\n\n<p class=\"text-center\" style=\"margin-bottom: 1rem\">\n\t{{ doc.company }}<br>\n\t{{ doc.select_print_heading or _(\"Invoice\") }}<br>\n</p>\n<p>\n\t<b>{{ _(\"Receipt No\") }}:</b> {{ doc.name }}<br>\n\t<b>{{ _(\"Date\") }}:</b> {{ doc.get_formatted(\"posting_date\") }}<br>\n\t<b>{{ _(\"Customer\") }}:</b> {{ doc.customer_name }}\n</p>\n\n<hr>\n<table class=\"table table-condensed cart no-border\">\n\t<thead>\n\t\t<tr>\n\t\t\t<th width=\"50%\">{{ _(\"Item\") }}</b></th>\n\t\t\t<th width=\"25%\" class=\"text-right\">{{ _(\"Qty\") }}</th>\n\t\t\t<th width=\"25%\" class=\"text-right\">{{ _(\"Amount\") }}</th>\n\t\t</tr>\n\t</thead>\n\t<tbody>\n\t\t{%- for item in doc.items -%}\n\t\t<tr>\n\t\t\t<td>\n\t\t\t\t{{ item.item_code }}\n\t\t\t\t{%- if item.item_name != item.item_code -%}\n\t\t\t\t\t<br>{{ item.item_name }}{%- endif -%}\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">{{ item.qty }}<br>@ {{ item.get_formatted(\"rate\") }}</td>\n\t\t\t<td class=\"text-right\">{{ item.get_formatted(\"amount\") }}</td>\n\t\t</tr>\n\t\t{%- endfor -%}\n\t</tbody>\n</table>\n<table class=\"table table-condensed no-border\">\n\t<tbody>\n\t\t<tr>\n\t\t\t{% if doc.flags.show_inclusive_tax_in_print %}\n\t\t\t\t<td class=\"text-right\" style=\"width: 70%\">\n\t\t\t\t\t{{ _(\"Total Excl. Tax\") }}\n\t\t\t\t</td>\n\t\t\t\t<td class=\"text-right\">\n\t\t\t\t\t{{ doc.get_formatted(\"net_total\", doc) }}\n\t\t\t\t</td>\n\t\t\t{% else %}\n\t\t\t\t<td class=\"text-right\" style=\"width: 70%\">\n\t\t\t\t\t{{ _(\"Total\") }}\n\t\t\t\t</td>\n\t\t\t\t<td class=\"text-right\">\n\t\t\t\t\t{{ doc.get_formatted(\"total\", doc) }}\n\t\t\t\t</td>\n\t\t\t{% endif %}\n\t\t</tr>\n\t\t{%- for row in doc.taxes -%}\n\t\t {%- if not row.included_in_print_rate or doc.flags.show_inclusive_tax_in_print -%}\n\t\t\t<tr>\n\t\t\t\t<td class=\"text-right\" style=\"width: 70%\">\n\t\t\t\t\t{{ row.description }}\n\t\t\t\t</td>\n\t\t\t\t<td class=\"text-right\">\n\t\t\t\t\t{{ row.get_formatted(\"tax_amount\", doc) }}\n\t\t\t\t</td>\n\t\t\t<tr>\n\t\t {%- endif -%}\n\t\t{%- endfor -%}\n\n\t\t{%- if doc.discount_amount -%}\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 75%\">\n\t\t\t\t{{ _(\"Discount\") }}\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ doc.get_formatted(\"discount_amount\") }}\n\t\t\t</td>\n\t\t</tr>\n\t\t{%- endif -%}\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 75%\">\n\t\t\t\t<b>{{ _(\"Grand Total\") }}</b>\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ doc.get_formatted(\"grand_total\") }}\n\t\t\t</td>\n\t\t</tr>\n\t\t{%- if doc.rounded_total -%}\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 75%\">\n\t\t\t\t<b>{{ _(\"Rounded Total\") }}</b>\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ doc.get_formatted(\"rounded_total\") }}\n\t\t\t</td>\n\t\t</tr>\n\t\t{%- endif -%}\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 75%\">\n\t\t\t\t<b>{{ _(\"Paid Amount\") }}</b>\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ doc.get_formatted(\"paid_amount\") }}\n\t\t\t</td>\n\t\t</tr>\n\t\t{%- if doc.change_amount -%}\n\t\t\t<tr>\n\t\t\t\t<td class=\"text-right\" style=\"width: 75%\">\n\t\t\t\t\t<b>{{ _(\"Change Amount\") }}</b>\n\t\t\t\t</td>\n\t\t\t\t<td class=\"text-right\">\n\t\t\t\t\t{{ doc.get_formatted(\"change_amount\") }}\n\t\t\t\t</td>\n\t\t\t</tr>\n\t\t{%- endif -%}\n\t</tbody>\n</table>\n<hr>\n<p>{{ doc.terms or \"\" }}</p>\n<p class=\"text-center\">{{ _(\"Thank you, please visit again.\") }}</p>",
"idx": 1,
"line_breaks": 0,
"modified": "2020-04-29 16:35:07.043058",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Invoice",
"owner": "Administrator",
"print_format_builder": 0,
"print_format_type": "Jinja",
"raw_printing": 0,
"show_section_headings": 0,
"standard": "Yes"
}

View File

@@ -1,153 +0,0 @@
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
// License: GNU General Public License v3. See license.txt
frappe.query_reports["Consolidated Accounts Payable"] = {
filters: [
{
fieldname: "companies",
label: __("Companies"),
fieldtype: "MultiSelectList",
options: "Company",
get_data: function (txt) {
return frappe.db.get_link_options("Company", txt);
},
description: __("Totals are dropped when the companies use different currencies"),
},
{
fieldname: "report_date",
label: __("Report Date"),
fieldtype: "Date",
default: frappe.datetime.get_today(),
},
{
fieldname: "party_type",
label: __("Party Type"),
fieldtype: "Autocomplete",
options: get_party_type_options(),
on_change: function () {
frappe.query_report.set_filter_value("party", "");
frappe.query_report.toggle_filter_display(
"supplier_group",
frappe.query_report.get_filter_value("party_type") !== "Supplier"
);
},
},
{
fieldname: "party",
label: __("Party"),
fieldtype: "MultiSelectList",
options: "party_type",
get_data: function (txt) {
if (!frappe.query_report.filters) return;
let party_type = frappe.query_report.get_filter_value("party_type");
if (!party_type) return;
return frappe.db.get_link_options(party_type, txt);
},
},
{
fieldname: "ageing_based_on",
label: __("Ageing Based On"),
fieldtype: "Select",
options: "Posting Date\nDue Date\nSupplier Invoice Date",
default: "Due Date",
},
{
fieldname: "age_as_on",
label: __("Age as on"),
fieldtype: "Select",
options: "Report Date\nToday",
default: "Report Date",
},
{
fieldname: "range",
label: __("Ageing Range"),
fieldtype: "Data",
default: "30, 60, 90, 120",
},
{
fieldname: "supplier_group",
label: __("Supplier Group"),
fieldtype: "Link",
options: "Supplier Group",
},
{
fieldname: "group_by_party",
label: __("Group By Supplier"),
fieldtype: "Check",
},
{
fieldname: "group_by_company",
label: __("Group By Company"),
fieldtype: "Check",
},
{
fieldname: "ignore_accounts",
label: __("Group by Voucher"),
fieldtype: "Check",
},
{
fieldname: "based_on_payment_terms",
label: __("Based On Payment Terms"),
fieldtype: "Check",
},
{
fieldname: "show_future_payments",
label: __("Show Future Payments"),
fieldtype: "Check",
},
{
fieldname: "show_remarks",
label: __("Show Remarks"),
fieldtype: "Check",
},
{
fieldname: "for_revaluation_journals",
label: __("Revaluation Journals"),
fieldtype: "Check",
},
{
fieldname: "in_party_currency",
label: __("In Party Currency"),
fieldtype: "Check",
},
],
collapsible_filters: true,
separate_check_filters: true,
formatter: function (value, row, column, data, default_formatter) {
value = default_formatter(value, row, column, data);
if (data && data.bold) {
value = value.bold();
}
return value;
},
onload: function (report) {
report.page.add_inner_button(__("Consolidated Accounts Payable Summary"), function () {
frappe.set_route("query-report", "Consolidated Accounts Payable Summary", report.get_values());
});
const company = frappe.defaults.get_user_default("Company");
if (company && !(report.get_filter_value("companies") || []).length) {
report.set_filter_value("companies", [company]);
}
if (frappe.boot.sysdefaults.default_ageing_range) {
report.set_filter_value("range", frappe.boot.sysdefaults.default_ageing_range);
}
},
};
function get_party_type_options() {
let options = [];
frappe.db
.get_list("Party Type", { filters: { account_type: "Payable" }, fields: ["name"] })
.then((res) => {
res.forEach((party_type) => {
options.push(party_type.name);
});
});
return options;
}

View File

@@ -1,36 +0,0 @@
{
"add_total_row": 1,
"add_translate_data": 0,
"columns": [],
"creation": "2026-09-11 10:00:00.000000",
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"filters": [],
"idx": 0,
"is_standard": "Yes",
"modified": "2026-09-11 10:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Consolidated Accounts Payable",
"owner": "Administrator",
"prepared_report": 0,
"ref_doctype": "Purchase Invoice",
"report_name": "Consolidated Accounts Payable",
"report_type": "Script Report",
"roles": [
{
"role": "Accounts User"
},
{
"role": "Purchase User"
},
{
"role": "Accounts Manager"
},
{
"role": "Auditor"
}
],
"timeout": 0
}

View File

@@ -1,14 +0,0 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
from erpnext.accounts.report.consolidated_accounts_receivable.consolidated_accounts_receivable import (
ConsolidatedReceivablePayable,
)
def execute(filters=None):
args = {
"account_type": "Payable",
"naming_by": ["Buying Settings", "supp_master_name"],
}
return ConsolidatedReceivablePayable(filters).run(args)

View File

@@ -1,133 +0,0 @@
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
// License: GNU General Public License v3. See license.txt
frappe.query_reports["Consolidated Accounts Payable Summary"] = {
filters: [
{
fieldname: "companies",
label: __("Companies"),
fieldtype: "MultiSelectList",
options: "Company",
get_data: function (txt) {
return frappe.db.get_link_options("Company", txt);
},
description: __("Totals are dropped when the companies use different currencies"),
},
{
fieldname: "report_date",
label: __("Report Date"),
fieldtype: "Date",
default: frappe.datetime.get_today(),
},
{
fieldname: "ageing_based_on",
label: __("Ageing Based On"),
fieldtype: "Select",
options: "Posting Date\nDue Date\nSupplier Invoice Date",
default: "Due Date",
},
{
fieldname: "age_as_on",
label: __("Age as on"),
fieldtype: "Select",
options: "Report Date\nToday",
default: "Report Date",
},
{
fieldname: "range",
label: __("Ageing Range"),
fieldtype: "Data",
default: "30, 60, 90, 120",
},
{
fieldname: "party_type",
label: __("Party Type"),
fieldtype: "Autocomplete",
options: get_party_type_options(),
on_change: function () {
frappe.query_report.set_filter_value("party", "");
frappe.query_report.toggle_filter_display(
"supplier_group",
frappe.query_report.get_filter_value("party_type") !== "Supplier"
);
},
},
{
fieldname: "party",
label: __("Party"),
fieldtype: "MultiSelectList",
options: "party_type",
get_data: function (txt) {
if (!frappe.query_report.filters) return;
let party_type = frappe.query_report.get_filter_value("party_type");
if (!party_type) return;
return frappe.db.get_link_options(party_type, txt);
},
},
{
fieldname: "supplier_group",
label: __("Supplier Group"),
fieldtype: "Link",
options: "Supplier Group",
},
{
fieldname: "show_future_payments",
label: __("Show Future Payments"),
fieldtype: "Check",
},
{
fieldname: "show_gl_balance",
label: __("Show GL Balance"),
fieldtype: "Check",
},
{
fieldname: "based_on_payment_terms",
label: __("Based On Payment Terms"),
fieldtype: "Check",
},
{
fieldname: "for_revaluation_journals",
label: __("Revaluation Journals"),
fieldtype: "Check",
},
],
collapsible_filters: true,
separate_check_filters: true,
formatter: function (value, row, column, data, default_formatter) {
value = default_formatter(value, row, column, data);
if (data && data.bold) {
value = value.bold();
}
return value;
},
onload: function (report) {
report.page.add_inner_button(__("Consolidated Accounts Payable"), function () {
frappe.set_route("query-report", "Consolidated Accounts Payable", report.get_values());
});
const company = frappe.defaults.get_user_default("Company");
if (company && !(report.get_filter_value("companies") || []).length) {
report.set_filter_value("companies", [company]);
}
if (frappe.boot.sysdefaults.default_ageing_range) {
report.set_filter_value("range", frappe.boot.sysdefaults.default_ageing_range);
}
},
};
function get_party_type_options() {
let options = [];
frappe.db
.get_list("Party Type", { filters: { account_type: "Payable" }, fields: ["name"] })
.then((res) => {
res.forEach((party_type) => {
options.push(party_type.name);
});
});
return options;
}

View File

@@ -1,36 +0,0 @@
{
"add_total_row": 0,
"add_translate_data": 0,
"columns": [],
"creation": "2026-08-07 10:00:00.000000",
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"filters": [],
"idx": 0,
"is_standard": "Yes",
"modified": "2026-08-07 10:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Consolidated Accounts Payable Summary",
"owner": "Administrator",
"prepared_report": 0,
"ref_doctype": "Purchase Invoice",
"report_name": "Consolidated Accounts Payable Summary",
"report_type": "Script Report",
"roles": [
{
"role": "Accounts User"
},
{
"role": "Purchase User"
},
{
"role": "Accounts Manager"
},
{
"role": "Auditor"
}
],
"timeout": 0
}

View File

@@ -1,14 +0,0 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
from erpnext.accounts.report.consolidated_accounts_receivable_summary.consolidated_accounts_receivable_summary import (
ConsolidatedReceivablePayableSummary,
)
def execute(filters=None):
args = {
"account_type": "Payable",
"naming_by": ["Buying Settings", "supp_master_name"],
}
return ConsolidatedReceivablePayableSummary(filters).run(args)

View File

@@ -1,184 +0,0 @@
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
// License: GNU General Public License v3. See license.txt
frappe.query_reports["Consolidated Accounts Receivable"] = {
filters: [
{
fieldname: "companies",
label: __("Companies"),
fieldtype: "MultiSelectList",
options: "Company",
get_data: function (txt) {
return frappe.db.get_link_options("Company", txt);
},
description: __("Totals are dropped when the companies use different currencies"),
},
{
fieldname: "report_date",
label: __("Report Date"),
fieldtype: "Date",
default: frappe.datetime.get_today(),
},
{
fieldname: "party_type",
label: __("Party Type"),
fieldtype: "Autocomplete",
options: get_party_type_options(),
on_change: function () {
frappe.query_report.set_filter_value("party", "");
frappe.query_report.toggle_filter_display(
"customer_group",
frappe.query_report.get_filter_value("party_type") !== "Customer"
);
},
},
{
fieldname: "party",
label: __("Party"),
fieldtype: "MultiSelectList",
options: "party_type",
get_data: function (txt) {
if (!frappe.query_report.filters) return;
let party_type = frappe.query_report.get_filter_value("party_type");
if (!party_type) return;
return frappe.db.get_link_options(party_type, txt);
},
},
{
fieldname: "ageing_based_on",
label: __("Ageing Based On"),
fieldtype: "Select",
options: "Posting Date\nDue Date",
default: "Due Date",
},
{
fieldname: "age_as_on",
label: __("Age as on"),
fieldtype: "Select",
options: "Report Date\nToday",
default: "Report Date",
},
{
fieldname: "range",
label: __("Ageing Range"),
fieldtype: "Data",
default: "30, 60, 90, 120",
},
{
fieldname: "customer_group",
label: __("Customer Group"),
fieldtype: "Link",
options: "Customer Group",
},
{
fieldname: "territory",
label: __("Territory"),
fieldtype: "MultiSelectList",
options: "Territory",
get_data: function (txt) {
return frappe.db.get_link_options("Territory", txt);
},
},
{
fieldname: "sales_partner",
label: __("Sales Partner"),
fieldtype: "Link",
options: "Sales Partner",
},
{
fieldname: "sales_person",
label: __("Sales Person"),
fieldtype: "Link",
options: "Sales Person",
},
{
fieldname: "group_by_party",
label: __("Group By Customer"),
fieldtype: "Check",
},
{
fieldname: "group_by_company",
label: __("Group By Company"),
fieldtype: "Check",
},
{
fieldname: "ignore_accounts",
label: __("Group by Voucher"),
fieldtype: "Check",
},
{
fieldname: "based_on_payment_terms",
label: __("Based On Payment Terms"),
fieldtype: "Check",
},
{
fieldname: "show_future_payments",
label: __("Show Future Payments"),
fieldtype: "Check",
},
{
fieldname: "show_remarks",
label: __("Show Remarks"),
fieldtype: "Check",
},
{
fieldname: "show_sales_person",
label: __("Show Sales Person"),
fieldtype: "Check",
},
{
fieldname: "show_delivery_notes",
label: __("Show Linked Delivery Notes"),
fieldtype: "Check",
},
{
fieldname: "for_revaluation_journals",
label: __("Revaluation Journals"),
fieldtype: "Check",
},
{
fieldname: "in_party_currency",
label: __("In Party Currency"),
fieldtype: "Check",
},
],
collapsible_filters: true,
separate_check_filters: true,
formatter: function (value, row, column, data, default_formatter) {
value = default_formatter(value, row, column, data);
if (data && data.bold) {
value = value.bold();
}
return value;
},
onload: function (report) {
report.page.add_inner_button(__("Consolidated Accounts Receivable Summary"), function () {
frappe.set_route("query-report", "Consolidated Accounts Receivable Summary", report.get_values());
});
const company = frappe.defaults.get_user_default("Company");
if (company && !(report.get_filter_value("companies") || []).length) {
report.set_filter_value("companies", [company]);
}
if (frappe.boot.sysdefaults.default_ageing_range) {
report.set_filter_value("range", frappe.boot.sysdefaults.default_ageing_range);
}
},
};
function get_party_type_options() {
let options = [];
frappe.db
.get_list("Party Type", { filters: { account_type: "Receivable" }, fields: ["name"] })
.then((res) => {
res.forEach((party_type) => {
options.push(party_type.name);
});
});
return options;
}

View File

@@ -1,30 +0,0 @@
{
"add_total_row": 1,
"add_translate_data": 0,
"columns": [],
"creation": "2026-09-11 10:00:00.000000",
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"filters": [],
"idx": 0,
"is_standard": "Yes",
"modified": "2026-09-11 10:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Consolidated Accounts Receivable",
"owner": "Administrator",
"prepared_report": 0,
"ref_doctype": "Sales Invoice",
"report_name": "Consolidated Accounts Receivable",
"report_type": "Script Report",
"roles": [
{
"role": "Accounts Manager"
},
{
"role": "Accounts User"
}
],
"timeout": 0
}

View File

@@ -1,147 +0,0 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
from collections import OrderedDict
import frappe
from frappe import _
from frappe.model import numeric_fieldtypes
from frappe.utils import flt
from erpnext.accounts.report.accounts_receivable.accounts_receivable import ReceivablePayableReport
from erpnext.accounts.report.consolidated_financial_statement.consolidated_financial_statement import (
get_subsidiary_companies,
)
# Outstanding invoices of a party across companies that need not be related to each other.
# Every row carries the company it belongs to, and a party's companies sit together.
def execute(filters=None):
args = {
"account_type": "Receivable",
"naming_by": ["Selling Settings", "cust_master_name"],
}
return ConsolidatedReceivablePayable(filters).run(args)
class ConsolidatedReceivablePayable(ReceivablePayableReport):
def run(self, args):
self.companies = get_consolidated_companies(self.filters)
self.args = args # the engine's get_data() takes no arguments, so hand them over here
columns, data, _message, chart, _report_summary, skip_total_row = super().run(args)
if self.filters.get("group_by_company") or len(row_currencies(data)) > 1:
# a grand total would either double count the subtotals or add unlike currencies
skip_total_row = 1
return columns, data, None, chart, None, skip_total_row
def get_columns(self):
super().get_columns()
add_company_columns(self.columns)
def get_data(self):
# party wins when both are checked, its subtotal is the point of this report
if self.filters.get("group_by_party"):
group_by, subtotal_of = "party", self.party_subtotal
elif self.filters.get("group_by_company"):
group_by, subtotal_of = "company", self.company_subtotal
else:
group_by, subtotal_of = "party", None
self.data = []
for rows in self.get_grouped_rows(group_by).values():
self.data.extend(rows)
if subtotal_of and len(row_currencies(rows)) <= 1:
self.data.append(subtotal_of(rows))
self.data.append({}) # blank separator, like the engine's own grouping
def get_grouped_rows(self, group_by):
"""Rows of every company, regrouped so those sharing `group_by` sit together."""
grouped = OrderedDict()
for row in rows_per_company(self.companies, self.filters, self.args, ReceivablePayableReport):
grouped.setdefault(row.get(group_by), []).append(row)
return grouped
def party_subtotal(self, rows):
return self.subtotal(rows, party=rows[0].party)
def company_subtotal(self, rows):
return self.subtotal(rows, company=rows[0].company)
def subtotal(self, rows, **label):
# same shape as the engine's own group-by-party subtotal
subtotal = frappe._dict(currency=rows[0].get("currency"), bold=1, **label)
for field in self.get_currency_fields():
subtotal[field] = sum(flt(row.get(field)) for row in rows)
return subtotal
def rows_per_company(companies, filters, args, engine):
"""Run `engine` once per company, tagging every row with the company it came from."""
for company in companies:
# subtotals are appended once per group by the caller, not once per company
company_filters = frappe._dict(filters, company=company, group_by_party=0)
company_filters.pop("companies", None)
parent = frappe.get_cached_value("Company", company, "parent_company")
for row in engine(company_filters).run(args)[1]:
row.company, row.parent_company = company, parent
yield row
def get_consolidated_companies(filters):
"""Selected companies, a group company standing for the companies under it."""
companies = []
for selected in filters.get("companies") or []:
for company in get_subsidiary_companies(selected):
if company not in companies:
companies.append(company)
return companies
def row_currencies(rows):
return {row.get("currency") for row in rows if row.get("currency")}
def add_company_columns(columns):
"""Company and its parent, right after the party columns, plus header alignment."""
at = company_column_index(columns)
columns.insert(
at,
dict(
label=_("Company"),
fieldname="company",
fieldtype="Link",
options="Company",
width=180,
sticky=True,
),
)
columns.insert(
at + 1,
dict(
label=_("Parent Company"),
fieldname="parent_company",
fieldtype="Link",
options="Company",
width=160,
),
)
# datatable guesses alignment from the first row, which misreads an empty column
for column in columns:
column["align"] = "right" if column["fieldtype"] in numeric_fieldtypes else "left"
def company_column_index(columns):
fieldnames = [column["fieldname"] for column in columns]
for fieldname in ("party_name", "party"):
if fieldname in fieldnames:
return fieldnames.index(fieldname) + 1
return 0

View File

@@ -1,133 +0,0 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from frappe.utils import today
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.report.consolidated_accounts_receivable.consolidated_accounts_receivable import (
execute,
)
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.tests.utils import ERPNextTestSuite
class ConsolidatedReportMixin(AccountsTestMixin):
"""Companies, invoices and filters shared by the consolidated report tests."""
def filters(self, companies=None, **kwargs):
return {
# an explicit empty list means "nothing selected", so do not fall back on it
"companies": [self.company_a, self.company_b] if companies is None else companies,
"party_type": "Customer",
"party": [self.customer],
"report_date": today(),
"range": "30, 60, 90, 120",
**kwargs,
}
def create_invoice(self, company, abbr, rate, customer=None, currency=None):
return create_sales_invoice(
item=self.item,
company=company,
customer=customer or self.customer,
currency=currency,
debit_to=f"Debtors - {abbr}",
income_account=f"Sales - {abbr}",
cost_center=f"Main - {abbr}",
parent_cost_center=f"Main - {abbr}",
warehouse=f"Stores - {abbr}",
posting_date=today(),
rate=rate,
price_list_rate=rate,
)
def create_test_company(self, company_name, abbr, currency="INR", is_group=0, parent=None):
if frappe.db.exists("Company", company_name):
return company_name
company = frappe.new_doc("Company")
company.company_name = company_name
company.abbr = abbr
company.country = "India"
company.default_currency = currency
company.create_chart_of_accounts_based_on = "Standard Template"
company.chart_of_accounts = "Standard"
company.is_group = is_group
company.parent_company = parent
company.insert()
return company.name
class TestConsolidatedAccountsReceivable(ERPNextTestSuite, ConsolidatedReportMixin):
def setUp(self):
self.maxDiff = None
# deliberately unrelated companies, there is no parent/child link between them
self.company_a = self.create_test_company("_Test Unrelated A", "_TUNA")
self.company_b = self.create_test_company("_Test Unrelated B", "_TUNB")
self.create_customer("_Test Consolidation Customer")
# the mixin would otherwise pass company=None, which the Item Default row rejects
self.create_item("_Test Consolidation Item", company=self.company_a)
def test_rows_carry_the_company_they_came_from(self):
self.create_invoice(self.company_a, "_TUNA", 200)
self.create_invoice(self.company_b, "_TUNB", 300)
rows = execute(self.filters())[1]
self.assertEqual([r.company for r in rows], [self.company_a, self.company_b])
self.assertFalse(any(r.parent_company for r in rows))
def test_group_by_party_adds_one_subtotal_for_all_companies(self):
self.create_invoice(self.company_a, "_TUNA", 200)
self.create_invoice(self.company_b, "_TUNB", 300)
result = execute(self.filters(group_by_party=1))
subtotals = [row for row in result[1] if row.get("bold")]
self.assertEqual(len(subtotals), 1)
self.assertEqual(subtotals[0].outstanding, 500.0)
self.assertEqual(result[5], 1) # skip_total_row, else the grand total double counts
def test_group_by_company_subtotals_each_company(self):
self.create_invoice(self.company_a, "_TUNA", 200)
self.create_invoice(self.company_b, "_TUNB", 300)
result = execute(self.filters(group_by_company=1))
subtotals = [row for row in result[1] if row.get("bold")]
self.assertEqual([s.company for s in subtotals], [self.company_a, self.company_b])
self.assertEqual([s.outstanding for s in subtotals], [200.0, 300.0])
self.assertEqual(result[5], 1)
def test_party_grouping_wins_when_both_are_selected(self):
self.create_invoice(self.company_a, "_TUNA", 200)
self.create_invoice(self.company_b, "_TUNB", 300)
subtotals = [
row for row in execute(self.filters(group_by_party=1, group_by_company=1))[1] if row.get("bold")
]
self.assertEqual(len(subtotals), 1)
self.assertEqual(subtotals[0].party, self.customer)
def test_no_total_when_companies_use_different_currencies(self):
usd = self.create_test_company("_Test Unrelated USD", "_TUNU", currency="USD")
self.create_invoice(self.company_a, "_TUNA", 200)
self.create_invoice(usd, "_TUNU", 300, currency="USD")
result = execute(self.filters(companies=[self.company_a, usd]))
self.assertEqual([r.company for r in result[1]], [self.company_a, usd])
self.assertEqual(result[5], 1) # skip_total_row, the rows span currencies
def test_group_company_expands_to_its_subsidiaries(self):
group = self.create_test_company("_Test Consolidation Group", "_TCGRP", is_group=1)
child = self.create_test_company("_Test Consolidation Child", "_TCCLD", parent=group)
self.create_invoice(child, "_TCCLD", 400)
rows = execute(self.filters(companies=[group]))[1]
self.assertEqual([r.company for r in rows], [child])
self.assertEqual([r.parent_company for r in rows], [group])

View File

@@ -1,159 +0,0 @@
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
// License: GNU General Public License v3. See license.txt
frappe.query_reports["Consolidated Accounts Receivable Summary"] = {
filters: [
{
fieldname: "companies",
label: __("Companies"),
fieldtype: "MultiSelectList",
options: "Company",
get_data: function (txt) {
return frappe.db.get_link_options("Company", txt);
},
description: __("Totals are dropped when the companies use different currencies"),
},
{
fieldname: "report_date",
label: __("Report Date"),
fieldtype: "Date",
default: frappe.datetime.get_today(),
},
{
fieldname: "ageing_based_on",
label: __("Ageing Based On"),
fieldtype: "Select",
options: "Posting Date\nDue Date",
default: "Due Date",
},
{
fieldname: "age_as_on",
label: __("Age as on"),
fieldtype: "Select",
options: "Report Date\nToday",
default: "Report Date",
},
{
fieldname: "range",
label: __("Ageing Range"),
fieldtype: "Data",
default: "30, 60, 90, 120",
},
{
fieldname: "party_type",
label: __("Party Type"),
fieldtype: "Autocomplete",
options: get_party_type_options(),
on_change: function () {
frappe.query_report.set_filter_value("party", "");
frappe.query_report.toggle_filter_display(
"customer_group",
frappe.query_report.get_filter_value("party_type") !== "Customer"
);
},
},
{
fieldname: "party",
label: __("Party"),
fieldtype: "MultiSelectList",
options: "party_type",
get_data: function (txt) {
if (!frappe.query_report.filters) return;
let party_type = frappe.query_report.get_filter_value("party_type");
if (!party_type) return;
return frappe.db.get_link_options(party_type, txt);
},
},
{
fieldname: "customer_group",
label: __("Customer Group"),
fieldtype: "Link",
options: "Customer Group",
},
{
fieldname: "territory",
label: __("Territory"),
fieldtype: "MultiSelectList",
options: "Territory",
get_data: function (txt) {
return frappe.db.get_link_options("Territory", txt);
},
},
{
fieldname: "show_future_payments",
label: __("Show Future Payments"),
fieldtype: "Check",
},
{
fieldname: "sales_partner",
label: __("Sales Partner"),
fieldtype: "Link",
options: "Sales Partner",
},
{
fieldname: "sales_person",
label: __("Sales Person"),
fieldtype: "Link",
options: "Sales Person",
},
{
fieldname: "show_gl_balance",
label: __("Show GL Balance"),
fieldtype: "Check",
},
{
fieldname: "show_sales_person",
label: __("Show Sales Person"),
fieldtype: "Check",
},
{
fieldname: "based_on_payment_terms",
label: __("Based On Payment Terms"),
fieldtype: "Check",
},
{
fieldname: "for_revaluation_journals",
label: __("Revaluation Journals"),
fieldtype: "Check",
},
],
collapsible_filters: true,
separate_check_filters: true,
formatter: function (value, row, column, data, default_formatter) {
value = default_formatter(value, row, column, data);
if (data && data.bold) {
value = value.bold();
}
return value;
},
onload: function (report) {
report.page.add_inner_button(__("Consolidated Accounts Receivable"), function () {
frappe.set_route("query-report", "Consolidated Accounts Receivable", report.get_values());
});
const company = frappe.defaults.get_user_default("Company");
if (company && !(report.get_filter_value("companies") || []).length) {
report.set_filter_value("companies", [company]);
}
if (frappe.boot.sysdefaults.default_ageing_range) {
report.set_filter_value("range", frappe.boot.sysdefaults.default_ageing_range);
}
},
};
function get_party_type_options() {
let options = [];
frappe.db
.get_list("Party Type", { filters: { account_type: "Receivable" }, fields: ["name"] })
.then((res) => {
res.forEach((party_type) => {
options.push(party_type.name);
});
});
return options;
}

View File

@@ -1,30 +0,0 @@
{
"add_total_row": 0,
"add_translate_data": 0,
"columns": [],
"creation": "2026-08-07 10:00:00.000000",
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"filters": [],
"idx": 0,
"is_standard": "Yes",
"modified": "2026-08-07 10:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Consolidated Accounts Receivable Summary",
"owner": "Administrator",
"prepared_report": 0,
"ref_doctype": "Sales Invoice",
"report_name": "Consolidated Accounts Receivable Summary",
"report_type": "Script Report",
"roles": [
{
"role": "Accounts Manager"
},
{
"role": "Accounts User"
}
],
"timeout": 0
}

View File

@@ -1,73 +0,0 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
from collections import OrderedDict
import frappe
from frappe import _
from frappe.utils import flt
from erpnext.accounts.report.accounts_receivable_summary.accounts_receivable_summary import (
AccountsReceivableSummary,
)
from erpnext.accounts.report.consolidated_accounts_receivable.consolidated_accounts_receivable import (
add_company_columns,
get_consolidated_companies,
row_currencies,
rows_per_company,
)
# What a party owes (or is owed) across companies that need not be related to each other.
# Each party gets one row per company, followed by a total row for that party.
def execute(filters=None):
args = {
"account_type": "Receivable",
"naming_by": ["Selling Settings", "cust_master_name"],
}
return ConsolidatedReceivablePayableSummary(filters).run(args)
class ConsolidatedReceivablePayableSummary(AccountsReceivableSummary):
def run(self, args):
self.companies = get_consolidated_companies(self.filters)
return super().run(args)
def get_columns(self):
super().get_columns()
add_company_columns(self.columns)
def get_data(self, args):
self.data = []
for rows in self.get_rows_by_party(args).values():
self.data.extend(rows)
# a total over companies of differing currencies would add unlike units
if len(row_currencies(rows)) <= 1:
self.data.append(self.total_row(rows))
def get_rows_by_party(self, args):
"""Rows of every company, regrouped so a party's companies sit together."""
by_party = OrderedDict()
for row in rows_per_company(self.companies, self.filters, args, AccountsReceivableSummary):
by_party.setdefault(row.party, []).append(row)
return by_party
def total_row(self, rows):
# label sits in the first column, like the total row of the plain summary reports;
# `bold` is picked up by the formatter in the report's js
total = frappe._dict(
party_type=_("Total"),
party="",
company="",
currency=rows[0].get("currency"),
bold=1,
)
for row in rows:
for field, value in row.items():
# `advance` arrives as an int when there is none, so don't filter on float alone
if isinstance(value, int | float) and not isinstance(value, bool):
total[field] = flt(total.get(field)) + value
return total

View File

@@ -1,98 +0,0 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
from frappe.utils import today
from erpnext.accounts.report.accounts_receivable_summary.accounts_receivable_summary import (
execute as execute_summary,
)
from erpnext.accounts.report.consolidated_accounts_receivable.test_consolidated_accounts_receivable import (
ConsolidatedReportMixin,
)
from erpnext.accounts.report.consolidated_accounts_receivable_summary.consolidated_accounts_receivable_summary import (
execute,
)
from erpnext.tests.utils import ERPNextTestSuite
class TestConsolidatedAccountsReceivableSummary(ERPNextTestSuite, ConsolidatedReportMixin):
def setUp(self):
self.maxDiff = None
# deliberately unrelated companies, there is no parent/child link between them
self.company_a = self.create_test_company("_Test Unrelated A", "_TUNA")
self.company_b = self.create_test_company("_Test Unrelated B", "_TUNB")
self.create_customer("_Test Consolidation Customer")
# the mixin would otherwise pass company=None, which the Item Default row rejects
self.create_item("_Test Consolidation Item", company=self.company_a)
def test_party_gets_a_row_per_company_plus_a_total(self):
self.create_invoice(self.company_a, "_TUNA", 200)
self.create_invoice(self.company_b, "_TUNB", 300)
rows = execute(self.filters())[1]
self.assertEqual([r.company for r in rows], [self.company_a, self.company_b, ""])
self.assertEqual([r.party for r in rows], [self.customer, self.customer, ""])
self.assertEqual([r.outstanding for r in rows], [200.0, 300.0, 500.0])
self.assertEqual(rows[-1].party_type, "Total")
self.assertTrue(rows[-1].bold)
def test_total_matches_individual_company_summaries(self):
self.create_invoice(self.company_a, "_TUNA", 200)
self.create_invoice(self.company_b, "_TUNB", 300)
total = execute(self.filters())[1][-1].outstanding
individual = sum(self.company_outstanding(company) for company in (self.company_a, self.company_b))
self.assertEqual(total, individual)
def test_company_without_transactions_is_omitted(self):
self.create_invoice(self.company_a, "_TUNA", 200)
rows = execute(self.filters())[1]
self.assertEqual([r.company for r in rows], [self.company_a, ""])
self.assertEqual(rows[-1].outstanding, 200.0)
def test_no_companies_selected_returns_nothing(self):
self.create_invoice(self.company_a, "_TUNA", 200)
self.assertEqual(execute(self.filters(companies=[]))[1], [])
def test_all_parties_are_shown_when_no_party_is_selected(self):
self.create_customer("_Test Second Consolidation Customer")
other = self.customer
self.create_customer("_Test Consolidation Customer")
self.create_invoice(self.company_a, "_TUNA", 200)
self.create_invoice(self.company_a, "_TUNA", 300, customer=other)
filters = self.filters()
filters.pop("party")
parties = {r.party for r in execute(filters)[1] if r.party}
self.assertEqual(parties, {self.customer, other})
def test_group_by_party_carried_over_from_the_detail_report_is_ignored(self):
# the detail report's button hands its own filters over, and the engine would
# otherwise answer with separator rows the summary cannot total
self.create_invoice(self.company_a, "_TUNA", 200)
rows = execute(self.filters(group_by_party=1))[1]
self.assertEqual([r.outstanding for r in rows], [200.0, 200.0])
def test_no_total_when_companies_use_different_currencies(self):
usd = self.create_test_company("_Test Unrelated USD", "_TUNU", currency="USD")
self.create_invoice(self.company_a, "_TUNA", 200)
self.create_invoice(usd, "_TUNU", 300, currency="USD")
rows = execute(self.filters(companies=[self.company_a, usd]))[1]
self.assertEqual([r.company for r in rows], [self.company_a, usd])
self.assertFalse(any(row.get("bold") for row in rows))
# ---------- helpers ----------
def company_outstanding(self, company):
filters = {"company": company, "report_date": today(), "range": "30, 60, 90, 120"}
return sum(r.outstanding for r in execute_summary(filters)[1] if r.party == self.customer)

View File

@@ -7,6 +7,11 @@ from frappe import _
from frappe.query_builder.functions import CurDate, DateDiff
from frappe.utils import cint
from erpnext.stock.doctype.company_restriction.company_restriction import (
get_allowed_companies_condition,
get_allowed_masters_condition,
)
def execute(filters=None):
columns = get_columns()
@@ -108,7 +113,7 @@ def get_sales_details(filters):
# renders the bare CURRENT_DATE keyword. Yields the integer number of days.
days_since_last_order = DateDiff(CurDate(), date_col)
sales_data = (
query = (
frappe.qb.from_(parent)
.inner_join(child)
.on(parent.name == child.parent)
@@ -123,7 +128,12 @@ def get_sales_details(filters):
)
.where(parent.docstatus == 1)
.orderby(days_since_last_order)
).run(as_dict=True)
)
if condition := get_allowed_companies_condition(parent.company, filters["based_on"]):
query = query.where(condition)
sales_data = query.run(as_dict=True)
for d in sales_data:
item_details_map.setdefault((d.territory, d.item_code), d)
@@ -150,10 +160,14 @@ def get_items(filters):
if filters.get("item"):
filters_dict.update({"name": filters["item"]})
item_filters = [filters_dict]
if condition := get_allowed_masters_condition(frappe.qb.DocType("Item").name, "Item"):
item_filters.append(condition)
items = frappe.get_all(
"Item",
fields=["name", "item_group", "item_name", "item_code"],
filters=filters_dict,
filters=item_filters,
order_by="name",
)

View File

@@ -440,7 +440,15 @@ def get_invoices(filters, additional_query_columns):
if filters.get("supplier"):
query = query.where(pi.supplier == filters.supplier)
if filters.get("supplier_group"):
query = query.where(pi.supplier_group == filters.supplier_group)
# read the group from the supplier master, to match the Supplier Group column
supplier = frappe.qb.DocType("Supplier")
query = query.where(
pi.supplier.isin(
frappe.qb.from_(supplier)
.select(supplier.name)
.where(supplier.supplier_group == filters.supplier_group)
)
)
query = get_conditions(filters, query, "Purchase Invoice")

View File

@@ -175,6 +175,23 @@ class TestPurchaseRegister(ERPNextTestSuite):
self.assertEqual(first_row.credit, 600)
self.assertEqual(first_row.balance, 500)
def test_supplier_group_filter_uses_supplier_master(self):
# invoices created before the supplier_group field existed have it blank
pi = make_purchase_invoice()
pi.db_set("supplier_group", None, update_modified=False)
supplier_group = frappe.db.get_value("Supplier", pi.supplier, "supplier_group")
filters = frappe._dict(
company="_Test Company 6",
from_date=add_months(today(), -1),
to_date=today(),
supplier_group=supplier_group,
)
rows = [frappe._dict(row) for row in execute(filters)[1] if row.get("voucher_no") == pi.name]
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0].supplier_group, supplier_group)
def make_purchase_invoice():
from erpnext.accounts.doctype.account.test_account import create_account

View File

@@ -6,6 +6,7 @@ from frappe import _
from frappe.query_builder.functions import IfNull
from erpnext.accounts.report.utils import validate_mandatory_date_range
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies_condition
class TaxWithholdingDetailsReport:
@@ -88,6 +89,8 @@ class TaxWithholdingDetailsReport:
query = query.where(twe.party_type == self.filters.party_type)
if self.filters.party:
query = query.where(twe.party == self.filters.party)
if condition := get_allowed_companies_condition(twe.company, "Tax Withholding Entry"):
query = query.where(condition)
return query

View File

@@ -10,6 +10,7 @@ from frappe.utils import cint, flt
from erpnext.accounts.report.general_ledger.general_ledger import get_accounts_with_children
from erpnext.accounts.report.trial_balance.trial_balance import validate_filters
from erpnext.accounts.utils import get_currency_precision
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_masters_condition
def execute(filters=None):
@@ -31,7 +32,11 @@ def get_data(filters, show_party_name):
else:
party_name_field = "name"
party_filters = {"name": filters.get("party")} if filters.get("party") else {}
party_filters = [{"name": filters.get("party")}] if filters.get("party") else []
party_type = frappe.qb.DocType(filters.get("party_type"))
if condition := get_allowed_masters_condition(party_type.name, filters.get("party_type")):
party_filters.append(condition)
parties = frappe.get_all(
filters.get("party_type"),
fields=["name", party_name_field],

View File

@@ -499,6 +499,27 @@ def update_bin_on_delete(row, doctype: str) -> None:
update_bin_qty(row.item_code, row.warehouse, qty_dict)
def validate_no_issued_proforma(rows) -> None:
"""Raise if a Sales Order row being deleted has an issued Proforma Invoice."""
if not rows:
return
proformed = set(
frappe.get_all(
"Proforma Invoice Item",
filters={"so_detail": ["in", [row.name for row in rows]], "docstatus": 1},
pluck="so_detail",
)
)
for row in rows:
if row.name in proformed:
frappe.throw(
_("Row #{0}: Cannot delete item {1} which has an issued Proforma Invoice.").format(
row.idx, row.item_code
)
)
def validate_and_delete_children(parent, data, ordered_item=None) -> bool:
"""Delete child rows not present in data; return True if any were removed."""
updated_item_names = [d.get("docname") for d in data]
@@ -508,6 +529,9 @@ def validate_and_delete_children(parent, data, ordered_item=None) -> bool:
item for item in parent.items if item.name not in updated_item_names and not item.get("closed")
]
if parent.doctype == "Sales Order":
validate_no_issued_proforma(deleted_children)
for d in deleted_children:
validate_child_on_delete(d, parent, ordered_item)
d.flags.ignore_permissions = True

View File

@@ -2781,48 +2781,21 @@ def is_immutable_ledger_enabled():
return frappe.get_single_value("Accounts Settings", "enable_immutable_ledger")
PRE_SUBMIT_DOCTYPE_CONFIG = {
"Sales Invoice": {
"check_prev_docstatus": True,
"check_credit_limit": True,
},
"Purchase Invoice": {
"check_prev_docstatus": True,
},
"Delivery Note": {
"check_prev_docstatus": True,
"check_credit_limit": True,
"check_packed_qty": True,
},
"Purchase Receipt": {
"check_prev_docstatus": True,
},
"Sales Order": {
"check_credit_limit": True,
},
}
def pre_submit_validation(doc, method=None):
cfg = PRE_SUBMIT_DOCTYPE_CONFIG.get(doc.doctype)
def pre_submit_validation(doc, check_prev_docstatus=False, check_credit_limit=False, check_packed_qty=False):
if (
doc.docstatus != 0
or not frappe.get_cached_value("Accounts Settings", None, "preview_mode")
or not cfg
or not doc.company
):
return
_run_pre_submit_checks(doc, cfg)
def _run_pre_submit_checks(doc, cfg):
if cfg.get("check_prev_docstatus"):
if check_prev_docstatus:
_check_prev_docstatus(doc)
if cfg.get("check_credit_limit"):
if check_credit_limit:
_check_credit_limit_warn(doc)
if cfg.get("check_packed_qty"):
if check_packed_qty:
_check_packed_qty_warn(doc)

View File

@@ -37,7 +37,7 @@
},
{
"fieldname": "subject",
"fieldtype": "Small Text",
"fieldtype": "Text Editor",
"in_list_view": 1,
"label": "Subject",
"print_width": "518",
@@ -76,7 +76,7 @@
"in_create": 1,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-03-27 13:06:32.933603",
"modified": "2026-09-25 12:00:00.000000",
"modified_by": "Administrator",
"module": "Assets",
"name": "Asset Activity",

View File

@@ -17,7 +17,7 @@ class AssetActivity(Document):
asset: DF.Link
date: DF.Datetime
subject: DF.SmallText
subject: DF.TextEditor
user: DF.Link
# end: auto-generated types

View File

@@ -121,7 +121,7 @@
},
{
"fieldname": "notes",
"fieldtype": "Small Text",
"fieldtype": "Text Editor",
"label": "Notes",
"read_only": 1
},
@@ -217,7 +217,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2025-05-23 01:17:16.708004",
"modified": "2026-09-25 12:00:00.000000",
"modified_by": "Administrator",
"module": "Assets",
"name": "Asset Depreciation Schedule",

View File

@@ -40,7 +40,7 @@ class AssetDepreciationSchedule(DepreciationScheduleController):
frequency_of_depreciation: DF.Int
naming_series: DF.Literal["ACC-ADS-.YYYY.-"]
net_purchase_amount: DF.Currency
notes: DF.SmallText | None
notes: DF.TextEditor | None
opening_accumulated_depreciation: DF.Currency
opening_number_of_booked_depreciations: DF.Int
rate_of_depreciation: DF.Percent

View File

@@ -163,6 +163,7 @@
"mps",
"is_internal_supplier",
"inter_company_order_reference",
"remarks",
"connections_tab"
],
"fields": [
@@ -931,6 +932,13 @@
"options": "Sales Order",
"read_only": 1
},
{
"fieldname": "remarks",
"fieldtype": "Small Text",
"label": "Remarks",
"no_copy": 1,
"print_hide": 1
},
{
"fieldname": "column_break_74",
"fieldtype": "Column Break"
@@ -1300,7 +1308,7 @@
"idx": 105,
"is_submittable": 1,
"links": [],
"modified": "2026-08-12 12:00:00.000000",
"modified": "2026-09-24 12:00:00.000000",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Order",

View File

@@ -116,6 +116,7 @@ class PurchaseOrder(BuyingController):
pricing_rules: DF.Table[PricingRuleDetail]
project: DF.Link | None
ref_sq: DF.Link | None
remarks: DF.SmallText | None
represents_company: DF.Link | None
rounded_total: DF.Currency
rounding_adjustment: DF.Currency

View File

@@ -1,6 +1,7 @@
# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import importlib
import frappe
from frappe import _, throw
@@ -11,6 +12,9 @@ import erpnext.buying.doctype.supplier_scorecard_variable.supplier_scorecard_var
from erpnext.buying.doctype.supplier_scorecard_criteria.supplier_scorecard_criteria import (
get_variables,
)
from erpnext.buying.doctype.supplier_scorecard_variable.supplier_scorecard_variable import (
VariablePathNotFound,
)
class SupplierScorecardPeriod(Document):
@@ -119,11 +123,30 @@ class SupplierScorecardPeriod(Document):
def import_string_path(path):
components = path.split(".")
mod = __import__(components[0])
for comp in components[1:]:
mod = getattr(mod, comp)
return mod
app_name = path.split(".", 1)[0]
if app_name not in frappe.get_installed_apps():
throw(_("App {0} is not installed").format(app_name), frappe.AppNotInstalledError)
target, attributes = import_longest_module(path)
for attribute in attributes:
if not hasattr(target, attribute):
throw(_("Could not find path for {0}").format(path), VariablePathNotFound)
target = getattr(target, attribute)
return target
def import_longest_module(path):
parts = path.split(".")
module = importlib.import_module(parts[0])
for index in range(1, len(parts)):
module_name = ".".join(parts[: index + 1])
try:
module = importlib.import_module(module_name)
except ModuleNotFoundError as error:
if error.name != module_name:
raise
return module, parts[index:]
return module, []
def make_supplier_scorecard(source_name, target_doc=None):

View File

@@ -1,10 +1,31 @@
# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import sys
import tempfile
from contextlib import contextmanager
from pathlib import Path
from unittest.mock import patch
import frappe
from erpnext.buying.doctype.supplier_scorecard_variable.supplier_scorecard_variable import (
VariablePathNotFound,
)
from erpnext.tests.utils import ERPNextTestSuite
CUSTOM_APP = "custom_scorecard_app"
CUSTOM_VARIABLES_SOURCE = """
def get_value(scorecard):
return 7
class Metrics:
@staticmethod
def get_value(scorecard):
return 7
"""
class TestSupplierScorecardPeriod(ERPNextTestSuite):
def test_criteria_score_is_clamped_to_bounds(self):
@@ -55,6 +76,60 @@ class TestSupplierScorecardPeriod(ERPNextTestSuite):
)
self.assertRaises(frappe.ValidationError, period.validate_criteria_weights)
def test_custom_variable_path_in_unimported_module(self):
for attribute in ("get_value", "Metrics.get_value"):
with self.subTest(attribute=attribute):
path = f"{CUSTOM_APP}.variables.{attribute}"
variable = make_variable(path)
period = make_period(
variables=[{"variable_label": "Custom", "param_name": "custom", "path": path}]
)
with unimported_custom_app():
variable.validate_path_exists()
with unimported_custom_app():
period.calculate_variables()
self.assertEqual(period.variables[0].value, 7)
def test_variable_path_outside_installed_apps_is_rejected(self):
period = make_period(variables=[{"variable_label": "OS", "param_name": "os", "path": "os.getcwd"}])
self.assertRaises(frappe.AppNotInstalledError, period.calculate_variables)
def test_missing_variable_path_is_rejected(self):
for path in ("erpnext.no_such_module.get_value", f"{CUSTOM_APP}.variables.missing"):
with self.subTest(path=path):
variable = make_variable(path)
with unimported_custom_app():
self.assertRaises(VariablePathNotFound, variable.validate_path_exists)
def test_variable_module_import_error_is_not_hidden(self):
variable = make_variable(f"{CUSTOM_APP}.broken.get_value")
with unimported_custom_app():
self.assertRaises(ModuleNotFoundError, variable.validate_path_exists)
@contextmanager
def unimported_custom_app():
with tempfile.TemporaryDirectory() as directory:
package = Path(directory, CUSTOM_APP)
package.mkdir()
(package / "__init__.py").touch()
(package / "variables.py").write_text(CUSTOM_VARIABLES_SOURCE)
(package / "broken.py").write_text("import scorecard_missing_dependency\n")
installed_apps = [*frappe.get_installed_apps(), CUSTOM_APP]
with (
patch.object(sys, "path", [directory, *sys.path]),
patch.dict(sys.modules),
patch.object(frappe, "get_installed_apps", return_value=installed_apps),
):
yield
def make_variable(path):
return frappe.get_doc({"doctype": "Supplier Scorecard Variable", "path": path})
def make_period(variables=None, criteria=None):
period = frappe.new_doc("Supplier Scorecard Period")

View File

@@ -36,14 +36,11 @@ class SupplierScorecardVariable(Document):
def validate_path_exists(self):
if "." in self.path:
try:
from erpnext.buying.doctype.supplier_scorecard_period.supplier_scorecard_period import (
import_string_path,
)
from erpnext.buying.doctype.supplier_scorecard_period.supplier_scorecard_period import (
import_string_path,
)
import_string_path(self.path)
except AttributeError:
frappe.throw(_("Could not find path for {0}").format(self.path), VariablePathNotFound)
import_string_path(self.path)
else:
if not hasattr(sys.modules[__name__], self.path):

View File

@@ -7,6 +7,8 @@ from frappe import _
from frappe.query_builder.functions import Min
from frappe.utils import flt
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies_condition
def execute(filters=None):
columns = get_columns(filters)
@@ -212,6 +214,8 @@ def get_mapped_mr_details(filters):
.where((parent.per_ordered >= 0) & (parent.name == child.parent) & (parent.docstatus == 1))
)
query = apply_filters_on_query(filters, parent, child, query)
if condition := get_allowed_companies_condition(parent.company, "Material Request"):
query = query.where(condition)
mr_details = query.run(as_dict=True)
@@ -293,6 +297,8 @@ def get_po_entries(filters):
.groupby(child.parent, child.material_request_item)
)
representative_lines = apply_filters_on_query(filters, parent, child, representative_lines)
if condition := get_allowed_companies_condition(parent.company, "Purchase Order"):
representative_lines = representative_lines.where(condition)
query = (
frappe.qb.from_(parent)

View File

@@ -5,6 +5,8 @@
import frappe
from frappe import _
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies_condition
def execute(filters=None):
if filters.from_date >= filters.to_date:
@@ -83,6 +85,10 @@ def get_subcontract_orders(filters):
["docstatus", "=", 1],
]
order = frappe.qb.DocType("Subcontracting Order")
if condition := get_allowed_companies_condition(order.company, "Subcontracting Order"):
record_filters.append(condition)
return frappe.get_all(
"Subcontracting Order", filters=record_filters, fields=["name", "transaction_date", "supplier"]
)

View File

@@ -5,6 +5,8 @@
import frappe
from frappe import _
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies_condition
def execute(filters=None):
if filters.from_date >= filters.to_date:
@@ -70,6 +72,10 @@ def get_order_items_to_supply(filters):
["Subcontracting Order", "docstatus", "=", 1],
]
order = frappe.qb.DocType("Subcontracting Order")
if condition := get_allowed_companies_condition(order.company, "Subcontracting Order"):
record_filters.append(condition)
return frappe.db.get_all(
"Subcontracting Order",
fields=[

View File

@@ -1153,7 +1153,7 @@ def set_default_income_account_for_item(obj):
obj: Transaction document containing items table with income_account field
"""
company_default = frappe.get_cached_value("Company", obj.company, "default_income_account")
for d in obj.get("items", default=[]):
for d in sorted(obj.get("items", default=[]), key=lambda row: row.item_code or ""):
income_account = getattr(d, "income_account", None)
if d.item_code and income_account and income_account != company_default:
set_item_default(d.item_code, obj.company, "income_account", income_account)

View File

@@ -228,7 +228,7 @@ class Lead(SellingController, CRMNote):
def has_quotation(self):
return frappe.db.get_value(
"Quotation", {"party_name": self.name, "docstatus": 1, "status": ["!=", "Lost"]}
"Quotation", {"party_name": self.name, "docstatus": 1, "is_active": 1, "status": ["!=", "Lost"]}
)
def has_lost_quotation(self):

View File

@@ -302,6 +302,7 @@ class Opportunity(TransactionBase, CRMNote):
"opportunity": self.name,
"status": ("not in", ["Lost", "Cancelled", "Expired"]),
"docstatus": 1,
"is_active": 1,
},
"name",
)
@@ -315,6 +316,7 @@ class Opportunity(TransactionBase, CRMNote):
.select(q.name)
.where(
(q.docstatus == 1)
& (q.is_active == 1)
& (qi.prevdoc_docname == self.name)
& q.status.notin(["Lost", "Cancelled", "Expired"])
)

View File

@@ -336,14 +336,14 @@ permission_query_conditions = {
"Item": "erpnext.stock.doctype.company_restriction.company_restriction.get_permission_query_conditions",
"Customer": "erpnext.stock.doctype.company_restriction.company_restriction.get_permission_query_conditions",
"Supplier": "erpnext.stock.doctype.company_restriction.company_restriction.get_permission_query_conditions",
"Item Price": "erpnext.stock.doctype.company_restriction.company_restriction.get_inherited_permission_query_conditions",
"*": "erpnext.stock.doctype.company_restriction.company_restriction.get_inherited_permission_query_conditions",
}
has_permission = {
"Item": "erpnext.stock.doctype.company_restriction.company_restriction.has_permission",
"Customer": "erpnext.stock.doctype.company_restriction.company_restriction.has_permission",
"Supplier": "erpnext.stock.doctype.company_restriction.company_restriction.has_permission",
"Item Price": "erpnext.stock.doctype.company_restriction.company_restriction.has_inherited_permission",
"*": "erpnext.stock.doctype.company_restriction.company_restriction.has_inherited_permission",
}
has_website_permission = {
@@ -382,14 +382,6 @@ period_closing_doctypes = [
"Subcontracting Receipt",
]
pre_submit_validation_doctypes = [
"Sales Invoice",
"Purchase Invoice",
"Delivery Note",
"Purchase Receipt",
"Sales Order",
]
sqlite_search = ["erpnext.stock.doctype.item.item_search.ItemSearch"]
doc_events = {
@@ -403,20 +395,9 @@ doc_events = {
tuple(period_closing_doctypes): {
"validate": "erpnext.accounts.doctype.accounting_period.accounting_period.validate_accounting_period_on_doc_save",
},
tuple(pre_submit_validation_doctypes): {
"validate": "erpnext.accounts.utils.pre_submit_validation",
},
("Item", "Customer", "Supplier"): {
"validate": "erpnext.stock.doctype.company_restriction.company_restriction.validate_allowed_companies",
},
"Item": {
"on_update": "erpnext.stock.doctype.item.item_search.reindex_item",
"after_rename": "erpnext.stock.doctype.item.item_search.reindex_renamed_item",
},
"Stock Entry": {
"on_submit": "erpnext.stock.doctype.material_request.material_request.update_completed_and_requested_qty",
"on_cancel": "erpnext.stock.doctype.material_request.material_request.update_completed_and_requested_qty",
},
"User": {
"after_insert": "frappe.contacts.doctype.contact.contact.update_contact",
"validate": "erpnext.setup.doctype.employee.employee.validate_employee_role",

View File

@@ -104,7 +104,9 @@ frappe.ui.form.on("Blanket Order", {
if (frm.doc.blanket_order_type === "Selling") {
frm.set_df_property("customer", "reqd", 1);
frm.set_df_property("supplier", "reqd", 0);
frm.set_value("supplier", "");
if (frm.doc.supplier) {
frm.set_value("supplier", "");
}
frm.set_query("tc_name", function () {
return { filters: { selling: 1 } };
@@ -113,7 +115,9 @@ frappe.ui.form.on("Blanket Order", {
if (frm.doc.blanket_order_type === "Purchasing") {
frm.set_df_property("supplier", "reqd", 1);
frm.set_df_property("customer", "reqd", 0);
frm.set_value("customer", "");
if (frm.doc.customer) {
frm.set_value("customer", "");
}
frm.set_query("tc_name", function () {
return { filters: { buying: 1 } };

View File

@@ -1490,6 +1490,7 @@ def _build_base_bom_items_query(bom, company, qty, t):
Min(t.bom_item.idx).as_("idx"),
Max(t.item_doc.item_name).as_("item_name"),
(Sum(t.qty_field_col / IfNull(t.bom_doc.quantity, 1)) * qty).as_("qty"),
(Sum(t.bom_item.stock_qty / IfNull(t.bom_doc.quantity, 1)) * qty).as_("stock_qty"),
Max(t.item_doc.image).as_("image"),
Max(t.bom_doc.project).as_("project"),
Max(t.item_doc.stock_uom).as_("stock_uom"),
@@ -1607,19 +1608,20 @@ def _add_bom_item_to_dict(item_dict, item, company, opts):
if item.operation:
key = (item.item_code, item.operation)
stock_qty = item.pop("stock_qty")
if item.get("is_phantom_item"):
_merge_phantom_bom_items(item_dict, item, company, opts)
_merge_phantom_bom_items(item_dict, item, stock_qty, company, opts)
elif key in item_dict:
item_dict[key]["qty"] += flt(item.qty)
else:
item_dict[key] = item
def _merge_phantom_bom_items(item_dict, item, company, opts):
def _merge_phantom_bom_items(item_dict, item, stock_qty, company, opts):
data = get_bom_items_as_dict(
item.get("bom_no"),
company,
qty=item.get("qty"),
qty=stock_qty,
fetch_exploded=opts.fetch_exploded,
fetch_secondary_items=opts.fetch_secondary_items,
include_non_stock_items=opts.include_non_stock_items,

View File

@@ -141,6 +141,36 @@ class TestBOM(ERPNextTestSuite):
self.assertEqual(flt(items_dict[component].qty), 1.0)
self.assertNotIn(rm_normal, items_dict)
@timeout
def test_get_items_explodes_phantom_row_by_stock_qty(self):
from erpnext.manufacturing.doctype.bom.bom import get_bom_items_as_dict
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
rm = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
kit = make_item(
properties={"is_stock_item": 0, "uoms": [{"uom": "Box", "conversion_factor": 5}]}
).name
phantom_bom = make_bom(item=kit, raw_materials=[rm], do_not_save=True)
phantom_bom.is_phantom_bom = 1
phantom_bom.save()
phantom_bom.submit()
fg_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
bom = make_bom(item=fg_item, raw_materials=[kit], do_not_save=True)
bom.items[0].update({"qty": 2, "uom": "Box", "bom_no": phantom_bom.name})
bom.save()
bom.submit()
for fetch_qty_in_stock_uom in (True, False):
items_dict = get_bom_items_as_dict(
bom.name,
"_Test Company",
qty=1,
fetch_exploded=0,
fetch_qty_in_stock_uom=fetch_qty_in_stock_uom,
)
self.assertEqual(flt(items_dict[rm].qty), 10.0)
@timeout
def test_get_items_amount_uses_each_lines_own_rate(self):
from erpnext.manufacturing.doctype.bom.bom import get_bom_items_as_dict

View File

@@ -1789,6 +1789,100 @@ class TestJobCard(ERPNextTestSuite):
8,
)
def test_semi_fg_secondary_items_across_split_job_cards(self):
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.manufacturing.doctype.work_order.mapper import make_job_card
from erpnext.stock.doctype.item.test_item import make_item
warehouse = "Stores - _TC"
rm = make_item("Split JC Scrap RM", {"is_stock_item": 1, "valuation_rate": 100}).name
fg = make_item("Split JC Scrap FG", {"is_stock_item": 1}).name
scrap = make_item("Split JC Scrap", {"is_stock_item": 1, "valuation_rate": 5}).name
fg_bom = frappe.new_doc(
"BOM",
company="_Test Company",
item=fg,
quantity=1,
with_operations=1,
track_semi_finished_goods=1,
)
fg_bom.append("items", {"item_code": rm, "qty": 1, "operation_row_id": 1})
fg_bom.append("secondary_items", {"item_code": scrap, "qty": 1, "secondary_item_type": "Scrap"})
operation = {
"operation": "Split JC Scrap Op",
"workstation": "_Test Workstation A",
"finished_good": fg,
"finished_good_qty": 1,
"is_final_finished_good": 1,
"sequence_id": 1,
"time_in_mins": 60,
"source_warehouse": warehouse,
"fg_warehouse": warehouse,
"skip_material_transfer": 1,
}
make_workstation(operation)
make_operation(operation)
fg_bom.append("operations", operation)
fg_bom.insert()
fg_bom.submit()
work_order = make_wo_order_test_record(
item=fg,
qty=10,
source_warehouse=warehouse,
fg_warehouse=warehouse,
bom_no=fg_bom.name,
skip_transfer=1,
do_not_save=True,
)
work_order.operations[0].time_in_mins = 60
work_order.save()
work_order.submit()
make_stock_entry(item_code=rm, target=warehouse, qty=100, basic_rate=100)
job_card = frappe.get_doc(
"Job Card", frappe.db.get_value("Job Card", {"work_order": work_order.name}, "name")
)
job_card.for_quantity = 5
job_card.secondary_items[0].stock_qty = 5
job_card.append(
"time_logs",
{"from_time": "2024-02-01 08:00:00", "to_time": "2024-02-01 09:00:00", "completed_qty": 5},
)
job_card.save()
job_card.submit()
frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item()).submit()
make_job_card(
work_order.name,
[
{
"name": work_order.operations[0].name,
"operation": "Split JC Scrap Op",
"qty": 5,
"pending_qty": 5,
"skip_material_transfer": 1,
}
],
)
job_card = frappe.get_doc(
"Job Card", frappe.db.get_value("Job Card", {"work_order": work_order.name, "docstatus": 0})
)
job_card.append(
"time_logs",
{"from_time": "2024-02-02 08:00:00", "to_time": "2024-02-02 09:00:00", "completed_qty": 5},
)
job_card.save()
job_card.submit()
stock_entry = frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item())
scrap_qty = sum(row.qty for row in stock_entry.items if row.item_code == scrap)
self.assertEqual(scrap_qty, 5)
def test_batch_split_operation_creates_child_batches(self):
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.stock.doctype.item.test_item import make_item

View File

@@ -57,7 +57,7 @@
},
{
"fieldname": "description",
"fieldtype": "Text",
"fieldtype": "Text Editor",
"label": "Description",
"read_only": 1
},
@@ -116,7 +116,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-06-23 16:52:37.669110",
"modified": "2026-09-25 12:00:00.000000",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Job Card Item",

View File

@@ -16,7 +16,7 @@ class JobCardItem(Document):
allow_alternative_item: DF.Check
consumed_qty: DF.Float
description: DF.Text | None
description: DF.TextEditor | None
item_code: DF.Link
item_group: DF.Link | None
item_name: DF.Data | None

View File

@@ -32,7 +32,6 @@ from erpnext.manufacturing.doctype.production_plan.services.material_request imp
)
from erpnext.manufacturing.doctype.production_plan.services.reservation import (
cancel_stock_reservation_entries,
get_non_completed_production_plans,
get_reserved_qty_for_production_plan,
get_reserved_qty_for_sub_assembly,
make_stock_reservation_entries,
@@ -267,8 +266,14 @@ class ProductionPlan(Document):
data.db_update()
self.calculate_total_produced_qty()
self.update_status_and_bin_qty()
def update_status_and_bin_qty(self):
previous_status = self.status
self.set_status()
self.db_set("status", self.status)
if previous_status != self.status and "Completed" in (previous_status, self.status):
self.update_bin_qty()
def on_submit(self):
self.update_bin_qty()
@@ -362,11 +367,7 @@ class ProductionPlan(Document):
return so_wise_planned_qty
def update_bin_qty(self):
for d in self.mr_items:
if d.warehouse:
bin_name = get_or_make_bin(d.item_code, d.warehouse)
bin = frappe.get_doc("Bin", bin_name, for_update=True)
bin.update_reserved_qty_for_production_plan()
self.update_raw_material_bin_qty()
for d in self.sub_assembly_items:
if d.fg_warehouse and d.type_of_manufacturing == "In House":
@@ -374,6 +375,13 @@ class ProductionPlan(Document):
bin = frappe.get_doc("Bin", bin_name, for_update=True)
bin.update_reserved_qty_for_for_sub_assembly()
def update_raw_material_bin_qty(self, item_codes: set[str] | None = None):
for d in self.mr_items:
if d.warehouse and (item_codes is None or d.item_code in item_codes):
bin_name = get_or_make_bin(d.item_code, d.warehouse)
bin = frappe.get_doc("Bin", bin_name, for_update=True)
bin.update_reserved_qty_for_production_plan()
def delete_draft_work_order(self):
for d in frappe.get_all(
"Work Order", fields=["name"], filters={"docstatus": 0, "production_plan": ("=", self.name)}
@@ -384,6 +392,9 @@ class ProductionPlan(Document):
def set_status(self, close: bool | None = None, update_bin: bool = False):
self.check_permission("write")
if close is None and self.status == "Closed":
return
self.status = {0: "Draft", 1: "Submitted", 2: "Cancelled"}.get(self.docstatus)
if close:

View File

@@ -27,58 +27,85 @@ _RESERVATION_TABLES = {
def get_reserved_qty_for_production_plan(item_code, warehouse):
from erpnext.manufacturing.doctype.work_order.work_order import get_reserved_qty_for_production
non_completed_production_plans = get_non_completed_production_plans()
reserved = _production_plan_reserved_qty(item_code, warehouse, non_completed_production_plans)
if reserved is None:
plan_reservations = _get_plan_reservations(item_code)
if not plan_reservations:
return None
for_production = flt(
get_reserved_qty_for_production(
item_code, warehouse, non_completed_production_plans, check_production_plan=True
work_order_reservations = _get_work_order_reservations(item_code, list(plan_reservations))
reserved_qty = 0.0
for plan, plan_qty_by_warehouse in plan_reservations.items():
reserved_qty += _get_remaining_reserved_qty(
plan_qty_by_warehouse, work_order_reservations.get(plan, {}), warehouse
)
)
if for_production > reserved:
return reserved_qty
def _get_remaining_reserved_qty(plan_qty_by_warehouse, work_order_qty_by_warehouse, warehouse):
remaining_qty_by_warehouse = {
plan_warehouse: max(qty - work_order_qty_by_warehouse.get(plan_warehouse, 0.0), 0.0)
for plan_warehouse, qty in plan_qty_by_warehouse.items()
}
total_remaining_qty = sum(remaining_qty_by_warehouse.values())
if not total_remaining_qty:
return 0.0
return reserved - for_production
matched_qty = sum(plan_qty_by_warehouse.values()) - total_remaining_qty
unmatched_qty = min(sum(work_order_qty_by_warehouse.values()) - matched_qty, total_remaining_qty)
remaining_qty = remaining_qty_by_warehouse.get(warehouse, 0.0)
return remaining_qty - remaining_qty * unmatched_qty / total_remaining_qty
def _production_plan_reserved_qty(item_code, warehouse, non_completed_production_plans):
def _get_plan_reservations(item_code):
table = frappe.qb.DocType("Production Plan")
child = frappe.qb.DocType("Material Request Plan Item")
query = (
frappe.qb.from_(table)
.inner_join(child)
.on(table.name == child.parent)
.select(Sum(child.required_bom_qty))
.where(_plan_reserved_filter(table, child, item_code, warehouse))
.select(
table.name.as_("production_plan"),
child.warehouse,
Sum(child.required_bom_qty).as_("reserved_qty"),
)
.where(
(table.docstatus == 1)
& (child.item_code == item_code)
& (table.status.notin(["Completed", "Closed"]))
)
.groupby(table.name, child.warehouse)
)
if non_completed_production_plans:
query = query.where(table.name.isin(non_completed_production_plans))
result = query.run()
return flt(result[0][0]) if result and result[0][0] is not None else None
return _group_by_plan_and_warehouse(query)
def _plan_reserved_filter(table, child, item_code, warehouse):
return (
(table.docstatus == 1)
& (child.item_code == item_code)
& (child.warehouse == warehouse)
& (table.status.notin(["Completed", "Closed"]))
def _get_work_order_reservations(item_code, plan_names):
work_order = frappe.qb.DocType("Work Order")
work_order_item = frappe.qb.DocType("Work Order Item")
query = (
frappe.qb.from_(work_order)
.from_(work_order_item)
.select(
work_order.production_plan,
work_order_item.source_warehouse.as_("warehouse"),
Sum(work_order_item.required_qty).as_("reserved_qty"),
)
.where(
(work_order_item.item_code == item_code)
& (work_order_item.parent == work_order.name)
& (work_order.docstatus == 1)
& (IfNull(work_order_item.source_warehouse, "") != "")
& work_order.production_plan.isin(plan_names)
)
.groupby(work_order.production_plan, work_order_item.source_warehouse)
)
return _group_by_plan_and_warehouse(query)
def get_non_completed_production_plans():
table = frappe.qb.DocType("Production Plan")
return (
frappe.qb.from_(table)
.select(table.name)
.distinct()
.where((table.docstatus == 1) & (table.status.notin(["Completed", "Closed"])))
).run(pluck="name")
def _group_by_plan_and_warehouse(query):
reservations = {}
for row in query.run(as_dict=True):
reservations.setdefault(row.production_plan, {})[row.warehouse] = flt(row.reserved_qty)
return reservations
def get_reserved_qty_for_sub_assembly(item_code, warehouse):

View File

@@ -6,7 +6,6 @@ from frappe.utils import add_to_date, flt, getdate, now_datetime, nowdate
from erpnext.controllers.item_variant import create_variant
from erpnext.manufacturing.doctype.production_plan.production_plan import (
get_items_for_material_requests,
get_non_completed_production_plans,
get_sales_orders,
get_warehouse_list,
)
@@ -1597,6 +1596,183 @@ class TestProductionPlan(ERPNextTestSuite):
self.assertEqual(after_qty, before_qty)
def test_plan_reservation_offsets_work_order_in_another_warehouse(self):
from erpnext.manufacturing.doctype.production_plan.services.reservation import (
get_reserved_qty_for_production_plan,
)
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
fg_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
plan_warehouse = "_Test Warehouse - _TC"
work_order_warehouse = "_Test Warehouse 1 - _TC"
make_bom(item=fg_item, raw_materials=[rm_item], source_warehouse=plan_warehouse)
plan = create_production_plan(
item_code=fg_item, planned_qty=10, ignore_existing_ordered_qty=1, do_not_submit=1
)
plan.submit()
self.assertEqual(get_reserved_qty_for_production_plan(rm_item, plan_warehouse), 10)
bin_name = frappe.db.get_value("Bin", {"item_code": rm_item, "warehouse": plan_warehouse}, "name")
bin = frappe.get_doc("Bin", bin_name)
self.assertEqual(bin.reserved_qty_for_production_plan, 10)
projected_qty = bin.projected_qty
work_order = submit_work_order_from_plan(plan, 5, work_order_warehouse)
self.assertEqual(get_reserved_qty_for_production_plan(rm_item, plan_warehouse), 5)
bin.reload()
self.assertEqual(bin.reserved_qty_for_production_plan, 5)
self.assertEqual(bin.projected_qty, projected_qty + 5)
work_order.cancel()
bin.reload()
self.assertEqual(bin.reserved_qty_for_production_plan, 10)
self.assertEqual(bin.projected_qty, projected_qty)
def test_plan_reservation_ignores_work_orders_of_other_plans(self):
from erpnext.manufacturing.doctype.production_plan.services.reservation import (
get_reserved_qty_for_production_plan,
)
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
first_warehouse = "_Test Warehouse - _TC"
second_warehouse = "_Test Warehouse 1 - _TC"
plans = []
for warehouse in (first_warehouse, second_warehouse):
fg_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
make_bom(item=fg_item, raw_materials=[rm_item], source_warehouse=warehouse)
plans.append(
create_production_plan(item_code=fg_item, planned_qty=10, ignore_existing_ordered_qty=1)
)
submit_work_order_from_plan(plans[1], 10, first_warehouse)
self.assertEqual(get_reserved_qty_for_production_plan(rm_item, first_warehouse), 10)
self.assertEqual(get_reserved_qty_for_production_plan(rm_item, second_warehouse), 0)
def test_plan_reservation_kept_for_work_order_without_source_warehouse(self):
from erpnext.manufacturing.doctype.production_plan.services.reservation import (
get_reserved_qty_for_production_plan,
)
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
fg_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
plan_warehouse = "_Test Warehouse - _TC"
make_bom(item=fg_item, raw_materials=[rm_item], source_warehouse=plan_warehouse)
plan = create_production_plan(item_code=fg_item, planned_qty=10, ignore_existing_ordered_qty=1)
submit_work_order_from_plan(plan, 5, None)
self.assertEqual(get_reserved_qty_for_production_plan(rm_item, plan_warehouse), 10)
self.assertEqual(
frappe.db.get_value(
"Bin", {"item_code": rm_item, "warehouse": plan_warehouse}, "reserved_qty_for_production_plan"
),
10,
)
def test_plan_reservation_released_when_plan_completes(self):
plan, work_order = make_plan_with_sub_assembly()
work_order.submit()
make_stock_entry(
item_code=plan.sub_assembly_items[0].production_item,
qty=5,
rate=10,
target=work_order.source_warehouse,
)
frappe.get_doc(make_se_from_wo(work_order.name, "Material Transfer for Manufacture", 5)).submit()
raw_material = plan.mr_items[0]
bin = frappe.get_doc(
"Bin", {"item_code": raw_material.item_code, "warehouse": raw_material.warehouse}
)
self.assertEqual(bin.reserved_qty_for_production_plan, 5)
manufacture = frappe.get_doc(make_se_from_wo(work_order.name, "Manufacture", 5))
manufacture.submit()
self.assertEqual(frappe.db.get_value("Production Plan", plan.name, "status"), "Completed")
bin.reload()
self.assertEqual(bin.reserved_qty_for_production_plan, 0)
manufacture.cancel()
bin.reload()
self.assertEqual(bin.reserved_qty_for_production_plan, 5)
def test_plan_reservation_released_when_last_work_order_is_closed(self):
from erpnext.manufacturing.doctype.work_order.work_order import close_work_order
plan, work_order = make_plan_with_sub_assembly()
work_order.submit()
plan.make_work_order()
sub_assembly = plan.sub_assembly_items[0]
sub_assembly_work_order = frappe.get_doc(
"Work Order", {"production_plan": plan.name, "production_item": sub_assembly.production_item}
)
sub_assembly_work_order.wip_warehouse = "_Test Warehouse 2 - _TC"
sub_assembly_work_order.submit()
make_stock_entry(
item_code=sub_assembly.production_item, qty=5, rate=10, target=work_order.source_warehouse
)
frappe.get_doc(make_se_from_wo(work_order.name, "Material Transfer for Manufacture", 5)).submit()
frappe.get_doc(make_se_from_wo(work_order.name, "Manufacture", 5)).submit()
bin = frappe.get_doc(
"Bin", {"item_code": sub_assembly.production_item, "warehouse": sub_assembly.fg_warehouse}
)
self.assertEqual(bin.reserved_qty_for_production_plan, 5)
close_work_order(sub_assembly_work_order.name, "Closed")
self.assertEqual(frappe.db.get_value("Production Plan", plan.name, "status"), "Completed")
bin.reload()
self.assertEqual(bin.reserved_qty_for_production_plan, 0)
def test_closed_plan_stays_closed_on_production(self):
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
fg_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
warehouse = "_Test Warehouse - _TC"
make_bom(item=fg_item, raw_materials=[rm_item], source_warehouse=warehouse)
plan = create_production_plan(item_code=fg_item, planned_qty=10, ignore_existing_ordered_qty=1)
work_order = submit_work_order_from_plan(plan, 5, warehouse)
plan.set_status(close=True)
make_stock_entry(item_code=rm_item, qty=5, rate=10, target=warehouse)
frappe.get_doc(make_se_from_wo(work_order.name, "Material Transfer for Manufacture", 5)).submit()
frappe.get_doc(make_se_from_wo(work_order.name, "Manufacture", 5)).submit()
self.assertEqual(frappe.db.get_value("Production Plan", plan.name, "status"), "Closed")
self.assertEqual(
frappe.db.get_value(
"Bin", {"item_code": rm_item, "warehouse": warehouse}, "reserved_qty_for_production_plan"
),
0,
)
def test_plan_reservation_offsets_are_distributed_across_warehouses(self):
from erpnext.manufacturing.doctype.production_plan.services.reservation import (
_get_remaining_reserved_qty,
)
reservations = {"Warehouse A": 6, "Warehouse B": 4}
cases = [
({"Warehouse A": 5}, 1, 4),
({"Warehouse C": 5}, 3, 2),
({"Warehouse A": 8}, 0, 2),
({"Warehouse C": 20}, 0, 0),
]
for work_order_reservations, warehouse_a_qty, warehouse_b_qty in cases:
with self.subTest(work_order_reservations=work_order_reservations):
self.assertEqual(
_get_remaining_reserved_qty(reservations, work_order_reservations, "Warehouse A"),
warehouse_a_qty,
)
self.assertEqual(
_get_remaining_reserved_qty(reservations, work_order_reservations, "Warehouse B"),
warehouse_b_qty,
)
self.assertEqual(
_get_remaining_reserved_qty({"Warehouse A": 5}, {"Warehouse B": 4}, "Warehouse A"), 1
)
def test_reserved_qty_for_production_plan_for_less_rm_qty(self):
from erpnext.stock.utils import get_or_make_bin
@@ -1612,7 +1788,6 @@ class TestProductionPlan(ERPNextTestSuite):
pln.make_work_order()
plans = []
for row in frappe.get_all("Work Order", filters={"production_plan": pln.name}, fields=["name"]):
wo_doc = frappe.get_doc("Work Order", row.name)
wo_doc.source_warehouse = "_Test Warehouse - _TC"
@@ -1629,16 +1804,12 @@ class TestProductionPlan(ERPNextTestSuite):
)
wo_doc.submit()
plans.append(pln.name)
bin_name = get_or_make_bin("Raw Material Item 1", "_Test Warehouse - _TC")
after_qty = flt(frappe.db.get_value("Bin", bin_name, "reserved_qty_for_production_plan"))
self.assertEqual(after_qty, before_qty)
non_completed_plans = get_non_completed_production_plans()
for plan in plans:
self.assertIn(plan, non_completed_plans)
self.assertNotIn(frappe.db.get_value("Production Plan", pln.name, "status"), ["Completed", "Closed"])
def test_reserved_qty_for_production_plan_for_material_requests_with_multi_UOM(self):
from erpnext.stock.utils import get_or_make_bin
@@ -4047,6 +4218,51 @@ def create_production_plan(**args):
return pln
def submit_work_order_from_plan(plan, qty, source_warehouse):
production_item = next(iter(plan.get_production_items().values()))
production_item["qty"] = qty
work_order = frappe.get_doc("Work Order", plan.create_work_order(production_item))
work_order.source_warehouse = source_warehouse
work_order.wip_warehouse = "_Test Warehouse 2 - _TC"
work_order.fg_warehouse = "_Test Warehouse - _TC"
for item in work_order.required_items:
item.source_warehouse = source_warehouse
work_order.submit()
return work_order
def make_plan_with_sub_assembly():
warehouse = "_Test Warehouse - _TC"
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
sub_assembly_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
fg_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
make_bom(item=sub_assembly_item, raw_materials=[rm_item], source_warehouse=warehouse)
make_bom(item=fg_item, raw_materials=[sub_assembly_item], source_warehouse=warehouse)
plan = create_production_plan(
item_code=fg_item,
planned_qty=5,
ignore_existing_ordered_qty=1,
sub_assembly_warehouse="_Test Warehouse 1 - _TC",
skip_getting_mr_items=1,
do_not_submit=1,
)
plan.get_sub_assembly_items()
for row in get_items_for_material_requests(plan.as_dict()):
plan.append("mr_items", row)
plan.submit()
production_item = next(iter(plan.get_production_items().values()))
production_item["use_multi_level_bom"] = 0
work_order = frappe.get_doc("Work Order", plan.create_work_order(production_item))
work_order.source_warehouse = warehouse
work_order.wip_warehouse = "_Test Warehouse 2 - _TC"
work_order.fg_warehouse = warehouse
for item in work_order.required_items:
item.source_warehouse = warehouse
return plan, work_order
def make_bom(**args):
args = frappe._dict(args)

View File

@@ -140,7 +140,7 @@
},
{
"fieldname": "description",
"fieldtype": "Small Text",
"fieldtype": "Text Editor",
"label": "description",
"read_only": 1
},
@@ -278,7 +278,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-08-12 00:00:00.000000",
"modified": "2026-09-25 12:00:00.000000",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Production Plan Sub Assembly Item",

View File

@@ -18,7 +18,7 @@ class ProductionPlanSubAssemblyItem(Document):
actual_qty: DF.Float
bom_level: DF.Int
bom_no: DF.Link | None
description: DF.SmallText | None
description: DF.TextEditor | None
fg_warehouse: DF.Link | None
indent: DF.Int
item_name: DF.Data | None

View File

@@ -35,11 +35,36 @@ class SalesForecast(Document):
self.db_set("status", "Cancelled")
def generate_manual_demand(self):
if not self.selected_items:
return
item_details_by_code = {
item.name: item
for item in frappe.get_all(
"Item",
filters={"name": ["in", [row.item_code for row in self.selected_items]]},
fields=["name", "item_name", "stock_uom as uom"],
)
}
ascii_items_by_lowercase_code = {}
if frappe.db.db_type == "mariadb":
# Unicode casefold does not always match MariaDB's collation.
ascii_items_by_lowercase_code = {
code.lower(): item for code, item in item_details_by_code.items() if code.isascii()
}
forecast_demand = []
for row in self.selected_items:
item_details = frappe.db.get_value(
"Item", row.item_code, ["item_name", "stock_uom as uom"], as_dict=True
)
if row.item_code not in item_details_by_code:
matching_item = None
if row.item_code.isascii():
matching_item = ascii_items_by_lowercase_code.get(row.item_code.lower())
# Fall back to the database for other collation-equivalent Item codes.
item_details_by_code[row.item_code] = matching_item or frappe.db.get_value(
"Item", row.item_code, ["item_name", "stock_uom as uom"], as_dict=True
)
item_details = item_details_by_code[row.item_code]
for index in range(self.demand_number):
if self.frequency == "Monthly":

View File

@@ -1,4 +1,35 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
from types import SimpleNamespace
from unittest.mock import Mock, patch
import frappe
from frappe.tests import UnitTestCase
from erpnext.manufacturing.doctype.sales_forecast.sales_forecast import SalesForecast
class TestSalesForecast(UnitTestCase):
def test_generate_demand_with_casefold_collision(self):
forecast = SimpleNamespace(
selected_items=[SimpleNamespace(item_code="SF-ss"), SimpleNamespace(item_code="SF-ẞ")],
from_date="2026-01-01",
frequency="Monthly",
demand_number=1,
items=[],
)
forecast.append = lambda field, demand: forecast.items.append(demand)
items = [
frappe._dict(name="SF-SS", item_name="ASCII Item", uom="Nos"),
frappe._dict(name="SF-ẞ", item_name="Unicode Item", uom="Kg"),
]
db = SimpleNamespace(db_type="mariadb", get_value=Mock(side_effect=AssertionError))
with patch.object(frappe, "get_all", return_value=items), patch.object(frappe, "db", db):
SalesForecast.generate_manual_demand(forecast)
self.assertEqual(
[(row["item_name"], row["uom"]) for row in forecast.items],
[("ASCII Item", "Nos"), ("Unicode Item", "Kg")],
)

View File

@@ -49,6 +49,7 @@ _SERIAL_BATCH_FIELDS = [
"`tabSerial and Batch Bundle`.`item_code`",
"`tabSerial and Batch Bundle`.`voucher_detail_no`",
]
CONSUMPTION_PURPOSES = ("Manufacture", "Material Consumption for Manufacture")
class WorkOrderStockReservation:
@@ -135,15 +136,17 @@ class WorkOrderStockReservation:
@staticmethod
def _apply_reservation_transfer(doc, qty_to_update, row_wise_serial_batch):
doc.db_set("transferred_qty", flt(qty_to_update), update_modified=False)
if (doc.has_batch_no or doc.has_serial_no) and doc.reservation_based_on == "Serial and Batch":
doc.consume_serial_batch_for_material_transfer(row_wise_serial_batch)
qty_to_update = doc.matched_serial_batch_qty
doc.db_set("transferred_qty", flt(qty_to_update), update_modified=False)
if doc.transferred_qty >= doc.reserved_qty:
doc.db_set("status", "Closed", update_modified=False)
doc.update_status()
doc.update_reserved_stock_in_bin()
doc.update_reserved_qty_in_voucher()
def update_consumed_qty_in_stock_reservation(self, item, consumed_qty, wip_warehouse):
filters = {
@@ -156,7 +159,7 @@ class WorkOrderStockReservation:
if not self.doc.skip_transfer:
filters["from_voucher_no"] = ("is", "set")
row_wise_serial_batch = get_row_wise_serial_batch(self.doc.name, "Manufacture")
row_wise_serial_batch = get_row_wise_serial_batch(self.doc.name, CONSUMPTION_PURPOSES)
names = frappe.get_all("Stock Reservation Entry", filters=filters, pluck="name", order_by="creation")
for name in names:
consumed_qty = self._apply_consumed_qty(name, consumed_qty, row_wise_serial_batch)
@@ -172,9 +175,11 @@ class WorkOrderStockReservation:
if (doc.has_batch_no or doc.has_serial_no) and doc.reservation_based_on == "Serial and Batch":
doc.consume_serial_batch_for_material_transfer(row_wise_serial_batch)
doc.db_set("consumed_qty", doc.matched_serial_batch_qty, update_modified=False)
doc.update_status()
doc.update_reserved_stock_in_bin()
doc.update_reserved_qty_in_voucher()
return consumed_qty
def validate_reserved_qty(self):
@@ -663,7 +668,7 @@ def get_consumed_qty(work_order, item_code):
def _consumed_qty_filter(stock_entry, stock_entry_detail, work_order, item_code):
return (
(stock_entry.work_order == work_order)
& (stock_entry.purpose.isin(["Manufacture", "Material Consumption for Manufacture"]))
& (stock_entry.purpose.isin(CONSUMPTION_PURPOSES))
& (stock_entry.docstatus == 1)
& (stock_entry_detail.s_warehouse.isnotnull())
# An attributed row belongs to its original requirement, not both item codes.
@@ -671,31 +676,27 @@ def _consumed_qty_filter(stock_entry, stock_entry_detail, work_order, item_code)
)
def get_reserved_qty_for_production(
item_code: str,
warehouse: str,
non_completed_production_plans: list | None = None,
check_production_plan: bool = False,
) -> float:
def get_reserved_qty_for_production(item_code: str, warehouse: str) -> float:
"""Get total reserved quantity for any item in specified warehouse"""
wo = frappe.qb.DocType("Work Order")
wo_item = frappe.qb.DocType("Work Order Item")
qty_field = wo_item.required_qty if check_production_plan else _production_reserved_qty_field(wo, wo_item)
query = (
frappe.qb.from_(wo)
.from_(wo_item)
.select(Sum(qty_field))
.select(Sum(_production_reserved_qty_field(wo, wo_item)))
.where(
(wo_item.item_code == item_code)
& (wo_item.parent == wo.name)
& (wo.docstatus == 1)
& (wo_item.source_warehouse == warehouse)
& (wo.status.notin(["Stopped", "Completed", "Closed"]))
& (
(wo_item.required_qty > wo_item.transferred_qty)
| (wo_item.required_qty > wo_item.consumed_qty)
)
)
)
query = _apply_production_plan_filter(
query, wo, wo_item, check_production_plan, non_completed_production_plans
)
return query.run()[0][0] or 0.0
@@ -708,28 +709,12 @@ def _production_reserved_qty_field(wo, wo_item):
return qty_field.else_(wo_item.required_qty - wo_item.consumed_qty)
def _apply_production_plan_filter(query, wo, wo_item, check_production_plan, non_completed_production_plans):
if check_production_plan:
query = query.where(wo.production_plan.isnotnull())
else:
query = query.where(
(wo.status.notin(["Stopped", "Completed", "Closed"]))
& (
(wo_item.required_qty > wo_item.transferred_qty)
| (wo_item.required_qty > wo_item.consumed_qty)
)
)
if non_completed_production_plans:
query = query.where(wo.production_plan.isin(non_completed_production_plans))
return query
def get_row_wise_serial_batch(work_order, purpose=None):
purpose = purpose or "Material Transfer for Manufacture"
purposes = [purpose] if isinstance(purpose, str) else purpose
stock_entries = frappe.get_all(
"Stock Entry",
filters={"work_order": work_order, "purpose": purpose, "docstatus": 1},
filters={"work_order": work_order, "purpose": ("in", purposes), "docstatus": 1},
pluck="name",
)

View File

@@ -423,8 +423,8 @@ class StatusService:
doc = frappe.get_doc("Production Plan", self.doc.production_plan)
doc.flags.ignore_permissions = True
doc.set_status()
doc.db_set("status", doc.status)
doc.update_status_and_bin_qty()
doc.update_raw_material_bin_qty({d.item_code for d in self.doc.required_items})
def _production_plan_ordered_qty(self):
table = frappe.qb.DocType("Work Order")

View File

@@ -1072,6 +1072,20 @@ class TestWorkOrder(ERPNextTestSuite):
wo.load_from_db()
self.assertEqual(wo.status, "Completed")
from erpnext.stock.stock_balance import get_planned_qty
completed_planned_qty = get_bin(wo.production_item, wo.fg_warehouse).planned_qty
expected_completed_qty = get_planned_qty(wo.production_item, wo.fg_warehouse)
se.cancel()
wo.reload()
self.assertEqual(wo.status, "In Process")
cancelled_planned_qty = get_bin(wo.production_item, wo.fg_warehouse).planned_qty
expected_cancelled_qty = get_planned_qty(wo.production_item, wo.fg_warehouse)
self.assertEqual(completed_planned_qty, expected_completed_qty)
self.assertEqual(cancelled_planned_qty, expected_cancelled_qty)
@timeout(seconds=60)
def test_job_card_secondary_item(self):
items = [
@@ -4552,6 +4566,166 @@ class TestWorkOrder(ERPNextTestSuite):
self.assertRaises(frappe.ValidationError, transfer_entry.submit)
@ERPNextTestSuite.change_settings(
"Stock Settings",
{"enable_stock_reservation": 1, "auto_reserve_serial_and_batch": 1},
)
def test_transfer_frees_reserved_batch_in_source_warehouse(self):
production_item = "Test Transfer Batch Release FG"
rm_item = "Test Transfer Batch Release RM"
source_warehouse = "Stores - _TC"
make_item(production_item, {"is_stock_item": 1})
make_item(
rm_item,
{
"is_stock_item": 1,
"has_batch_no": 1,
"batch_number_series": "TST-BATCH-REL-.###",
"create_new_batch": 1,
},
)
make_bom(item=production_item, source_warehouse=source_warehouse, raw_materials=[rm_item])
receipt = test_stock_entry.make_stock_entry(
item_code=rm_item, target=source_warehouse, qty=30, basic_rate=100
)
batch_no = get_batch_from_bundle(receipt.items[0].serial_and_batch_bundle)
wo = make_wo_order_test_record(
item=production_item, qty=20, reserve_stock=1, source_warehouse=source_warehouse
)
frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 20)).submit()
issue = test_stock_entry.make_stock_entry(
item_code=rm_item,
source=source_warehouse,
qty=5,
batch_no=batch_no,
use_serial_batch_fields=1,
)
self.assertEqual(issue.docstatus, 1)
@ERPNextTestSuite.change_settings(
"Stock Settings",
{"enable_stock_reservation": 1, "auto_reserve_serial_and_batch": 1},
)
def test_transfer_of_other_batch_keeps_reservation_open(self):
production_item = "Test Other Batch Release FG"
rm_item = "Test Other Batch Release RM"
source_warehouse = "Stores - _TC"
make_item(production_item, {"is_stock_item": 1})
make_item(
rm_item,
{
"is_stock_item": 1,
"has_batch_no": 1,
"batch_number_series": "TST-BATCH-OTH-.###",
"create_new_batch": 1,
},
)
make_bom(item=production_item, source_warehouse=source_warehouse, raw_materials=[rm_item])
batches = []
for _ in range(2):
receipt = test_stock_entry.make_stock_entry(
item_code=rm_item, target=source_warehouse, qty=50, basic_rate=100
)
batches.append(get_batch_from_bundle(receipt.items[0].serial_and_batch_bundle))
wo = make_wo_order_test_record(
item=production_item, qty=50, reserve_stock=1, source_warehouse=source_warehouse
)
sre = frappe.get_doc(
"Stock Reservation Entry",
{"voucher_no": wo.name, "warehouse": source_warehouse, "docstatus": 1},
)
reserved_batch = sre.sb_entries[0].batch_no
other_batch = batches[1] if batches[0] == reserved_batch else batches[0]
transfer = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 50))
for row in transfer.items:
row.update(
{"batch_no": other_batch, "use_serial_batch_fields": 1, "serial_and_batch_bundle": None}
)
transfer.insert()
transfer.submit()
sre.reload()
self.assertEqual(sre.status, "Reserved")
self.assertEqual(sre.transferred_qty, 0)
self.assertEqual([(row.batch_no, row.delivered_qty) for row in sre.sb_entries], [(reserved_batch, 0)])
frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 50)).submit()
wo.reload()
self.assertEqual(wo.required_items[0].stock_reserved_qty, 50)
@ERPNextTestSuite.change_settings(
"Stock Settings",
{"enable_stock_reservation": 1, "auto_reserve_serial_and_batch": 1},
)
@ERPNextTestSuite.change_settings("Manufacturing Settings", {"material_consumption": 1})
def test_material_consumption_uses_batch_reservation(self):
production_item = "Test Consumption Reservation FG"
rm_item = "Test Consumption Reservation RM"
source_warehouse = "Stores - _TC"
make_item(production_item, {"is_stock_item": 1})
make_item(
rm_item,
{
"is_stock_item": 1,
"has_batch_no": 1,
"batch_number_series": "TST-BATCH-MCM-.###",
"create_new_batch": 1,
},
)
make_bom(item=production_item, source_warehouse=source_warehouse, raw_materials=[rm_item])
test_stock_entry.make_stock_entry(item_code=rm_item, target=source_warehouse, qty=50, basic_rate=100)
wo = make_wo_order_test_record(
item=production_item, qty=50, reserve_stock=1, source_warehouse=source_warehouse
)
frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 50)).submit()
frappe.get_doc(make_stock_entry(wo.name, "Material Consumption for Manufacture", 50)).submit()
wip_reservation = frappe.db.get_value(
"Stock Reservation Entry",
{"voucher_no": wo.name, "warehouse": wo.wip_warehouse, "docstatus": 1},
["consumed_qty", "status"],
as_dict=True,
)
self.assertEqual(wip_reservation.consumed_qty, 50)
self.assertEqual(wip_reservation.status, "Delivered")
@ERPNextTestSuite.change_settings(
"Stock Settings",
{"enable_stock_reservation": 1, "allow_negative_stock": 0},
)
def test_ledger_preview_ignores_own_work_order_reservation(self):
from erpnext.controllers.ledger_preview import get_stock_ledger_preview
production_item = "Test Preview Reservation FG"
rm_item = "Test Preview Reservation RM"
source_warehouse = "Stores - _TC"
make_item(production_item, {"is_stock_item": 1})
make_item(rm_item, {"is_stock_item": 1})
make_bom(item=production_item, source_warehouse=source_warehouse, raw_materials=[rm_item])
test_stock_entry.make_stock_entry(item_code=rm_item, target=source_warehouse, qty=20, basic_rate=100)
wo = make_wo_order_test_record(
item=production_item, qty=20, reserve_stock=1, source_warehouse=source_warehouse
)
transfer = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 20))
transfer.insert()
transfer.run_method("before_sl_preview")
_, sl_data = get_stock_ledger_preview(transfer, frappe._dict(company=transfer.company))
self.assertEqual(len(sl_data), 2)
@ERPNextTestSuite.change_settings(
"Stock Settings",
{"enable_stock_reservation": 1, "allow_partial_reservation": 1},

View File

@@ -255,6 +255,11 @@ frappe.ui.form.on("Work Order", {
frm.trigger("toggle_items_editable");
frm.trigger("set_fg_warehouse_mandatory");
frm.trigger("toggle_hide_fields");
erpnext.work_order.render_linked_lists(frm);
},
on_tab_change(frm) {
frm.wo_linked_lists && frm.wo_linked_lists.load_active_tab();
},
toggle_hide_fields(frm) {
@@ -899,8 +904,6 @@ erpnext.work_order = {
);
}
erpnext.work_order.setup_stock_reservation(frm);
if (!frm.doc.track_semi_finished_goods) {
const show_start_btn =
frm.doc.skip_transfer || frm.doc.transfer_material_against == "Job Card" ? 0 : 1;
@@ -1050,6 +1053,8 @@ erpnext.work_order = {
}
}
}
erpnext.work_order.setup_stock_reservation(frm);
},
add_start_button(frm) {
@@ -1066,6 +1071,7 @@ erpnext.work_order = {
setup_stock_reservation(frm) {
if (frm.doc.docstatus === 1 && frm.doc.reserve_stock) {
if (
!["Closed", "Completed"].includes(frm.doc.status) &&
frm.events.has_unreserved_stock(frm) &&
(frm.doc.skip_transfer || frm.doc.material_transferred_for_manufacturing < frm.doc.qty)
) {
@@ -1077,13 +1083,11 @@ erpnext.work_order = {
}
if (frm.events.has_reserved_stock(frm)) {
if (frm.doc.skip_transfer || frm.doc.material_transferred_for_manufacturing < frm.doc.qty) {
frm.add_custom_button(
__("Unreserve"),
() => erpnext.stock_reservation.unreserve_stock(frm),
__("Stock Reservation")
);
}
frm.add_custom_button(
__("Unreserve"),
() => erpnext.stock_reservation.unreserve_stock(frm),
__("Stock Reservation")
);
frm.add_custom_button(
__("Reserved Stock"),
@@ -1501,3 +1505,215 @@ frappe.tour["Work Order"] = [
),
},
];
erpnext.work_order.render_linked_lists = function (frm) {
if (!frm.wo_linked_lists) {
frm.wo_linked_lists = new erpnext.work_order.LinkedLists(frm);
}
frm.wo_linked_lists.render();
};
// Same conditions as the "Material Request" toolbar button in set_custom_buttons().
erpnext.work_order.can_create_material_request = function (frm) {
const doc = frm.doc;
if (doc.docstatus !== 1) return false;
if (["Closed", "Completed", "Stopped"].includes(doc.status)) return false;
if (doc.track_semi_finished_goods) return false;
if (doc.skip_transfer || doc.transfer_material_against === "Job Card") return false;
return (doc.required_items || []).some((item) => flt(item.transferred_qty) < flt(item.required_qty));
};
// EmbeddedList with an action in the empty state. Lazy: the class only exists once
// embedded_list.bundle.js has loaded.
erpnext.work_order.get_embedded_list_class = function () {
if (erpnext.work_order._EmbeddedListWithEmptyAction) {
return erpnext.work_order._EmbeddedListWithEmptyAction;
}
erpnext.work_order._EmbeddedListWithEmptyAction = class extends frappe.ui.EmbeddedList {
toggle_result_area() {
super.toggle_result_area();
const has_rows = this.data.length > 0;
const searched = this._all_data && this._all_data.length > 0;
if (has_rows || searched || !this.empty_state_action) return;
const $empty = frappe.ui.empty_state({
icon: this.empty_icon,
title: this.empty_message,
description: this.empty_description,
actions: [this.empty_state_action],
});
this.$no_result.replaceWith($empty);
this.$no_result = $empty;
this.$no_result.toggle(true);
}
};
return erpnext.work_order._EmbeddedListWithEmptyAction;
};
erpnext.work_order.LinkedLists = class WorkOrderLinkedLists {
constructor(frm) {
this.frm = frm;
this.lists = {};
this.tabs = {
job_card_tab: {
html_field: "job_card_list_html",
doctype: "Job Card",
fields: ["name", "status", "docstatus", "operation", "workstation", "for_quantity"],
columns: [
{
label: __("Job Card"),
fieldname: "name",
type: "link",
route: (row) => ["Form", "Job Card", row.name],
},
{ label: __("Operation"), fieldname: "operation" },
{ label: __("Workstation"), fieldname: "workstation" },
{ label: __("For Qty"), fieldname: "for_quantity", align: "right" },
{
label: __("Status"),
render: (row) => {
const [label, color] = frappe.get_indicator(row, "Job Card") || [
row.status,
"gray",
];
return frappe.ui.badge.html({ label, theme: color });
},
},
],
},
material_request_tab: {
html_field: "material_request_list_html",
doctype: "Material Request",
fields: ["name", "status", "material_request_type", "transaction_date"],
empty_message: __("No Material Request created"),
empty_description: __("Create your first Material Request to get started."),
can_add: (frm) => erpnext.work_order.can_create_material_request(frm),
empty_state_action: {
label: __("Create Material Request"),
icon: "plus",
onclick: () => erpnext.work_order.make_material_request(this.frm),
},
columns: [
{
label: __("Material Request"),
fieldname: "name",
type: "link",
route: (row) => ["Form", "Material Request", row.name],
},
{ label: __("Type"), fieldname: "material_request_type" },
{
label: __("Date"),
render: (row) => frappe.format(row.transaction_date, { fieldtype: "Date" }),
},
{ label: __("Status"), fieldname: "status", type: "badge" },
],
},
stock_entry_tab: {
html_field: "stock_entry_list_html",
doctype: "Stock Entry",
fields: ["name", "stock_entry_type", "posting_date", "docstatus"],
columns: [
{
label: __("Stock Entry"),
fieldname: "name",
type: "link",
route: (row) => ["Form", "Stock Entry", row.name],
},
{ label: __("Purpose"), fieldname: "stock_entry_type" },
{
label: __("Date"),
render: (row) => frappe.format(row.posting_date, { fieldtype: "Date" }),
},
{
label: __("Status"),
render: (row) =>
frappe.ui.badge.html({
label: { 0: __("Draft"), 1: __("Submitted"), 2: __("Cancelled") }[
row.docstatus
],
theme: { 0: "gray", 1: "green", 2: "red" }[row.docstatus],
}),
},
],
},
};
}
render() {
if (this.frm.is_new()) {
Object.values(this.tabs).forEach((cfg) => {
const wrapper = this.frm.fields_dict[cfg.html_field]?.$wrapper;
wrapper &&
wrapper
.empty()
.append(
$('<div class="text-muted">').text(
__("Save the Work Order to view linked documents.")
)
);
});
return;
}
frappe
.require("embedded_list.bundle.js")
.then(() => {
this._loaded = true;
this.lists = {};
this.load_active_tab();
})
.catch((e) => {
console.error("Work Order: failed to load embedded_list.bundle.js", e);
});
}
build(tab_fieldname) {
const cfg = this.tabs[tab_fieldname];
if (!cfg) return;
if (this.lists[tab_fieldname]) return;
const wrapper = this.frm.fields_dict[cfg.html_field]?.$wrapper;
if (!wrapper) return;
wrapper.empty();
const can_add = !cfg.can_add || cfg.can_add(this.frm);
const opts = {
wrapper,
doctype: cfg.doctype,
filters: { work_order: this.frm.doc.name },
fields: cfg.fields,
columns: cfg.columns,
order_by: "creation desc",
add_button: can_add ? cfg.add_button : undefined,
empty_state_action: can_add ? cfg.empty_state_action : undefined,
empty_description: cfg.empty_description,
empty_message: cfg.empty_message || __("No {0} linked to this Work Order.", [__(cfg.doctype)]),
};
const ListClass = erpnext.work_order.get_embedded_list_class();
const list = new ListClass(opts);
this.lists[tab_fieldname] = list;
if (tab_fieldname === "job_card_tab") {
// Load Job Card's list settings first so the status badge can reuse its
// indicator colors on the very first render.
frappe.model.with_doctype("Job Card", () => list.refresh());
return;
}
list.refresh();
}
load_active_tab() {
if (!this._loaded || this.frm.is_new()) return;
const active = this.frm.get_active_tab && this.frm.get_active_tab();
const fieldname = active?.df?.fieldname;
if (fieldname && this.tabs[fieldname]) {
this.build(fieldname);
}
}
};

View File

@@ -18,33 +18,30 @@
"qty",
"sales_order",
"reserve_stock",
"allow_alternative_item",
"section_break_vrpa",
"max_producible_qty",
"material_transferred_for_manufacturing",
"additional_transferred_qty",
"column_break_ezmq",
"produced_qty",
"process_loss_qty",
"disassembled_qty",
"column_break_ezmq",
"material_transferred_for_manufacturing",
"additional_transferred_qty",
"max_producible_qty",
"warehouses",
"source_warehouse",
"wip_warehouse",
"column_break_12",
"fg_warehouse",
"scrap_warehouse",
"column_break_dqlq",
"skip_transfer",
"from_wip_warehouse",
"operations_section",
"transfer_material_against",
"operations",
"section_break_ndpq",
"use_multi_level_bom",
"source_warehouse",
"required_items",
"work_order_configuration",
"settings_section",
"allow_alternative_item",
"use_multi_level_bom",
"column_break_17",
"skip_transfer",
"from_wip_warehouse",
"update_consumed_material_cost_in_project",
"time",
"planned_start_date",
"planned_end_date",
@@ -60,6 +57,12 @@
"column_break_24",
"corrective_operation_cost",
"total_operating_cost",
"job_card_tab",
"job_card_list_html",
"material_request_tab",
"material_request_list_html",
"stock_entry_tab",
"stock_entry_list_html",
"more_info",
"production_item_info_section",
"image",
@@ -77,6 +80,7 @@
"secondary_items_section",
"secondary_items",
"reference_section",
"update_consumed_material_cost_in_project",
"project",
"subcontracting_inward_order",
"production_plan",
@@ -285,10 +289,6 @@
"options": "Warehouse",
"read_only_depends_on": "subcontracting_inward_order"
},
{
"fieldname": "column_break_12",
"fieldtype": "Column Break"
},
{
"description": "This is a location where scraped materials are stored.",
"fieldname": "scrap_warehouse",
@@ -422,7 +422,7 @@
},
{
"fieldname": "description",
"fieldtype": "Small Text",
"fieldtype": "Text Editor",
"label": "Item Description",
"read_only": 1
},
@@ -503,10 +503,6 @@
"options": "Work Order",
"read_only": 1
},
{
"fieldname": "settings_section",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_18",
"fieldtype": "Column Break"
@@ -521,7 +517,7 @@
"description": "This is a location where raw materials are available.",
"fieldname": "source_warehouse",
"fieldtype": "Link",
"label": "Source Warehouse",
"label": "Default source Warehouse",
"link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]",
"options": "Warehouse",
"read_only_depends_on": "eval:doc.subcontracting_inward_order"
@@ -540,10 +536,6 @@
"fieldtype": "Section Break",
"label": "Finished Good Serial / Batch"
},
{
"fieldname": "column_break_17",
"fieldtype": "Column Break"
},
{
"default": "0",
"fetch_from": "production_item.has_serial_no",
@@ -604,7 +596,7 @@
{
"fieldname": "work_order_configuration",
"fieldtype": "Tab Break",
"label": "Configuration"
"label": "Scheduling & Costing"
},
{
"collapsible": 1,
@@ -732,6 +724,40 @@
"label": "Secondary Items (as per BOM)",
"options": "Work Order Additional Item",
"read_only": 1
},
{
"fieldname": "column_break_dqlq",
"fieldtype": "Column Break"
},
{
"fieldname": "job_card_tab",
"fieldtype": "Tab Break",
"label": "Job Cards"
},
{
"fieldname": "job_card_list_html",
"fieldtype": "HTML",
"label": "Job Cards"
},
{
"fieldname": "material_request_tab",
"fieldtype": "Tab Break",
"label": "Material Requests"
},
{
"fieldname": "material_request_list_html",
"fieldtype": "HTML",
"label": "Material Requests"
},
{
"fieldname": "stock_entry_tab",
"fieldtype": "Tab Break",
"label": "Stock Entries"
},
{
"fieldname": "stock_entry_list_html",
"fieldtype": "HTML",
"label": "Stock Entries"
}
],
"grid_page_length": 50,
@@ -740,7 +766,7 @@
"image_field": "image",
"is_submittable": 1,
"links": [],
"modified": "2026-08-08 12:00:00.000000",
"modified": "2026-09-25 12:00:00.000000",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Work Order",

View File

@@ -121,7 +121,7 @@ class WorkOrder(Document):
bom_no: DF.Link
company: DF.Link
corrective_operation_cost: DF.Currency
description: DF.SmallText | None
description: DF.TextEditor | None
disassembled_qty: DF.Float
expected_delivery_date: DF.Date | None
fg_warehouse: DF.Link | None

View File

@@ -72,7 +72,7 @@
},
{
"fieldname": "description",
"fieldtype": "Text",
"fieldtype": "Text Editor",
"label": "Description",
"read_only": 1
},
@@ -227,7 +227,7 @@
"grid_page_length": 50,
"istable": 1,
"links": [],
"modified": "2026-08-07 10:00:00.000000",
"modified": "2026-09-25 12:00:00.000000",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Work Order Item",

View File

@@ -20,7 +20,7 @@ class WorkOrderItem(Document):
available_qty_at_source_warehouse: DF.Float
available_qty_at_wip_warehouse: DF.Float
consumed_qty: DF.Float
description: DF.Text | None
description: DF.TextEditor | None
include_item_in_manufacturing: DF.Check
is_additional_item: DF.Check
is_customer_provided_item: DF.Check

View File

@@ -5,6 +5,8 @@
import frappe
from frappe import _
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies_condition
def execute(filters=None):
data = get_data(filters)
@@ -72,6 +74,9 @@ def get_filtered_data(filters):
if filters.get("workstation"):
bom_ops_query = bom_ops_query.where(bom_ops.workstation == filters.get("workstation"))
if condition := get_allowed_companies_condition(bom.company, "BOM"):
bom_ops_query = bom_ops_query.where(condition)
bom_operation_data = bom_ops_query.run(as_dict=True)
return bom_operation_data

View File

@@ -5,6 +5,8 @@
import frappe
from frappe import _
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies_condition
def execute(filters=None):
columns, data = [], []
@@ -77,6 +79,9 @@ def get_data(filters):
if filters.get("work_order"):
query = query.where(wo.name == filters.get("work_order"))
if condition := get_allowed_companies_condition(wo.company, "Work Order"):
query = query.where(condition)
results = []
for d in query.run(as_dict=True):
results.append(d)

View File

@@ -5,6 +5,8 @@
import frappe
from frappe import _
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies_condition
def execute(filters=None):
columns, data = [], []
@@ -36,8 +38,13 @@ def get_data(filters):
query_filters["report_date"] = ["between", [filters.get("from_date"), filters.get("to_date")]]
inspection_filters = [query_filters]
company = frappe.qb.DocType("Quality Inspection").company
if condition := get_allowed_companies_condition(company, "Quality Inspection"):
inspection_filters.append(condition)
return frappe.get_all(
"Quality Inspection", fields=fields, filters=query_filters, order_by="report_date asc"
"Quality Inspection", fields=fields, filters=inspection_filters, order_by="report_date asc"
)

View File

@@ -7,6 +7,8 @@ from frappe import _
from frappe.query_builder.functions import IfNull, Max, Sum
from frappe.utils import cint
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies_condition
def execute(filters=None):
wo_list = get_work_orders()
@@ -96,9 +98,13 @@ def get_item_list(wo_list, filters):
def get_work_orders():
work_order_filters = [{"docstatus": 1, "status": ("!=", "Completed")}]
if condition := get_allowed_companies_condition(frappe.qb.DocType("Work Order").company, "Work Order"):
work_order_filters.append(condition)
out = frappe.get_all(
"Work Order",
filters={"docstatus": 1, "status": ("!=", "Completed")},
filters=work_order_filters,
fields=["name", "status", "bom_no", "qty", "produced_qty"],
order_by="name",
)

View File

@@ -529,3 +529,4 @@ erpnext.patches.v16_0.set_blanket_order_status
erpnext.patches.v16_0.set_blanket_order_item_stock_uom
erpnext.patches.v16_0.recalculate_subcontracting_order_service_cost
erpnext.patches.v16_0.move_sub_assembly_rate_setting_to_bom_item
erpnext.patches.v16_0.convert_plain_text_line_breaks_to_html

View File

@@ -0,0 +1,19 @@
import frappe
from frappe.utils import escape_html
FIELDS_NOW_TEXT_EDITOR = {
"Delivery Stop": "customer_address",
"Project User": "project_status",
}
def execute():
for doctype, fieldname in FIELDS_NOW_TEXT_EDITOR.items():
rows = frappe.get_all(
doctype,
filters=[[fieldname, "like", "%\n%"], [fieldname, "not like", "%<%"]],
fields=["name", fieldname],
)
for row in rows:
html = escape_html(row[fieldname]).replace("\n", "<br>")
frappe.db.set_value(doctype, row.name, fieldname, html, update_modified=False)

View File

@@ -79,13 +79,13 @@
{
"depends_on": "eval:parent.doctype == 'Project Update'",
"fieldname": "project_status",
"fieldtype": "Text",
"fieldtype": "Text Editor",
"label": "Project Status"
}
],
"istable": 1,
"links": [],
"modified": "2024-03-27 13:10:21.859474",
"modified": "2026-09-25 12:00:00.000000",
"modified_by": "Administrator",
"module": "Projects",
"name": "Project User",

View File

@@ -20,7 +20,7 @@ class ProjectUser(Document):
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
project_status: DF.Text | None
project_status: DF.TextEditor | None
user: DF.Link
view_attachments: DF.Check
welcome_email_sent: DF.Check

View File

@@ -179,7 +179,11 @@ erpnext.buying = {
this.frm.set_value("billing_address", r.message.primary_address || "");
if (frappe.meta.has_field(this.frm.doc.doctype, "shipping_address")) {
const is_drop_ship = this.frm.doc.items.some((item) => item.delivered_by_supplier);
if (
frappe.meta.has_field(this.frm.doc.doctype, "shipping_address") &&
!is_drop_ship
) {
this.frm.set_value("shipping_address", r.message.shipping_address || "");
}
},

View File

@@ -43,5 +43,6 @@ import "./financial_statements.js";
import "./sales_trends_filters.js";
import "./purchase_trends_filters.js";
import "./stock_balance_report.js";
import "./subcontracting_inward_report_filters.js";
// import { sum } from 'frappe/public/utils/util.js'

View File

@@ -4,19 +4,21 @@
frappe.ui.form.on("Sales Order", {
refresh(frm) {
erpnext.proforma.toggle_tab(frm, false);
if (frm.doc.docstatus !== 1) return;
if (frm.doc.docstatus === 0) return;
frappe.db.get_single_value("Selling Settings", "enable_proforma_invoice").then((enabled) => {
if (!enabled) return;
// Defer so the button lands after the standard Create options, not before them.
setTimeout(() => {
frm.add_custom_button(
__("Proforma Invoice"),
() => erpnext.proforma.open_dialog(frm),
__("Create")
);
}, 0);
if (frm.doc.docstatus === 1) {
// Defer so the button lands after the standard Create options, not before them.
setTimeout(() => {
frm.add_custom_button(
__("Proforma Invoice"),
() => erpnext.proforma.open_dialog(frm),
__("Create")
);
}, 0);
}
erpnext.proforma.render_list(frm);
});
},
@@ -117,6 +119,12 @@ Object.assign(erpnext.proforma, {
read_only: 1,
in_list_view: 1,
},
{
fieldname: "description",
fieldtype: "Text Editor",
label: __("Description"),
in_list_view: 1,
},
{
fieldname: "qty",
fieldtype: "Float",
@@ -205,11 +213,12 @@ Object.assign(erpnext.proforma, {
const by_amount = values.based_on === "Amount";
const items = (values.items || [])
.filter((row) => flt(by_amount ? row.amount : row.qty) > 0)
.map((row) =>
by_amount
? { so_detail: row.so_detail, qty: row.qty, amount: row.amount }
: { so_detail: row.so_detail, qty: row.qty }
);
.map((row) => ({
so_detail: row.so_detail,
description: row.description,
qty: row.qty,
amount: row.amount,
}));
if (!items.length) {
frappe.msgprint(__("Please enter a quantity or amount for at least one item."));
@@ -314,6 +323,7 @@ Object.assign(erpnext.proforma, {
],
});
list.refresh();
if (frm.doc.docstatus !== 1) return;
frappe.ui
.button({

View File

@@ -216,8 +216,11 @@ $.extend(erpnext.stock_reservation, {
unreserve_stock(frm) {
erpnext.stock_reservation.get_stock_reservation_entries(frm.doctype, frm.docname).then((r) => {
if (!r.exc && r.message) {
if (r.message.length > 0) {
erpnext.stock_reservation.prepare_for_cancel_sre_entries(frm, r.message);
const sre_entries = r.message.filter(
(sre) => erpnext.stock_reservation.get_held_qty(sre) > 0
);
if (sre_entries.length > 0) {
erpnext.stock_reservation.prepare_for_cancel_sre_entries(frm, sre_entries);
} else {
frappe.msgprint(__("No reserved stock to unreserve."));
}
@@ -253,7 +256,7 @@ $.extend(erpnext.stock_reservation, {
sre: sre.name,
item_code: sre.item_code,
warehouse: sre.warehouse,
qty: flt(sre.reserved_qty) - flt(sre.delivered_qty),
qty: erpnext.stock_reservation.get_held_qty(sre),
});
});
@@ -261,6 +264,12 @@ $.extend(erpnext.stock_reservation, {
dialog.show();
},
get_held_qty(sre) {
return (
flt(sre.reserved_qty) - flt(sre.delivered_qty) - flt(sre.transferred_qty) - flt(sre.consumed_qty)
);
},
cancel_stock_reservation(dialog, frm) {
let data = { sr_entries: dialog.fields_dict.sr_entries.grid.get_selected_children() };
let method = "erpnext.manufacturing.doctype.work_order.work_order.cancel_stock_reservation_entries";

View File

@@ -0,0 +1,32 @@
erpnext.get_subcontracting_inward_report_filters = function () {
return [
{
fieldname: "company",
label: __("Company"),
fieldtype: "Link",
options: "Company",
default: frappe.defaults.get_user_default("Company"),
reqd: 1,
},
{
fieldname: "from_date",
label: __("From Date"),
fieldtype: "Date",
default: frappe.datetime.add_months(frappe.datetime.get_today(), -1),
reqd: 1,
},
{
fieldname: "to_date",
label: __("To Date"),
fieldtype: "Date",
default: frappe.datetime.get_today(),
reqd: 1,
},
{
fieldname: "customer",
label: __("Customer"),
fieldtype: "Link",
options: "Customer",
},
];
};

View File

@@ -1,8 +1,8 @@
// Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
// frappe.ui.form.on("Proforma Invoice", {
// refresh(frm) {
// },
// });
frappe.ui.form.on("Proforma Invoice", {
refresh(frm) {
frm.page.btn_primary.toggle(frm.doc.docstatus !== 2);
},
});

View File

@@ -45,8 +45,59 @@ class ProformaInvoice(Document):
def validate(self) -> None:
validate_feature_enabled()
self.validate_amended_doc()
self.validate_sales_order()
self.set_item_values()
self.set_total_qty()
def validate_sales_order(self) -> None:
if frappe.db.get_value("Sales Order", self.sales_order, "docstatus") != 1:
frappe.throw(_("A Proforma Invoice can only be created against a submitted Sales Order."))
def set_item_values(self) -> None:
"""Copy each line's item details from its Sales Order line, then set the rate and amount."""
so_items = {
row.name: row
for row in frappe.get_all(
"Sales Order Item",
filters={"parent": self.sales_order, "parenttype": "Sales Order"},
fields=["name", "item_code", "item_name", "description", "uom", "rate"],
)
}
for item in self.items:
so_item = so_items.get(item.so_detail)
if not so_item:
frappe.throw(
_("Row #{0}: The line does not belong to Sales Order {1}").format(
item.idx, frappe.bold(self.sales_order)
)
)
item.item_code = so_item.item_code
item.item_name = so_item.item_name
item.uom = so_item.uom
item.description = item.description or so_item.description
self.set_rate_and_amount(item, so_item.rate)
def set_rate_and_amount(self, item, sales_order_rate: float) -> None:
"""Quantity basis bills at the Sales Order rate; Amount basis derives the rate from the amount."""
if flt(item.qty) <= 0:
frappe.throw(_("Row #{0}: Qty must be a positive number").format(item.idx))
if self.based_on == "Amount":
if flt(item.amount) <= 0:
frappe.throw(_("Row #{0}: Amount must be a positive number").format(item.idx))
item.rate = flt(item.amount) / flt(item.qty)
else:
item.rate = sales_order_rate
item.amount = flt(item.qty) * flt(sales_order_rate)
def validate_amended_doc(self) -> None:
if self.amended_from:
frappe.throw(
_("Cannot amend {0} {1}, please create a new one instead.").format(
self.doctype, frappe.bold(self.amended_from)
)
)
def before_submit(self) -> None:
self.status = "Issued"
@@ -80,6 +131,7 @@ class ProformaInvoice(Document):
for item in sales_order.items:
item.qty = lines[item.name].qty
item.rate = lines[item.name].rate
item.description = lines[item.name].description
item.discount_amount = 0
item.discount_percentage = 0
sales_order.run_method("calculate_taxes_and_totals")
@@ -116,6 +168,7 @@ def get_sales_order_items(sales_order: str) -> list[dict]:
{
"item_code": item.item_code,
"item_name": item.item_name,
"description": item.description,
"uom": item.uom,
"so_detail": item.name,
"qty": flt(item.qty),
@@ -155,37 +208,36 @@ def make_proforma_invoice(
print_format: str | None = None,
letter_head: str | None = None,
) -> str:
"""The sole creation path for a Proforma Invoice (the doctype is `in_create`).
"""Create and submit a Proforma Invoice from the Sales Order dialog.
`based_on` decides what the user edited per line: "Quantity" (rate fixed, amount = qty x rate)
or "Amount" (both qty and amount entered, rate derived). `hide_item_qty` (Amount basis only)
hides the qty and rate on the printed proforma for a clean value-based document.
"""
validate_feature_enabled()
selected = frappe.parse_json(items)
sales_order_doc = frappe.get_doc("Sales Order", sales_order)
if sales_order_doc.docstatus != 1:
frappe.throw(_("A Proforma Invoice can only be created against a submitted Sales Order."))
so_items = {item.name: item for item in sales_order_doc.items}
proforma = frappe.new_doc("Proforma Invoice")
proforma.sales_order = sales_order
proforma.based_on = based_on
proforma.hide_item_qty = 1 if (based_on == "Amount" and int(hide_item_qty or 0)) else 0
if naming_series:
proforma.naming_series = naming_series
proforma.print_format = print_format or frappe.db.get_single_value(
"Selling Settings", "default_proforma_print_format"
proforma.print_format = (
print_format
or frappe.db.get_single_value("Selling Settings", "default_proforma_print_format")
or "Proforma Invoice"
)
proforma.letter_head = letter_head
for row in selected:
so_item = so_items.get(row.get("so_detail"))
if not so_item:
continue
line = _proforma_line(so_item, based_on, row)
if line:
proforma.append("items", line)
for row in frappe.parse_json(items):
proforma.append(
"items",
{
"so_detail": row.get("so_detail"),
"qty": row.get("qty"),
"amount": row.get("amount"),
"description": row.get("description"),
},
)
if not proforma.items:
frappe.throw(_("Please enter a quantity or amount for at least one item."))
@@ -195,32 +247,6 @@ def make_proforma_invoice(
return proforma.name
def _proforma_line(so_item, based_on: str, row: dict) -> dict | None:
if based_on == "Amount":
# Amount basis: both qty and amount are user-entered; the rate is derived.
qty = flt(row.get("qty"))
amount = flt(row.get("amount"))
if amount <= 0 or qty <= 0:
return None
rate = amount / qty
else:
qty = flt(row.get("qty"))
if qty <= 0:
return None
rate = flt(so_item.rate)
amount = qty * rate
return {
"item_code": so_item.item_code,
"item_name": so_item.item_name,
"uom": so_item.uom,
"qty": qty,
"rate": rate,
"amount": amount,
"so_detail": so_item.name,
}
@frappe.whitelist()
def send_proforma_email(proforma_name: str, recipients: str) -> None:
proforma = frappe.get_doc("Proforma Invoice", proforma_name)

View File

@@ -6,6 +6,7 @@ import json
import frappe
from frappe.utils import flt
from erpnext.accounts.services.child_item_update import update_child_qty_rate
from erpnext.selling.doctype.proforma_invoice.proforma_invoice import (
get_sales_order_items,
make_proforma_invoice,
@@ -24,6 +25,9 @@ class TestProformaInvoice(ERPNextTestSuite):
name = make_proforma_invoice(sales_order.name, json.dumps(items), **kwargs)
return frappe.get_doc("Proforma Invoice", name)
def make_draft_proforma(self, sales_order, **item):
return frappe.new_doc("Proforma Invoice", sales_order=sales_order.name, items=[item]).insert()
def test_partial_proforma_is_non_blocking(self):
"""A proforma must not touch delivery/billing or the source Sales Order."""
sales_order = make_sales_order(qty=10)
@@ -172,6 +176,74 @@ class TestProformaInvoice(ERPNextTestSuite):
("Proforma Invoice PRO-TEST-0001", "Please find attached the proforma invoice PRO-TEST-0001."),
)
def test_line_description_is_editable(self):
sales_order = make_sales_order(qty=10, do_not_submit=True)
sales_order.items[0].description = "Ordered description"
sales_order.submit()
so_detail = sales_order.items[0].name
edited = make_proforma_invoice(
sales_order.name, json.dumps([{"so_detail": so_detail, "qty": 4, "description": "Edited"}])
)
unedited = self.create_proforma(sales_order, [(so_detail, 4)])
self.assertEqual(get_sales_order_items(sales_order.name)[0]["description"], "Ordered description")
self.assertEqual(frappe.get_doc("Proforma Invoice", edited).items[0].description, "Edited")
self.assertEqual(unedited.items[0].description, "Ordered description")
def test_update_items_cannot_delete_a_proformed_row(self):
sales_order = make_sales_order(
item_list=[
{"item_code": "_Test Item", "qty": 5, "rate": 100},
{"item_code": "_Test Item 2", "qty": 2, "rate": 50},
]
)
proformed, other = sales_order.items
proforma = self.create_proforma(sales_order, [(proformed.name, 2)])
keep_other = json.dumps(
[{"item_code": other.item_code, "qty": other.qty, "rate": other.rate, "docname": other.name}]
)
self.assertRaises(
frappe.ValidationError, update_child_qty_rate, "Sales Order", keep_other, sales_order.name
)
proforma.cancel()
update_child_qty_rate("Sales Order", keep_other, sales_order.name)
sales_order.reload()
self.assertEqual([item.name for item in sales_order.items], [other.name])
def test_line_from_another_sales_order_is_rejected(self):
sales_order = make_sales_order(qty=10)
other_item = make_sales_order(qty=10).items[0]
self.assertRaises(
frappe.ValidationError,
self.make_draft_proforma,
sales_order,
so_detail=other_item.name,
item_code=other_item.item_code,
qty=4,
)
def test_quantity_basis_bills_at_sales_order_rate(self):
sales_order = make_sales_order(qty=10)
so_item = sales_order.items[0]
proforma = self.make_draft_proforma(
sales_order, so_detail=so_item.name, item_code=so_item.item_code, qty=4, rate=1, amount=1
)
item = proforma.items[0]
self.assertEqual(item.item_code, so_item.item_code)
self.assertEqual(flt(item.rate), flt(so_item.rate))
self.assertEqual(flt(item.amount), 4 * flt(so_item.rate))
def test_amended_proforma_is_rejected(self):
proforma = frappe.get_doc({"doctype": "Proforma Invoice", "amended_from": "PRO-TEST-0001"})
self.assertRaises(frappe.ValidationError, proforma.validate_amended_doc)
def test_requires_submitted_sales_order(self):
"""The server rejects a proforma against a draft Sales Order (the button is JS-gated only)."""
sales_order = make_sales_order(qty=10, do_not_submit=True)

View File

@@ -7,6 +7,7 @@
"field_order": [
"item_code",
"item_name",
"description",
"column_break_qty",
"qty",
"uom",
@@ -32,6 +33,11 @@
"label": "Item Name",
"read_only": 1
},
{
"fieldname": "description",
"fieldtype": "Text Editor",
"label": "Description"
},
{
"fieldname": "column_break_qty",
"fieldtype": "Column Break"
@@ -79,7 +85,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-07-16 00:00:00.000000",
"modified": "2026-09-24 12:00:00.000000",
"modified_by": "Administrator",
"module": "Selling",
"name": "Proforma Invoice Item",

View File

@@ -14,6 +14,7 @@ class ProformaInvoiceItem(Document):
from frappe.types import DF
amount: DF.Currency
description: DF.TextEditor | None
item_code: DF.Link
item_name: DF.Data | None
parent: DF.Data

View File

@@ -123,7 +123,7 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False, ar
{
"Quotation": {
"doctype": "Sales Order",
"validation": {"docstatus": ["=", 1]},
"validation": {"docstatus": ["=", 1], "is_active": ["=", 1]},
"field_no_map": ["payment_terms_template"],
},
"Quotation Item": {
@@ -184,7 +184,10 @@ def _make_sales_invoice(source_name, target_doc=None, ignore_permissions=False,
"Quotation",
source_name,
{
"Quotation": {"doctype": "Sales Invoice", "validation": {"docstatus": ["=", 1]}},
"Quotation": {
"doctype": "Sales Invoice",
"validation": {"docstatus": ["=", 1], "is_active": ["=", 1]},
},
"Quotation Item": {
"doctype": "Sales Invoice Item",
"postprocess": update_item,
@@ -201,6 +204,32 @@ def _make_sales_invoice(source_name, target_doc=None, ignore_permissions=False,
return doclist
@frappe.whitelist()
def make_revision(source_name: str, target_doc: str | dict | Document | None = None):
frappe.get_doc("Quotation", source_name).validate_can_be_revised()
def set_revision_of(source, target):
target.revision_of = source.revision_of or source.name
return get_mapped_doc(
"Quotation",
source_name,
{
"Quotation": {
"doctype": "Quotation",
"validation": {"docstatus": ["=", 1]},
"field_no_map": ["valid_till"],
},
"Quotation Item": {
"doctype": "Quotation Item",
"field_map": {"prevdoc_doctype": "prevdoc_doctype", "prevdoc_docname": "prevdoc_docname"},
},
},
target_doc,
set_revision_of,
)
def _make_customer(source_name, ignore_permissions=False):
quotation = frappe.db.get_value(
"Quotation",

View File

@@ -12,6 +12,7 @@ frappe.ui.form.on("Quotation", {
(frm.custom_make_buttons = {
"Sales Order": "Sales Order",
Quotation: "New Version",
}),
frm.set_query("quotation_to", function () {
return {
@@ -126,6 +127,7 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext.
if (doc.docstatus == 1 && !["Lost", "Ordered"].includes(doc.status)) {
if (
doc.is_active &&
frappe.model.can_create("Sales Order") &&
(frappe.boot.sysdefaults.allow_sales_order_creation_for_expired_quotation ||
!doc.valid_till ||
@@ -144,9 +146,38 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext.
if (doc.status !== "Ordered" && this.frm.has_perm("write")) {
this.frm.add_custom_button(__("Set as Lost"), () => {
this.frm.trigger("set_as_lost_dialog");
if (!doc.__onload?.has_versions_to_set_as_lost) {
this.frm.trigger("set_as_lost_dialog");
return;
}
frappe.confirm(
__("The other versions of this Quotation will also be set as Lost. Continue?"),
() => this.frm.trigger("set_as_lost_dialog")
);
});
}
if (frappe.model.can_create("Quotation")) {
this.frm.add_custom_button(
__("New Version"),
() => {
if (doc.__onload?.is_latest_version) {
this.make_revision();
return;
}
frappe.confirm(
__(
"Newer versions of this Quotation already exist. Create a new version anyway?"
),
() => this.make_revision()
);
},
__("Create")
);
this.frm.page.set_inner_btn_group_as_primary(__("Create"));
}
}
if (this.frm.doc.docstatus === 0 && frappe.model.can_read("Opportunity")) {
@@ -201,6 +232,13 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext.
}
}
make_revision() {
frappe.model.open_mapped_doc({
method: "erpnext.selling.doctype.quotation.mapper.make_revision",
frm: this.frm,
});
}
set_dynamic_field_label() {
if (this.frm.doc.quotation_to == "Customer") {
this.frm.set_df_property("party_name", "label", "Customer");

View File

@@ -18,11 +18,14 @@
"column_break_7",
"transaction_date",
"valid_till",
"is_active",
"column_break1",
"order_type",
"company",
"has_unit_price_items",
"amended_from",
"revision_of",
"is_latest_revision",
"currency_and_price_list",
"currency",
"conversion_rate",
@@ -214,6 +217,27 @@
"read_only": 1,
"width": "150px"
},
{
"fieldname": "revision_of",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Revision Of",
"no_copy": 1,
"options": "Quotation",
"print_hide": 1,
"read_only": 1,
"search_index": 1
},
{
"default": "0",
"fieldname": "is_latest_revision",
"fieldtype": "Check",
"hidden": 1,
"label": "Is Latest Revision",
"no_copy": 1,
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "company",
"fieldtype": "Link",
@@ -246,6 +270,16 @@
"fieldtype": "Date",
"label": "Valid Till"
},
{
"allow_on_submit": 1,
"default": "1",
"depends_on": "eval:doc.docstatus==1",
"fieldname": "is_active",
"fieldtype": "Check",
"label": "Is Active",
"no_copy": 1,
"read_only_depends_on": "eval:doc.status==\"Lost\""
},
{
"default": "Sales",
"fieldname": "order_type",
@@ -1146,7 +1180,7 @@
"idx": 82,
"is_submittable": 1,
"links": [],
"modified": "2026-08-12 12:00:00.000000",
"modified": "2026-09-26 12:00:00.000000",
"modified_by": "Administrator",
"module": "Selling",
"name": "Quotation",

View File

@@ -4,8 +4,9 @@
import frappe
from frappe import _
from frappe.desk.notifications import get_open_count as get_linked_document_counts
from frappe.model.document import Document
from frappe.utils import getdate, nowdate
from frappe.utils import cint, formatdate, get_datetime, getdate, nowdate
from pypika.terms import ExistsCriterion
from erpnext.controllers.selling_controller import SellingController
@@ -15,6 +16,7 @@ from .mapper import (
)
form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
VERSIONS_TO_SET_AS_LOST = {"status": ["not in", ["Partially Ordered", "Ordered", "Lost"]]}
class Quotation(SellingController):
@@ -76,6 +78,8 @@ class Quotation(SellingController):
ignore_pricing_rule: DF.Check
in_words: DF.Data | None
incoterm: DF.Link | None
is_active: DF.Check
is_latest_revision: DF.Check
item_wise_tax_details: DF.Table[ItemWiseTaxDetail]
items: DF.Table[QuotationItem]
language: DF.Link | None
@@ -97,6 +101,7 @@ class Quotation(SellingController):
pricing_rules: DF.Table[PricingRuleDetail]
quotation_to: DF.Link
referral_sales_partner: DF.Link | None
revision_of: DF.Link | None
rounded_total: DF.Currency
rounding_adjustment: DF.Currency
scan_barcode: DF.Data | None
@@ -128,6 +133,25 @@ class Quotation(SellingController):
valid_till: DF.Date | None
# end: auto-generated types
def autoname(self):
if self.revision_of:
self.name = f"{self.revision_of}-R{self.get_next_revision_index()}"
def get_next_revision_index(self):
frappe.db.get_value("Quotation", self.revision_of, "name", for_update=True)
revisions = frappe.get_all(
"Quotation",
filters={"revision_of": self.revision_of, "amended_from": ["is", "not set"]},
pluck="name",
)
return max((cint(name.rsplit("-R", 1)[-1]) for name in revisions), default=0) + 1
def onload(self):
super().onload()
if self.docstatus == 1:
self.set_onload("is_latest_version", self.is_latest_version)
self.set_onload("has_versions_to_set_as_lost", self.has_versions_to_set_as_lost)
def set_indicator(self):
if self.docstatus == 1:
self.indicator_color = "blue"
@@ -146,6 +170,7 @@ class Quotation(SellingController):
self.validate_uom_is_integer("stock_uom", "stock_qty")
self.validate_uom_is_integer("uom", "qty")
self.validate_valid_till()
self.validate_revision()
self.set_customer_name()
if self.items:
self.with_items = 1
@@ -164,6 +189,49 @@ class Quotation(SellingController):
if self.valid_till and getdate(self.valid_till) < getdate(self.transaction_date):
frappe.throw(_("Valid till date cannot be before transaction date"))
def validate_revision(self):
if not self.revision_of:
return
self.validate_revision_matches_original()
if self.get_other_versions({"status": "Lost"}):
frappe.throw(_("Quotation {0} is Lost and cannot be revised.").format(self.revision_of))
later_dates = [version.transaction_date for version in self.get_newer_versions()]
if later_dates:
frappe.throw(
_(
"Transaction Date must be after {0}, the date of the latest version of this Quotation."
).format(formatdate(max(later_dates)))
)
def validate_revision_matches_original(self):
original = frappe.db.get_value(
"Quotation", self.revision_of, ["company", "quotation_to", "party_name"], as_dict=True
)
if self.company != original.company:
frappe.throw(
_("A revision must have the same company as Quotation {0}.").format(self.revision_of)
)
if not self.has_party_of(original):
frappe.throw(
_("A revision must be for the same {0} as Quotation {1}.").format(
_(original.quotation_to), self.revision_of
)
)
def has_party_of(self, original: frappe._dict) -> bool:
if self.quotation_to == original.quotation_to and self.party_name == original.party_name:
return True
return (
original.quotation_to == "Lead"
and self.quotation_to == "Customer"
and frappe.db.get_value("Customer", self.party_name, "lead_name") == original.party_name
)
def set_has_alternative_item(self):
"""Mark 'Has Alternative Item' for rows."""
if not any(row.is_alternative for row in self.get("items")):
@@ -269,10 +337,10 @@ class Quotation(SellingController):
):
self.check_permission("write")
if not (self.is_fully_ordered() or self.is_partially_ordered()):
if not (self.is_fully_ordered() or self.is_partially_ordered() or self.has_ordered_versions):
get_lost_reasons = frappe.get_list("Quotation Lost Reason", fields=["name"])
lost_reasons_lst = [reason.get("name") for reason in get_lost_reasons]
self.db_set("status", "Lost")
self.db_set({"status": "Lost", "is_active": 1})
if detailed_reason:
self.db_set("order_lost_reason", detailed_reason)
@@ -290,6 +358,7 @@ class Quotation(SellingController):
for competitor in competitors:
self.append("competitors", competitor)
self.set_other_versions_as_lost()
self.update_opportunity("Lost")
self.update_lead()
self.save()
@@ -297,6 +366,15 @@ class Quotation(SellingController):
else:
frappe.throw(_("Cannot set as Lost as Sales Order is made."))
def before_update_after_submit(self):
if self.status == "Lost" and self.has_value_changed("is_active"):
frappe.throw(_("Is Active cannot be changed on a Lost Quotation."))
def on_update_after_submit(self):
if self.has_value_changed("is_active"):
self.update_opportunity("Quotation" if self.is_active else "Open")
self.update_lead()
def on_submit(self):
# Check for Approving Authority
frappe.get_cached_doc("Authorization Control").validate_approving_authority(
@@ -306,6 +384,75 @@ class Quotation(SellingController):
# update enquiry status
self.update_opportunity("Quotation")
self.update_lead()
self.deactivate_other_versions()
self.update_latest_revision()
def deactivate_other_versions(self):
if not (self.revision_of and self.is_active):
return
self.update_other_versions({"is_active": 1}, {"is_active": 0})
def set_other_versions_as_lost(self):
self.update_other_versions(VERSIONS_TO_SET_AS_LOST, {"status": "Lost", "is_active": 0})
@property
def has_ordered_versions(self) -> bool:
return bool(self.get_other_versions({"status": ["in", ["Partially Ordered", "Ordered"]]}))
@property
def has_versions_to_set_as_lost(self) -> bool:
return bool(self.get_other_versions(VERSIONS_TO_SET_AS_LOST))
def update_other_versions(self, filters: dict, values: dict):
self.update_versions({version.name: values for version in self.get_other_versions(filters)})
def update_latest_revision(self):
versions = self.get_other_versions({})
if not (versions or self.is_latest_revision):
return
if self.docstatus == 1:
versions.append(self)
latest = max(versions, key=get_version_order).name if len(versions) > 1 else None
self.update_versions(
{
version.name: {"is_latest_revision": int(version.name == latest)}
for version in versions
if version.name != self.name
},
update_modified=False,
)
self.db_set("is_latest_revision", int(self.name == latest), update_modified=False)
@staticmethod
def update_versions(updates: dict[str, dict], update_modified: bool = True):
frappe.db.bulk_update("Quotation", updates, update_modified=update_modified)
for name in updates:
frappe.clear_document_cache("Quotation", name)
@property
def is_latest_version(self) -> bool:
return not self.get_newer_versions()
def get_newer_versions(self) -> list[frappe._dict]:
own_order = get_version_order(self)
return [version for version in self.get_other_versions({}) if get_version_order(version) > own_order]
def validate_can_be_revised(self):
if self.status in ("Lost", "Ordered"):
frappe.throw(_("Cannot revise a Quotation with status {0}.").format(_(self.status)))
def get_other_versions(self, filters: dict, ignore_permissions: bool = True) -> list[frappe._dict]:
original = self.revision_of or self.name
return frappe.get_list(
"Quotation",
filters={"docstatus": 1, "name": ["!=", self.name], **filters},
or_filters={"name": original, "revision_of": original},
fields=["name", "transaction_date", "creation"],
ignore_permissions=ignore_permissions,
)
def on_cancel(self):
if self.lost_reasons:
@@ -316,6 +463,7 @@ class Quotation(SellingController):
self.set_status(update=True)
self.update_opportunity("Open")
self.update_lead()
self.update_latest_revision()
def carry_forward_communication(self):
from erpnext.crm.utils import copy_comments, link_communications
@@ -358,6 +506,10 @@ class Quotation(SellingController):
return rows_with_alternatives
def get_version_order(version) -> tuple:
return (getdate(version.transaction_date), get_datetime(version.creation))
def get_list_context(context=None):
from erpnext.controllers.website_list_for_contact import get_list_context
@@ -375,6 +527,26 @@ def get_list_context(context=None):
return list_context
@frappe.whitelist()
def get_open_count(doctype: str, name: str, items: str | list[str]) -> dict:
items = frappe.parse_json(items)
if not (isinstance(items, list) and all(isinstance(item, str) for item in items)):
frappe.throw(_("Items must be a list of DocType names."))
counts = get_linked_document_counts(doctype, name, [item for item in items if item != "Quotation"])
versions = [
version.name
for version in frappe.get_doc("Quotation", name).get_other_versions(
{"docstatus": ["!=", 2]}, ignore_permissions=False
)
]
if versions and counts["count"]:
counts["count"]["internal_links_found"].append(
{"doctype": "Quotation", "names": versions, "count": len(versions), "open_count": 0}
)
return counts
def set_expired_status():
quotation = frappe.qb.DocType("Quotation")
so = frappe.qb.DocType("Sales Order")

View File

@@ -4,11 +4,14 @@ from frappe import _
def get_data():
return {
"fieldname": "prevdoc_docname",
"method": "erpnext.selling.doctype.quotation.quotation.get_open_count",
"non_standard_fieldnames": {
"Auto Repeat": "reference_document",
"Quotation": "revision_of",
},
"transactions": [
{"label": _("Sales Order"), "items": ["Sales Order"]},
{"label": _("Subscription"), "items": ["Auto Repeat"]},
{"label": _("Versions"), "items": ["Quotation"]},
],
}

View File

@@ -1,5 +1,14 @@
frappe.listview_settings["Quotation"] = {
add_fields: ["customer_name", "base_grand_total", "status", "company", "currency", "valid_till"],
add_fields: [
"customer_name",
"base_grand_total",
"status",
"company",
"currency",
"valid_till",
"is_active",
"is_latest_revision",
],
onload: function (listview) {
if (listview.page.fields_dict.quotation_to) {
@@ -26,14 +35,18 @@ frappe.listview_settings["Quotation"] = {
},
get_indicator: function (doc) {
if (doc.status === "Open") {
if (doc.status === "Lost") {
return [__("Lost"), "gray", "status,=,Lost"];
} else if (doc.docstatus === 1 && !doc.is_active) {
return [__("Inactive"), "red", "is_active,=,0"];
} else if (doc.status === "Open" && doc.is_latest_revision) {
return [__("Latest"), "orange", "is_latest_revision,=,1"];
} else if (doc.status === "Open") {
return [__("Open"), "orange", "status,=,Open"];
} else if (doc.status === "Partially Ordered") {
return [__("Partially Ordered"), "yellow", "status,=,Partially Ordered"];
} else if (doc.status === "Ordered") {
return [__("Ordered"), "green", "status,=,Ordered"];
} else if (doc.status === "Lost") {
return [__("Lost"), "gray", "status,=,Lost"];
} else if (doc.status === "Expired") {
return [__("Expired"), "gray", "status,=,Expired"];
}

View File

@@ -8,7 +8,9 @@ from frappe.tests import change_settings
from frappe.utils import add_days, add_months, flt, getdate, nowdate
from erpnext.controllers.accounts_controller import InvalidQtyError, update_child_qty_rate
from erpnext.selling.doctype.quotation.mapper import make_sales_order
from erpnext.crm.doctype.opportunity.test_opportunity import make_opportunity
from erpnext.selling.doctype.quotation.mapper import make_revision, make_sales_invoice, make_sales_order
from erpnext.selling.doctype.quotation.quotation import get_open_count
from erpnext.tests.utils import ERPNextTestSuite
@@ -496,6 +498,297 @@ class TestQuotation(ERPNextTestSuite):
make_sales_order(quotation.name)
def test_revision_names_follow_the_original(self):
quotation = make_quotation()
first_revision = make_revision(quotation.name)
first_revision.insert()
first_revision.submit()
second_revision = make_revision(first_revision.name).insert()
self.assertEqual(first_revision.name, f"{quotation.name}-R1")
self.assertEqual(second_revision.name, f"{quotation.name}-R2")
self.assertEqual(second_revision.revision_of, quotation.name)
def test_every_version_lists_the_other_versions(self):
quotation = make_quotation()
first_revision = make_revision(quotation.name)
first_revision.insert()
first_revision.submit()
second_revision = make_revision(first_revision.name).insert()
counts = get_open_count("Quotation", first_revision.name, ["Quotation", "Sales Order"])
versions = next(
link for link in counts["count"]["internal_links_found"] if link["doctype"] == "Quotation"
)
self.assertCountEqual(versions["names"], [quotation.name, second_revision.name])
def test_revision_copies_items_and_clears_validity(self):
opportunity = make_opportunity(with_items=1)
quotation = make_quotation(rate=250, do_not_save=1)
quotation.valid_till = add_days(nowdate(), 10)
quotation.items[0].prevdoc_doctype = "Opportunity"
quotation.items[0].prevdoc_docname = opportunity.name
quotation.insert()
quotation.submit()
revision = make_revision(quotation.name).insert()
self.assertIsNone(revision.valid_till)
self.assertEqual(revision.items[0].rate, 250)
self.assertEqual(revision.items[0].prevdoc_docname, opportunity.name)
def test_latest_revision_is_flagged(self):
quotation = make_quotation()
self.assertEqual(quotation.is_latest_revision, 0)
first_revision = make_revision(quotation.name)
first_revision.insert()
first_revision.submit()
second_revision = make_revision(first_revision.name)
second_revision.insert()
second_revision.submit()
self.assertEqual(self.get_latest_revision_flags(quotation), [0, 0, 1])
second_revision.cancel()
self.assertEqual(self.get_latest_revision_flags(quotation), [0, 1, 0])
def get_latest_revision_flags(self, quotation):
return [
frappe.db.get_value("Quotation", name, "is_latest_revision")
for name in (quotation.name, f"{quotation.name}-R1", f"{quotation.name}-R2")
]
def test_submitting_a_revision_deactivates_other_versions(self):
quotation = make_quotation()
first_revision = make_revision(quotation.name)
first_revision.insert()
first_revision.submit()
self.assertEqual(frappe.db.get_value("Quotation", quotation.name, "is_active"), 0)
second_revision = make_revision(first_revision.name)
second_revision.insert()
second_revision.submit()
self.assertEqual(frappe.db.get_value("Quotation", first_revision.name, "is_active"), 0)
self.assertEqual(frappe.db.get_value("Quotation", second_revision.name, "is_active"), 1)
def test_setting_a_revision_as_lost_sets_other_versions_as_lost(self):
quotation = make_quotation()
revision = make_revision(quotation.name)
revision.insert()
revision.submit()
revision.declare_enquiry_lost([], [])
self.assertEqual(frappe.db.get_value("Quotation", quotation.name, "status"), "Lost")
def test_version_cannot_be_set_as_lost_when_another_version_is_ordered(self):
quotation = make_quotation()
revision = make_revision(quotation.name)
revision.insert()
revision.submit()
sales_order = make_sales_order(revision.name)
sales_order.delivery_date = nowdate()
sales_order.insert()
sales_order.submit()
quotation.reload()
self.assertRaises(frappe.ValidationError, quotation.declare_enquiry_lost, [], [])
def test_opportunity_is_lost_when_an_older_version_is_set_as_lost(self):
opportunity = make_opportunity(with_items=1)
quotation = make_quotation(do_not_save=1)
quotation.items[0].prevdoc_doctype = "Opportunity"
quotation.items[0].prevdoc_docname = opportunity.name
quotation.insert()
quotation.submit()
revision = make_revision(quotation.name)
revision.insert()
revision.submit()
quotation.reload()
quotation.declare_enquiry_lost([], [])
self.assertEqual(frappe.db.get_value("Opportunity", opportunity.name, "status"), "Lost")
def test_draft_revision_cannot_be_submitted_after_the_quotation_is_lost(self):
quotation = make_quotation()
revision = make_revision(quotation.name).insert()
quotation.declare_enquiry_lost([], [])
self.assertRaises(frappe.ValidationError, revision.submit)
def test_revision_keeps_the_company_of_the_original(self):
quotation = make_quotation()
revision = make_revision(quotation.name)
revision.company = "_Test Company 1"
self.assertRaisesRegex(frappe.ValidationError, "same company", revision.insert)
def test_revision_keeps_the_customer_of_the_original(self):
quotation = make_quotation()
revision = make_revision(quotation.name)
revision.update(
{"party_name": "_Test Customer 1", "customer_address": None, "shipping_address_name": None}
)
self.assertRaisesRegex(frappe.ValidationError, "same Customer", revision.insert)
def test_revision_of_a_lead_quotation_can_move_to_its_customer(self):
from erpnext.crm.doctype.lead.mapper import make_customer
from erpnext.crm.doctype.lead.test_lead import make_lead
lead = make_lead()
quotation = make_quotation(do_not_save=1)
quotation.quotation_to = "Lead"
quotation.party_name = lead.name
quotation.insert()
quotation.submit()
customer = make_customer(lead.name).insert(ignore_permissions=True)
unrelated_revision = make_revision(quotation.name)
unrelated_revision.update({"quotation_to": "Customer", "party_name": "_Test Customer"})
self.assertRaisesRegex(frappe.ValidationError, "same Lead", unrelated_revision.insert)
revision = make_revision(quotation.name)
revision.update({"quotation_to": "Customer", "party_name": customer.name})
revision.insert()
def test_an_older_version_can_be_set_as_lost(self):
quotation = make_quotation()
revision = make_revision(quotation.name)
revision.insert()
revision.submit()
quotation.reload()
self.assertTrue(quotation.has_versions_to_set_as_lost)
quotation.declare_enquiry_lost([], [])
self.assertEqual(frappe.db.get_value("Quotation", quotation.name, "is_active"), 1)
self.assertEqual(
frappe.db.get_value("Quotation", revision.name, ["status", "is_active"]), ("Lost", 0)
)
self.assertFalse(quotation.has_versions_to_set_as_lost)
def test_latest_version_is_ordered_by_transaction_date(self):
quotation = make_quotation()
first_revision = make_revision(quotation.name)
first_revision.transaction_date = add_days(quotation.transaction_date, 2)
first_revision.insert()
second_revision = make_revision(quotation.name)
second_revision.transaction_date = add_days(quotation.transaction_date, 1)
second_revision.insert()
second_revision.submit()
first_revision.submit()
self.assertTrue(first_revision.is_latest_version)
self.assertFalse(second_revision.is_latest_version)
def test_older_revision_cannot_be_submitted_after_a_newer_one(self):
quotation = make_quotation()
first_revision = make_revision(quotation.name).insert()
second_revision = make_revision(quotation.name).insert()
second_revision.submit()
self.assertRaises(frappe.ValidationError, first_revision.submit)
def test_revision_cannot_be_dated_before_the_latest_version(self):
quotation = make_quotation()
revision = make_revision(quotation.name)
revision.transaction_date = add_days(quotation.transaction_date, -1)
self.assertRaises(frappe.ValidationError, revision.insert)
def test_an_older_version_can_be_revised(self):
quotation = make_quotation()
first_revision = make_revision(quotation.name)
first_revision.insert()
first_revision.submit()
second_revision = make_revision(quotation.name).insert()
self.assertEqual(second_revision.name, f"{quotation.name}-R2")
def test_is_active_is_locked_on_a_lost_quotation(self):
quotation = make_quotation()
quotation.declare_enquiry_lost([], [])
quotation.reload()
quotation.is_active = 0
self.assertRaises(frappe.ValidationError, quotation.save)
def test_lost_quotation_cannot_be_revised(self):
quotation = make_quotation()
quotation.declare_enquiry_lost([], [])
self.assertRaises(frappe.ValidationError, make_revision, quotation.name)
def test_draft_quotation_cannot_be_revised(self):
quotation = make_quotation(do_not_submit=1)
self.assertRaises(frappe.ValidationError, make_revision, quotation.name)
def test_inactive_quotation_cannot_be_ordered_or_invoiced(self):
quotation = make_quotation()
quotation.is_active = 0
quotation.save()
self.assertRaises(frappe.ValidationError, make_sales_order, quotation.name)
self.assertRaises(frappe.ValidationError, make_sales_invoice, quotation.name)
def test_sales_order_cannot_be_submitted_against_a_lost_quotation(self):
quotation = make_quotation()
sales_order = make_sales_order(quotation.name)
sales_order.delivery_date = nowdate()
sales_order.insert()
quotation.declare_enquiry_lost([], [])
self.assertRaisesRegex(frappe.ValidationError, "is Lost", sales_order.submit)
def test_sales_order_cannot_be_submitted_against_an_inactive_quotation(self):
quotation = make_quotation()
sales_order = make_sales_order(quotation.name)
sales_order.delivery_date = nowdate()
sales_order.insert()
quotation.is_active = 0
quotation.save()
self.assertRaises(frappe.ValidationError, sales_order.submit)
def test_deactivating_a_quotation_reopens_its_opportunity(self):
opportunity = make_opportunity(with_items=0)
quotation = make_quotation(do_not_save=1)
quotation.opportunity = opportunity.name
quotation.insert()
quotation.submit()
quotation.is_active = 0
quotation.save()
self.assertEqual(frappe.db.get_value("Opportunity", opportunity.name, "status"), "Open")
def test_inactive_quotation_is_not_an_active_offer(self):
opportunity = make_opportunity(with_items=1)
quotation = make_quotation(do_not_save=1)
quotation.items[0].prevdoc_doctype = "Opportunity"
quotation.items[0].prevdoc_docname = opportunity.name
quotation.insert()
quotation.submit()
opportunity.reload()
self.assertTrue(opportunity.has_active_quotation())
quotation.is_active = 0
quotation.save()
self.assertFalse(opportunity.has_active_quotation())
def test_create_quotation_with_margin(self):
from erpnext.selling.doctype.quotation.mapper import make_sales_order
from erpnext.selling.doctype.sales_order.mapper import (
@@ -505,15 +798,14 @@ class TestQuotation(ERPNextTestSuite):
rate_with_margin = flt((1500 * 18.75) / 100 + 1500)
test_record = frappe.copy_doc(self.globalTestRecords["Quotation"][0])
quotation = frappe.copy_doc(self.globalTestRecords["Quotation"][0])
test_record.items[0].price_list_rate = 1500
test_record.items[0].margin_type = "Percentage"
test_record.items[0].margin_rate_or_amount = 18.75
quotation.items[0].price_list_rate = 1500
quotation.items[0].margin_type = "Percentage"
quotation.items[0].margin_rate_or_amount = 18.75
# set rate to zero, so that it is recalculated on save
test_record.items[0].rate = 0
quotation.items[0].rate = 0
quotation = frappe.copy_doc(test_record)
quotation.transaction_date = nowdate()
quotation.valid_till = add_months(quotation.transaction_date, 1)
quotation.insert()

View File

@@ -1257,6 +1257,7 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
get_query_filters: {
company: me.frm.doc.company,
docstatus: 1,
is_active: 1,
status: ["not in", ["Lost", "Ordered"]],
},
allow_child_item_selection: true,

View File

@@ -19,6 +19,7 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
update_linked_doc,
validate_inter_company_party,
)
from erpnext.accounts.utils import pre_submit_validation
from erpnext.controllers.selling_controller import SellingController
from erpnext.manufacturing.doctype.blanket_order.blanket_order import (
validate_against_blanket_order,
@@ -274,6 +275,7 @@ class SalesOrder(SellingController):
StatusService(self).set_default_statuses()
self.reset_default_field_value("set_warehouse", "items", "warehouse")
pre_submit_validation(self, check_credit_limit=True)
def set_has_unit_price_items(self):
"""
@@ -502,6 +504,12 @@ class SalesOrder(SellingController):
if doc.docstatus.is_cancelled():
frappe.throw(_("Quotation {0} is cancelled").format(quotation))
if flag == "submit" and doc.status == "Lost":
frappe.throw(_("Quotation {0} is Lost").format(quotation))
if flag == "submit" and not doc.is_active:
frappe.throw(_("Quotation {0} is inactive").format(quotation))
doc.set_status(update=True)
doc.update_opportunity("Converted" if flag == "submit" else "Quotation")

View File

@@ -1,44 +0,0 @@
{
"allow_roles": [
{
"role": "Stock Manager"
},
{
"role": "Stock User"
}
],
"creation": "2026-02-20 13:52:55.989409",
"docstatus": 0,
"doctype": "Module Onboarding",
"idx": 0,
"is_complete": 0,
"modified": "2026-02-20 13:53:46.461261",
"modified_by": "Administrator",
"module": "Selling",
"name": "Stock Onboarding",
"owner": "Administrator",
"steps": [
{
"step": "Setup Warehouse"
},
{
"step": "Create Item"
},
{
"step": "Create Purchase Receipt"
},
{
"step": "Create Delivery Note"
},
{
"step": "View Stock Balance Report"
},
{
"step": "View Stock Ledger Report"
},
{
"step": "Create Stock Entry"
}
],
"title": "Onboarding for Stock!"
}

View File

@@ -1,20 +0,0 @@
{
"action": "Create Entry",
"action_label": "Create delivery note",
"creation": "2026-02-20 13:42:13.571273",
"docstatus": 0,
"doctype": "Onboarding Step",
"idx": 3,
"is_complete": 0,
"is_single": 0,
"is_skipped": 0,
"modified": "2026-02-23 17:38:50.095742",
"modified_by": "Administrator",
"name": "Create Delivery Note",
"owner": "Administrator",
"reference_document": "Delivery Note",
"show_form_tour": 0,
"show_full_form": 0,
"title": "Create Delivery Note",
"validate_action": 1
}

View File

@@ -1,23 +0,0 @@
{
"action": "View Report",
"action_label": "View Stock Balance Report",
"creation": "2026-02-20 13:52:54.499787",
"docstatus": 0,
"doctype": "Onboarding Step",
"idx": 0,
"is_complete": 0,
"is_single": 0,
"is_skipped": 0,
"modified": "2026-02-20 13:52:54.499787",
"modified_by": "Administrator",
"name": "View Stock Balance Report",
"owner": "Administrator",
"reference_report": "Stock Balance",
"report_description": "View Stock Balance Report",
"report_reference_doctype": "Stock Ledger Entry",
"report_type": "Script Report",
"show_form_tour": 0,
"show_full_form": 0,
"title": "View Stock Balance Report",
"validate_action": 1
}

View File

@@ -9,14 +9,14 @@
"docstatus": 0,
"doctype": "Print Format",
"font_size": 0,
"html": "<div class=\"proforma-print\">\n\t<style>\n\t\t.proforma-print { font-family: \"Inter\", sans-serif; color: #1f272e; font-size: 12px; }\n\t\t.proforma-print h2 { margin: 0; font-size: 20px; letter-spacing: 1px; }\n\t\t.proforma-print .muted { color: #6b7280; }\n\t\t.proforma-print table { width: 100%; border-collapse: collapse; }\n\t\t.proforma-print .meta-table td { padding: 2px 0; vertical-align: top; }\n\t\t.proforma-print .items-table th, .proforma-print .items-table td {\n\t\t\tborder-bottom: 1px solid #e5e7eb; padding: 8px 6px; text-align: left;\n\t\t}\n\t\t.proforma-print .items-table th { border-bottom: 2px solid #9ca3af; }\n\t\t.proforma-print .text-right { text-align: right !important; }\n\t\t.proforma-print .totals { width: 45%; margin-left: auto; margin-top: 12px; }\n\t\t.proforma-print .totals td { padding: 4px 6px; }\n\t\t.proforma-print .grand { border-top: 2px solid #9ca3af; font-weight: 600; font-size: 14px; }\n\t\t.proforma-print .footer-note { margin-top: 30px; font-size: 11px; color: #6b7280; }\n\t</style>\n\n\t<table class=\"meta-table\">\n\t\t<tr>\n\t\t\t<td style=\"width: 60%;\">\n\t\t\t\t<h2>{{ _(\"PROFORMA INVOICE\") }}</h2>\n\t\t\t\t<div class=\"muted\">{{ doc.company }}</div>\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t<table style=\"width: 100%;\">\n\t\t\t\t\t<tr><td class=\"text-right muted\">{{ _(\"Proforma No\") }}</td><td class=\"text-right\">{{ doc.proforma_no or doc.name }}</td></tr>\n\t\t\t\t\t<tr><td class=\"text-right muted\">{{ _(\"Date\") }}</td><td class=\"text-right\">{{ frappe.utils.formatdate(doc.proforma_date) }}</td></tr>\n\t\t\t\t\t<tr><td class=\"text-right muted\">{{ _(\"Against Sales Order\") }}</td><td class=\"text-right\">{{ doc.name }}</td></tr>\n\t\t\t\t</table>\n\t\t\t</td>\n\t\t</tr>\n\t</table>\n\n\t<hr style=\"border: none; border-top: 1px solid #e5e7eb; margin: 14px 0;\">\n\n\t<table class=\"meta-table\">\n\t\t<tr>\n\t\t\t<td><strong>{{ _(\"Bill To\") }}</strong><br>{{ doc.customer_name }}</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{% if doc.customer_address %}{{ doc.get_formatted(\"address_display\") }}{% endif %}\n\t\t\t</td>\n\t\t</tr>\n\t</table>\n\n\t<table class=\"items-table\" style=\"margin-top: 16px;\">\n\t\t<thead>\n\t\t\t<tr>\n\t\t\t\t<th style=\"width: 5%;\">{{ _(\"Sr\") }}</th>\n\t\t\t\t<th style=\"width: 45%;\">{{ _(\"Item\") }}</th>\n\t\t\t\t{% if not doc.hide_item_qty %}<th class=\"text-right\" style=\"width: 14%;\">{{ _(\"Qty\") }}</th>{% endif %}\n\t\t\t\t{% if not doc.hide_item_qty %}<th class=\"text-right\" style=\"width: 16%;\">{{ _(\"Rate\") }}</th>{% endif %}\n\t\t\t\t<th class=\"text-right\" style=\"width: 20%;\">{{ _(\"Amount\") }}</th>\n\t\t\t</tr>\n\t\t</thead>\n\t\t<tbody>\n\t\t\t{% for row in doc.items %}\n\t\t\t<tr>\n\t\t\t\t<td>{{ loop.index }}</td>\n\t\t\t\t<td><strong>{{ row.item_code }}</strong>{% if row.item_name != row.item_code %}<br><span class=\"muted\">{{ row.item_name }}</span>{% endif %}</td>\n\t\t\t\t{% if not doc.hide_item_qty %}<td class=\"text-right\">{{ row.get_formatted(\"qty\") }} {{ row.uom }}</td>{% endif %}\n\t\t\t\t{% if not doc.hide_item_qty %}<td class=\"text-right\">{{ row.get_formatted(\"rate\", doc) }}</td>{% endif %}\n\t\t\t\t<td class=\"text-right\">{{ row.get_formatted(\"amount\", doc) }}</td>\n\t\t\t</tr>\n\t\t\t{% endfor %}\n\t\t</tbody>\n\t</table>\n\n\t<table class=\"totals\">\n\t\t<tr>\n\t\t\t<td class=\"muted\">{{ _(\"Net Total\") }}</td>\n\t\t\t<td class=\"text-right\">{{ doc.get_formatted(\"net_total\") }}</td>\n\t\t</tr>\n\t\t{% for tax in doc.taxes %}\n\t\t\t{% if tax.tax_amount %}\n\t\t\t<tr>\n\t\t\t\t<td class=\"muted\">{{ tax.description }}</td>\n\t\t\t\t<td class=\"text-right\">{{ tax.get_formatted(\"tax_amount\", doc) }}</td>\n\t\t\t</tr>\n\t\t\t{% endif %}\n\t\t{% endfor %}\n\t\t<tr class=\"grand\">\n\t\t\t<td>{{ _(\"Grand Total\") }}</td>\n\t\t\t<td class=\"text-right\">{{ doc.get_formatted(\"grand_total\") }}</td>\n\t\t</tr>\n\t</table>\n\n\t<div class=\"footer-note\">\n\t\t{{ _(\"This is a proforma invoice and is not a demand for payment or a tax invoice.\") }}\n\t</div>\n</div>\n",
"html": "<div class=\"proforma-print\">\n\t<style>\n\t\t.proforma-print { font-family: \"Inter\", sans-serif; color: #1f272e; font-size: 12px; }\n\t\t.proforma-print h2 { margin: 0; font-size: 20px; letter-spacing: 1px; }\n\t\t.proforma-print .muted { color: #6b7280; }\n\t\t.proforma-print table { width: 100%; border-collapse: collapse; }\n\t\t.proforma-print .meta-table td { padding: 2px 0; vertical-align: top; }\n\t\t.proforma-print .items-table th, .proforma-print .items-table td {\n\t\t\tborder-bottom: 1px solid #e5e7eb; padding: 8px 6px; text-align: left;\n\t\t}\n\t\t.proforma-print .items-table th { border-bottom: 2px solid #9ca3af; }\n\t\t.proforma-print .text-right { text-align: right !important; }\n\t\t.proforma-print .totals { width: 45%; margin-left: auto; margin-top: 12px; }\n\t\t.proforma-print .totals td { padding: 4px 6px; }\n\t\t.proforma-print .grand { border-top: 2px solid #9ca3af; font-weight: 600; font-size: 14px; }\n\t</style>\n\n\t<table class=\"meta-table\">\n\t\t<tr>\n\t\t\t<td style=\"width: 60%;\">\n\t\t\t\t<h2>{{ _(\"PROFORMA INVOICE\") }}</h2>\n\t\t\t\t<div class=\"muted\">{{ doc.company }}</div>\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t<table style=\"width: 100%;\">\n\t\t\t\t\t<tr><td class=\"text-right muted\">{{ _(\"Proforma No\") }}</td><td class=\"text-right\">{{ doc.proforma_no or doc.name }}</td></tr>\n\t\t\t\t\t<tr><td class=\"text-right muted\">{{ _(\"Date\") }}</td><td class=\"text-right\">{{ frappe.utils.formatdate(doc.proforma_date) }}</td></tr>\n\t\t\t\t</table>\n\t\t\t</td>\n\t\t</tr>\n\t</table>\n\n\t<hr style=\"border: none; border-top: 1px solid #e5e7eb; margin: 14px 0;\">\n\n\t<table class=\"meta-table\">\n\t\t<tr>\n\t\t\t<td>\n\t\t\t\t<strong>{{ _(\"Bill To\") }}</strong><br>{{ doc.customer_name }}\n\t\t\t\t{% if doc.customer_address %}<br>{{ doc.get_formatted(\"address_display\") }}{% endif %}\n\t\t\t</td>\n\t\t</tr>\n\t</table>\n\n\t<table class=\"items-table\" style=\"margin-top: 16px;\">\n\t\t<thead>\n\t\t\t<tr>\n\t\t\t\t<th style=\"width: 5%;\">{{ _(\"Sr\") }}</th>\n\t\t\t\t<th style=\"width: 45%;\">{{ _(\"Item\") }}</th>\n\t\t\t\t{% if not doc.hide_item_qty %}<th class=\"text-right\" style=\"width: 14%;\">{{ _(\"Qty\") }}</th>{% endif %}\n\t\t\t\t{% if not doc.hide_item_qty %}<th class=\"text-right\" style=\"width: 16%;\">{{ _(\"Rate\") }}</th>{% endif %}\n\t\t\t\t<th class=\"text-right\" style=\"width: 20%;\">{{ _(\"Amount\") }}</th>\n\t\t\t</tr>\n\t\t</thead>\n\t\t<tbody>\n\t\t\t{% for row in doc.items %}\n\t\t\t<tr>\n\t\t\t\t<td>{{ loop.index }}</td>\n\t\t\t\t<td>\n\t\t\t\t\t<strong>{{ row.item_code }}</strong>{% if row.item_name != row.item_code %}<br><span class=\"muted\">{{ row.item_name }}</span>{% endif %}\n\t\t\t\t\t{% if row.description and (row.description | striptags) != row.item_name %}<div class=\"muted\">{{ row.description }}</div>{% endif %}\n\t\t\t\t</td>\n\t\t\t\t{% if not doc.hide_item_qty %}<td class=\"text-right\">{{ row.get_formatted(\"qty\") }} {{ row.uom }}</td>{% endif %}\n\t\t\t\t{% if not doc.hide_item_qty %}<td class=\"text-right\">{{ row.get_formatted(\"rate\", doc) }}</td>{% endif %}\n\t\t\t\t<td class=\"text-right\">{{ row.get_formatted(\"amount\", doc) }}</td>\n\t\t\t</tr>\n\t\t\t{% endfor %}\n\t\t</tbody>\n\t</table>\n\n\t<table class=\"totals\">\n\t\t<tr>\n\t\t\t<td class=\"muted\">{{ _(\"Net Total\") }}</td>\n\t\t\t<td class=\"text-right\">{{ doc.get_formatted(\"net_total\") }}</td>\n\t\t</tr>\n\t\t{% for tax in doc.taxes %}\n\t\t\t{% if tax.tax_amount %}\n\t\t\t<tr>\n\t\t\t\t<td class=\"muted\">{{ tax.description }}</td>\n\t\t\t\t<td class=\"text-right\">{{ tax.get_formatted(\"tax_amount\", doc) }}</td>\n\t\t\t</tr>\n\t\t\t{% endif %}\n\t\t{% endfor %}\n\t\t<tr class=\"grand\">\n\t\t\t<td>{{ _(\"Grand Total\") }}</td>\n\t\t\t<td class=\"text-right\">{{ doc.get_formatted(\"grand_total\") }}</td>\n\t\t</tr>\n\t</table>\n</div>\n",
"idx": 0,
"line_breaks": 0,
"margin_bottom": 15.0,
"margin_left": 15.0,
"margin_right": 15.0,
"margin_top": 15.0,
"modified": "2026-07-16 00:00:00.000000",
"modified": "2026-09-25 10:00:00.000000",
"modified_by": "Administrator",
"module": "Selling",
"name": "Proforma Invoice",

Some files were not shown because too many files have changed in this diff Show More