Merge branch 'frappe:develop' into add-employee-name-to-session-user

This commit is contained in:
El-Shafei H.
2025-09-24 11:26:38 +03:00
committed by GitHub
161 changed files with 23381 additions and 15109 deletions

View File

@@ -85,7 +85,7 @@ jobs:
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
id: yarn-cache

View File

@@ -111,7 +111,7 @@ jobs:
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
id: yarn-cache

View File

@@ -109,7 +109,7 @@ jobs:
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
id: yarn-cache

View File

@@ -94,7 +94,7 @@ jobs:
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
id: yarn-cache

View File

@@ -10,8 +10,10 @@ from frappe.contacts.doctype.address.address import (
class ERPNextAddress(Address):
def validate(self):
self.validate_reference()
self.update_compnay_address()
super().validate()
self.update_company_address()
if hasattr(super(), "validate"):
super().validate()
def link_address(self):
"""Link address based on owner"""
@@ -20,7 +22,7 @@ class ERPNextAddress(Address):
return super().link_address()
def update_compnay_address(self):
def update_company_address(self):
for link in self.get("links"):
if link.link_doctype == "Company":
self.is_your_company_address = 1
@@ -38,6 +40,10 @@ class ERPNextAddress(Address):
"""
After Address is updated, update the related 'Primary Address' on Customer.
"""
if hasattr(super(), "on_update"):
super().on_update()
address_display = get_address_display(self.as_dict())
filters = {"customer_primary_address": self.name}
customers = frappe.db.get_all("Customer", filters=filters, as_list=True)

View File

@@ -11,6 +11,9 @@
"cost_center",
"debit",
"credit",
"reporting_currency_exchange_rate",
"debit_in_reporting_currency",
"credit_in_reporting_currency",
"account_currency",
"debit_in_account_currency",
"credit_in_account_currency",
@@ -124,12 +127,30 @@
"fieldname": "is_period_closing_voucher_entry",
"fieldtype": "Check",
"label": "Is Period Closing Voucher Entry"
},
{
"fieldname": "debit_in_reporting_currency",
"fieldtype": "Currency",
"label": "Debit Amount in Reporting Currency",
"options": "Company:company:reporting_currency"
},
{
"fieldname": "credit_in_reporting_currency",
"fieldtype": "Currency",
"label": "Credit Amount in Reporting Currency",
"options": "Company:company:reporting_currency"
},
{
"fieldname": "reporting_currency_exchange_rate",
"fieldtype": "Float",
"label": "Reporting Currency Exchange Rate",
"precision": "9"
}
],
"icon": "fa fa-list",
"in_create": 1,
"links": [],
"modified": "2024-03-27 13:05:56.710541",
"modified": "2025-08-22 19:13:50.400404",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Account Closing Balance",
@@ -158,7 +179,8 @@
"role": "Auditor"
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}
}

View File

@@ -2,12 +2,15 @@
# For license information, please see license.txt
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import cint, cstr
from frappe.utils import cint, cstr, flt
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
)
from erpnext.exceptions import ReportingCurrencyExchangeNotFoundError
from erpnext.setup.utils import get_exchange_rate
class AccountClosingBalance(Document):
@@ -26,12 +29,15 @@ class AccountClosingBalance(Document):
cost_center: DF.Link | None
credit: DF.Currency
credit_in_account_currency: DF.Currency
credit_in_reporting_currency: DF.Currency
debit: DF.Currency
debit_in_account_currency: DF.Currency
debit_in_reporting_currency: DF.Currency
finance_book: DF.Link | None
is_period_closing_voucher_entry: DF.Check
period_closing_voucher: DF.Link | None
project: DF.Link | None
reporting_currency_exchange_rate: DF.Float
# end: auto-generated types
pass
@@ -55,6 +61,7 @@ def make_closing_entries(closing_entries, voucher_name, company, closing_date):
"closing_date": closing_date,
}
)
set_amount_in_reporting_currency(cle, company, closing_date)
cle.flags.ignore_permissions = True
cle.flags.ignore_links = True
cle.submit()
@@ -144,3 +151,29 @@ def get_previous_closing_entries(company, closing_date, accounting_dimensions):
entries = query.run(as_dict=1)
return entries
def set_amount_in_reporting_currency(cle, company, closing_date):
default_currency, reporting_currency = frappe.get_cached_value(
"Company", company, ["default_currency", "reporting_currency"]
)
reporting_currency_exchange_rate = get_exchange_rate(default_currency, reporting_currency, closing_date)
if not reporting_currency_exchange_rate:
frappe.throw(
title=_("Reporting Currency Exchange Not Found"),
msg=_(
"Unable to find exchange rate for {0} to {1} for key date {2}. Please create a Currency Exchange record manually."
).format(default_currency, reporting_currency, closing_date),
exc=ReportingCurrencyExchangeNotFoundError,
)
debit_in_reporting_currency = flt(cle.get("debit", 0) * reporting_currency_exchange_rate)
credit_in_reporting_currency = flt(cle.get("credit", 0) * reporting_currency_exchange_rate)
cle.update(
{
"reporting_currency_exchange_rate": reporting_currency_exchange_rate,
"debit_in_reporting_currency": debit_in_reporting_currency,
"credit_in_reporting_currency": credit_in_reporting_currency,
}
)

View File

@@ -17,6 +17,7 @@
"accounting_dimensions_section",
"cost_center",
"dimension_col_break",
"project",
"section_break_8",
"rate",
"section_break_9",
@@ -95,6 +96,13 @@
"fieldname": "dimension_col_break",
"fieldtype": "Column Break"
},
{
"allow_on_submit": 1,
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
},
{
"fieldname": "section_break_8",
"fieldtype": "Section Break"

View File

@@ -155,8 +155,10 @@ def get_payment_entries_for_bank_clearance(
entries = []
condition = ""
pe_condition = ""
if not include_reconciled_entries:
condition = "and (clearance_date IS NULL or clearance_date='0000-00-00')"
pe_condition = "and (pe.clearance_date IS NULL or pe.clearance_date='0000-00-00')"
journal_entries = frappe.db.sql(
f"""
@@ -181,19 +183,20 @@ def get_payment_entries_for_bank_clearance(
payment_entries = frappe.db.sql(
f"""
select
"Payment Entry" as payment_document, name as payment_entry,
reference_no as cheque_number, reference_date as cheque_date,
if(paid_from=%(account)s, paid_amount + total_taxes_and_charges, 0) as credit,
if(paid_from=%(account)s, 0, received_amount + total_taxes_and_charges) as debit,
posting_date, ifnull(party,if(paid_from=%(account)s,paid_to,paid_from)) as against_account, clearance_date,
if(paid_to=%(account)s, paid_to_account_currency, paid_from_account_currency) as account_currency
from `tabPayment Entry`
"Payment Entry" as payment_document, pe.name as payment_entry,
pe.reference_no as cheque_number, pe.reference_date as cheque_date,
if(pe.paid_from=%(account)s, pe.paid_amount + if(pe.payment_type = 'Pay' and c.default_currency = pe.paid_from_account_currency, pe.base_total_taxes_and_charges, pe.total_taxes_and_charges) , 0) as credit,
if(pe.paid_from=%(account)s, 0, pe.received_amount + pe.total_taxes_and_charges) as debit,
pe.posting_date, ifnull(pe.party,if(pe.paid_from=%(account)s,pe.paid_to,pe.paid_from)) as against_account, pe.clearance_date,
if(pe.paid_to=%(account)s, pe.paid_to_account_currency, pe.paid_from_account_currency) as account_currency
from `tabPayment Entry` as pe
join `tabCompany` c on c.name = pe.company
where
(paid_from=%(account)s or paid_to=%(account)s) and docstatus=1
and posting_date >= %(from)s and posting_date <= %(to)s
{condition}
(pe.paid_from=%(account)s or pe.paid_to=%(account)s) and pe.docstatus=1
and pe.posting_date >= %(from)s and pe.posting_date <= %(to)s
{pe_condition}
order by
posting_date ASC, name DESC
pe.posting_date ASC, pe.name DESC
""",
{
"account": account,

View File

@@ -29,14 +29,17 @@
"against_voucher",
"voucher_detail_no",
"transaction_exchange_rate",
"reporting_currency_exchange_rate",
"amounts_section",
"debit_in_account_currency",
"debit",
"debit_in_transaction_currency",
"debit_in_reporting_currency",
"column_break_bm1w",
"credit_in_account_currency",
"credit",
"credit_in_transaction_currency",
"credit_in_reporting_currency",
"dimensions_section",
"cost_center",
"column_break_lmnm",
@@ -353,13 +356,31 @@
{
"fieldname": "column_break_8abq",
"fieldtype": "Column Break"
},
{
"fieldname": "debit_in_reporting_currency",
"fieldtype": "Currency",
"label": "Debit Amount in Reporting Currency",
"options": "Company:company:reporting_currency"
},
{
"fieldname": "credit_in_reporting_currency",
"fieldtype": "Currency",
"label": "Credit Amount in Reporting Currency",
"options": "Company:company:reporting_currency"
},
{
"fieldname": "reporting_currency_exchange_rate",
"fieldtype": "Float",
"label": "Reporting Currency Exchange Rate",
"precision": "9"
}
],
"icon": "fa fa-list",
"idx": 1,
"in_create": 1,
"links": [],
"modified": "2025-03-21 15:29:11.221890",
"modified": "2025-08-22 12:57:17.750252",
"modified_by": "Administrator",
"module": "Accounts",
"name": "GL Entry",
@@ -390,8 +411,9 @@
}
],
"quick_entry": 1,
"row_format": "Dynamic",
"search_fields": "voucher_no,account,posting_date,against_voucher",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}
}

View File

@@ -19,7 +19,8 @@ from erpnext.accounts.party import (
validate_party_gle_currency,
)
from erpnext.accounts.utils import OUTSTANDING_DOCTYPES, get_account_currency, get_fiscal_year
from erpnext.exceptions import InvalidAccountCurrency
from erpnext.exceptions import InvalidAccountCurrency, ReportingCurrencyExchangeNotFoundError
from erpnext.setup.utils import get_exchange_rate
exclude_from_linked_with = True
@@ -42,9 +43,11 @@ class GLEntry(Document):
cost_center: DF.Link | None
credit: DF.Currency
credit_in_account_currency: DF.Currency
credit_in_reporting_currency: DF.Currency
credit_in_transaction_currency: DF.Currency
debit: DF.Currency
debit_in_account_currency: DF.Currency
debit_in_reporting_currency: DF.Currency
debit_in_transaction_currency: DF.Currency
due_date: DF.Date | None
finance_book: DF.Link | None
@@ -57,6 +60,7 @@ class GLEntry(Document):
posting_date: DF.Date | None
project: DF.Link | None
remarks: DF.Text | None
reporting_currency_exchange_rate: DF.Float
to_rename: DF.Check
transaction_currency: DF.Link | None
transaction_date: DF.Date | None
@@ -88,6 +92,8 @@ class GLEntry(Document):
self.validate_party()
self.validate_currency()
self.set_amount_in_reporting_currency()
def on_update(self):
adv_adj = self.flags.adv_adj
if not self.flags.from_repost and self.voucher_type != "Period Closing Voucher":
@@ -131,18 +137,20 @@ class GLEntry(Document):
if not self.is_cancelled and not (self.party_type and self.party):
account_type = frappe.get_cached_value("Account", self.account, "account_type")
if account_type == "Receivable":
frappe.throw(
_("{0} {1}: Customer is required against Receivable account {2}").format(
self.voucher_type, self.voucher_no, self.account
# skipping validation for payroll entry creation in case party is not required
if not frappe.flags.party_not_required_for_receivable_payable:
if account_type == "Receivable":
frappe.throw(
_("{0} {1}: Customer is required against Receivable account {2}").format(
self.voucher_type, self.voucher_no, self.account
)
)
)
elif account_type == "Payable":
frappe.throw(
_("{0} {1}: Supplier is required against Payable account {2}").format(
self.voucher_type, self.voucher_no, self.account
elif account_type == "Payable":
frappe.throw(
_("{0} {1}: Supplier is required against Payable account {2}").format(
self.voucher_type, self.voucher_no, self.account
)
)
)
# Zero value transaction is not allowed
if not (
@@ -292,6 +300,25 @@ class GLEntry(Document):
if self.party_type and self.party:
validate_party_gle_currency(self.party_type, self.party, self.company, self.account_currency)
def set_amount_in_reporting_currency(self):
default_currency, reporting_currency = frappe.get_cached_value(
"Company", self.company, ["default_currency", "reporting_currency"]
)
transaction_date = self.transaction_date or self.posting_date
self.reporting_currency_exchange_rate = get_exchange_rate(
default_currency, reporting_currency, transaction_date
)
if not self.reporting_currency_exchange_rate:
frappe.throw(
title=_("Reporting Currency Exchange Not Found"),
msg=_(
"Unable to find exchange rate for {0} to {1} for key date {2}. Please create a Currency Exchange record manually."
).format(default_currency, reporting_currency, transaction_date),
exc=ReportingCurrencyExchangeNotFoundError,
)
self.debit_in_reporting_currency = flt(self.debit * self.reporting_currency_exchange_rate)
self.credit_in_reporting_currency = flt(self.credit * self.reporting_currency_exchange_rate)
def validate_and_set_fiscal_year(self):
if not self.fiscal_year:
self.fiscal_year = get_fiscal_year(self.posting_date, company=self.company)[0]

View File

@@ -644,8 +644,11 @@ class JournalEntry(AccountsController):
def validate_party(self):
for d in self.get("accounts"):
account_type = frappe.get_cached_value("Account", d.account, "account_type")
# skipping validation for payroll entry creation
skip_validation = frappe.flags.party_not_required_for_receivable_payable
if account_type in ["Receivable", "Payable"]:
if not (d.party_type and d.party):
if not (d.party_type and d.party) and not skip_validation:
frappe.throw(
_(
"Row {0}: Party Type and Party is required for Receivable / Payable account {1}"

View File

@@ -25,6 +25,7 @@
"accounting_dimensions_section",
"cost_center",
"dimension_col_break",
"project",
"help_section",
"loyalty_program_help"
],
@@ -144,6 +145,12 @@
{
"fieldname": "dimension_col_break",
"fieldtype": "Column Break"
},
{
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
}
],
"links": [],

View File

@@ -14,6 +14,7 @@
"accounting_dimensions_section",
"cost_center",
"dimension_col_break",
"project",
"section_break_4",
"invoices"
],
@@ -63,6 +64,12 @@
"label": "Cost Center",
"options": "Cost Center"
},
{
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
},
{
"collapsible": 1,
"fieldname": "accounting_dimensions_section",

View File

@@ -28,6 +28,7 @@
"accounting_dimensions_section",
"cost_center",
"dimension_col_break",
"project",
"sec_break1",
"invoice_name",
"invoices",
@@ -194,6 +195,12 @@
"label": "Cost Center",
"options": "Cost Center"
},
{
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
},
{
"depends_on": "eval:doc.party",
"description": "Only 'Payment Entries' made against this advance account are supported.",

View File

@@ -5,6 +5,7 @@
import frappe
from frappe import _, msgprint, qb
from frappe.model.document import Document
from frappe.model.meta import get_field_precision
from frappe.query_builder import Criterion
from frappe.query_builder.custom import ConstantColumn
from frappe.utils import flt, fmt_money, get_link_to_form, getdate, nowdate, today
@@ -392,6 +393,12 @@ class PaymentReconciliation(Document):
inv.outstanding_amount = flt(entry.get("outstanding_amount"))
def get_difference_amount(self, payment_entry, invoice, allocated_amount):
allocated_amount_precision = get_field_precision(
frappe.get_meta("Payment Reconciliation Allocation").get_field("allocated_amount")
)
difference_amount_precision = get_field_precision(
frappe.get_meta("Payment Reconciliation Allocation").get_field("difference_amount")
)
difference_amount = 0
if frappe.get_cached_value(
"Account", self.receivable_payable_account, "account_currency"
@@ -399,8 +406,14 @@ class PaymentReconciliation(Document):
if invoice.get("exchange_rate") and payment_entry.get("exchange_rate", 1) != invoice.get(
"exchange_rate", 1
):
allocated_amount_in_ref_rate = payment_entry.get("exchange_rate", 1) * allocated_amount
allocated_amount_in_inv_rate = invoice.get("exchange_rate", 1) * allocated_amount
allocated_amount_in_ref_rate = flt(
payment_entry.get("exchange_rate", 1) * flt(allocated_amount, allocated_amount_precision),
difference_amount_precision,
)
allocated_amount_in_inv_rate = flt(
invoice.get("exchange_rate", 1) * flt(allocated_amount, allocated_amount_precision),
difference_amount_precision,
)
difference_amount = allocated_amount_in_ref_rate - allocated_amount_in_inv_rate
return difference_amount

View File

@@ -452,7 +452,7 @@ def get_pricing_rule_for_item(args, doc=None, for_validate=False):
get_pricing_rule_items(pricing_rule, other_items=fetch_other_item) or []
)
if pricing_rule.coupon_code_based == 1:
if pricing_rule.get("coupon_code_based") == 1:
if not args.coupon_code:
continue
coupon_code = frappe.db.get_value(

View File

@@ -34,11 +34,13 @@
<thead>
<tr>
<th style="width: 12%">{{ _("Date") }}</th>
<th style="width: 15%">{{ _("Reference") }}</th>
<th style="width: 25%">{{ _("Remarks") }}</th>
<th style="width: 20%">{{ _("Reference") }}</th>
<th style="width: 15%">{{ _("Debit") }}</th>
<th style="width: 15%">{{ _("Credit") }}</th>
<th style="width: 18%">{{ _("Balance (Dr - Cr)") }}</th>
{% if filters.show_remarks %}
<th style="width: 20%">{{ _("Remarks") }}</th>
{% endif %}
</tr>
</thead>
<tbody>
@@ -47,36 +49,51 @@
{% if(row.posting_date) %}
<td>{{ frappe.format(row.posting_date, 'Date') }}</td>
<td>{{ row.voucher_type }}
<br>{{ row.voucher_no }}</td>
<td>
{% if not (filters.party or filters.account) %}
<br>{{ row.voucher_no }}
{% if not (filters.party or filters.account) %}
{{ row.party or row.account }}
<br>
{% endif %}
<br>{{ _("Remarks:") }} {{ row.remarks }}
{% if row.bill_no %}
<br>{{ _("Supplier Invoice No") }}: {{ row.bill_no }}
{{ _("Supplier Invoice No") }}: {{ row.bill_no }}
{% endif %}
</td>
<td style="text-align: right">
{{ frappe.utils.fmt_money(row.debit, currency=filters.presentation_currency) }}</td>
<td style="text-align: right">
{{ frappe.utils.fmt_money(row.credit, currency=filters.presentation_currency) }}</td>
<td style="text-align: right">
{{ frappe.utils.fmt_money(row.balance, currency=filters.presentation_currency) }}
</td>
{% if filters.show_remarks %}
<td>
{% if row.remarks %}
{{ _("Remarks:") }} {{ row.remarks }}
{% endif %}
</td>
{% endif %}
{% else %}
<td></td>
<td></td>
<td><b>{{ frappe.format(row.account, {fieldtype: "Link"}) or "&nbsp;" }}</b></td>
<td>
<b>{{ frappe.format(row.account, {fieldtype: "Link"}) or "&nbsp;" }}</b>
</td>
<td style="text-align: right">
{{ row.get('account', '') and frappe.utils.fmt_money(row.debit, currency=filters.presentation_currency) }}
</td>
<td style="text-align: right">
{{ row.get('account', '') and frappe.utils.fmt_money(row.credit, currency=filters.presentation_currency) }}
</td>
{% endif %}
<td style="text-align: right">
{{ frappe.utils.fmt_money(row.balance, currency=filters.presentation_currency) }}
</td>
{% if filters.show_remarks %}
<td>
{% if row.remarks %}
{{ _("Remarks:") }} {{ row.remarks }}
{% endif %}
</td>
{% endif %}
{% endif %}
</tr>
{% endfor %}
</tbody>

View File

@@ -83,6 +83,16 @@ frappe.ui.form.on("Process Statement Of Accounts", {
},
};
});
frm.set_query("print_format", function () {
return {
filters: {
print_format_for: "Report",
report: frm.doc.report,
disabled: 0,
print_format_type: "Jinja",
},
};
});
if (frm.doc.__islocal) {
frm.set_value("from_date", frappe.datetime.add_months(frappe.datetime.get_today(), -1));
frm.set_value("to_date", frappe.datetime.get_today());
@@ -106,6 +116,16 @@ frappe.ui.form.on("Process Statement Of Accounts", {
filters: filters,
};
});
frm.set_query("print_format", function () {
return {
filters: {
print_format_for: "Report",
report: frm.doc.report,
disabled: 0,
print_format_type: "Jinja",
},
};
});
},
customer_collection: function (frm) {
frm.set_value("collection_name", "");

View File

@@ -38,6 +38,7 @@
"column_break_17",
"customers",
"preferences",
"print_format",
"orientation",
"include_break",
"include_ageing",
@@ -406,10 +407,16 @@
"fieldname": "show_future_payments",
"fieldtype": "Check",
"label": "Show Future Payments"
},
{
"fieldname": "print_format",
"fieldtype": "Link",
"label": "Print Format",
"options": "Print Format"
}
],
"links": [],
"modified": "2025-08-29 00:20:08.088189",
"modified": "2025-09-03 14:24:43.608565",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Process Statement Of Accounts",

View File

@@ -67,6 +67,7 @@ class ProcessStatementOfAccounts(Document):
pdf_name: DF.Data | None
posting_date: DF.Date | None
primary_mandatory: DF.Check
print_format: DF.Link | None
project: DF.TableMultiSelect[PSOAProject]
report: DF.Literal["General Ledger", "Accounts Receivable"]
sales_partner: DF.Link | None
@@ -109,6 +110,25 @@ class ProcessStatementOfAccounts(Document):
self.to_date = self.start_date
self.from_date = add_months(self.to_date, -1 * self.filter_duration)
if self.print_format:
pf = frappe.db.get_value(
"Print Format",
self.print_format,
["print_format_type", "print_format_for", "report", "disabled"],
as_dict=True,
)
if not pf:
frappe.throw(title=_("Invalid Print Format"), msg=_("Selected Print Format does not exist."))
if pf.print_format_type != "Jinja":
frappe.throw(title=_("Invalid Print Format"), msg=_("Print Format Type should be Jinja."))
if pf.print_format_for != "Report" or pf.report != self.report or pf.disabled:
frappe.throw(
title=_("Invalid Print Format"),
msg=_(
"Print Format must be an enabled Report Print Format matching the selected Report."
),
)
def validate_account(self):
if not self.account:
return
@@ -290,6 +310,10 @@ def get_html(doc, filters, entry, col, res, ageing):
if process_soa_html and process_soa_html.get(doc.report):
template_path = process_soa_html[doc.report][-1]
if doc.print_format:
custom_html, custom_css = frappe.db.get_value("Print Format", doc.print_format, ["html", "css"])
template_path = f"<style>{custom_css}</style> {custom_html}"
if doc.letter_head:
from frappe.www.printview import get_letter_head

View File

@@ -159,7 +159,7 @@
{% else %}
<th style="width: 24%">{{ _("Reference") }}</th>
{% endif %}
{% if not(filters.show_future_payments) %}
{% if not(filters.show_future_payments) and filters.show_remarks %}
<th style="width: 20%">
{% if (filters.customer or filters.supplier or filters.customer_name) %}
{{ _("Remarks") }}
@@ -228,7 +228,7 @@
<td>{{ data[i]["sales_person"] }}</td>
{% endif %}
{% if not (filters.show_future_payments) %}
{% if not (filters.show_future_payments) and filters.show_remarks %}
<td>
{% if(not(filters.customer or filters.supplier or filters.customer_name)) %}
{{ data[i]["party"] }}
@@ -327,12 +327,23 @@
{% endfor %}
<td></td>
<td></td>
<td></td>
<td></td>
<td style="text-align: right"><b>{{ frappe.utils.fmt_money(data|sum(attribute="invoiced"), currency=data[0]["currency"]) }}</b></td>
<td style="text-align: right"><b>{{ frappe.utils.fmt_money(data|sum(attribute="paid"), currency=data[0]["currency"]) }}</b></td>
<td style="text-align: right"><b>{{ frappe.utils.fmt_money(data|sum(attribute="credit_note"), currency=data[0]["currency"]) }}</b></td>
<td style="text-align: right"><b>{{ frappe.utils.fmt_money(data|sum(attribute="outstanding"), currency=data[0]["currency"]) }}</b></td>
{% if (filters.show_future_payments) or filters.show_remarks %}
<td></td>
{% endif %}
{% if not(filters.show_future_payments) %}
<td></td>
<td style="text-align: right"><b>{{ frappe.utils.fmt_money(data|sum(attribute="invoiced"), currency=data[0]["currency"]) }}</b></td>
<td style="text-align: right"><b>{{ frappe.utils.fmt_money(data|sum(attribute="paid"), currency=data[0]["currency"]) }}</b></td>
<td style="text-align: right"><b>{{ frappe.utils.fmt_money(data|sum(attribute="credit_note"), currency=data[0]["currency"]) }}</b></td>
<td style="text-align: right"><b>{{ frappe.utils.fmt_money(data|sum(attribute="outstanding"), currency=data[0]["currency"]) }}</b></td>
{% else %}
<td style="text-align: right"><b>{{ frappe.utils.fmt_money(data|sum(attribute="invoiced"), currency=data[0]["currency"]) }}</b></td>
<td style="text-align: right"><b>{{ frappe.utils.fmt_money(data|sum(attribute="outstanding"), currency=data[0]["currency"]) }}</b></td>
<td></td>
<td></td>
<td style="text-align: right"><b>{{ frappe.utils.fmt_money(data|sum(attribute="future_amount"), currency=data[0]["currency"]) }}</b></td>
<td style="text-align: right"><b>{{ frappe.utils.fmt_money(data|sum(attribute="remaining_balance"), currency=data[0]["currency"]) }}</b></td>
{% endif %}
</tbody>
</table>
<br>

View File

@@ -23,6 +23,7 @@
"accounting_dimensions_section",
"cost_center",
"dimension_col_break",
"project",
"section_break_9",
"account_currency",
"net_amount",
@@ -214,6 +215,13 @@
"fieldname": "dimension_col_break",
"fieldtype": "Column Break"
},
{
"allow_on_submit": 1,
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
},
{
"default": "0",
"depends_on": "eval:['Purchase Taxes and Charges Template', 'Payment Entry'].includes(parent.doctype)",

View File

@@ -275,6 +275,7 @@
"read_only": 1
},
{
"fetch_from": "customer.tax_id",
"fieldname": "tax_id",
"fieldtype": "Data",
"hide_days": 1,
@@ -2241,7 +2242,7 @@
"link_fieldname": "consolidated_invoice"
}
],
"modified": "2025-08-04 19:20:28.732039",
"modified": "2025-09-09 14:48:59.472826",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice",

View File

@@ -17,6 +17,7 @@
"accounting_dimensions_section",
"cost_center",
"dimension_col_break",
"project",
"section_break_8",
"rate",
"section_break_9",
@@ -191,6 +192,13 @@
"fieldname": "dimension_col_break",
"fieldtype": "Column Break"
},
{
"allow_on_submit": 1,
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
},
{
"default": "0",
"depends_on": "eval:['Sales Taxes and Charges Template', 'Payment Entry'].includes(parent.doctype)",

View File

@@ -18,6 +18,7 @@
"accounting_dimensions_section",
"cost_center",
"dimension_col_break",
"project",
"shipping_amount_section",
"calculate_based_on",
"column_break_8",
@@ -138,6 +139,12 @@
{
"fieldname": "dimension_col_break",
"fieldtype": "Column Break"
},
{
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
}
],
"icon": "fa fa-truck",

View File

@@ -164,6 +164,12 @@
{% } %}
</tr>
</thead>
<div class="show-filters">
{% if subtitle %}
{{ subtitle }}
<hr>
{% endif %}
</div>
<tbody>
{% for(var i=0, l=data.length; i<l; i++) { %}
<tr>

View File

@@ -976,6 +976,7 @@ class ReceivablePayableReport:
if self.account_type == "Receivable":
self.add_customer_filters()
self.exclude_employee_transaction()
elif self.account_type == "Payable":
self.add_supplier_filters()
@@ -1055,6 +1056,9 @@ class ReceivablePayableReport:
)
)
def exclude_employee_transaction(self):
self.qb_selection_filter.append(self.ple.party_type != "Employee")
def add_supplier_filters(self):
supplier = qb.DocType("Supplier")
if self.filters.get("supplier_group"):

View File

@@ -38,6 +38,7 @@ from erpnext.accounts.report.profit_and_loss_statement.profit_and_loss_statement
get_report_summary as get_pl_summary,
)
from erpnext.accounts.report.utils import convert, convert_to_presentation_currency
from erpnext.accounts.utils import get_zero_cutoff
def execute(filters=None):
@@ -564,7 +565,7 @@ def prepare_data(accounts, start_date, end_date, balance_must_be, companies, com
row[company] = flt(d.get(company, 0.0), 3)
if abs(row[company]) >= 0.005:
if abs(row[company]) >= get_zero_cutoff(filters.presentation_currency):
# ignore zero values
has_value = True
total += flt(row[company])

View File

@@ -12,6 +12,7 @@ from erpnext.accounts.report.financial_statements import (
filter_out_zero_value_rows,
)
from erpnext.accounts.report.trial_balance.trial_balance import validate_filters
from erpnext.accounts.utils import get_zero_cutoff
def execute(filters=None):
@@ -154,7 +155,7 @@ def prepare_data(accounts, filters, company_currency, dimension_list):
for dimension in dimension_list:
row[frappe.scrub(dimension)] = flt(d.get(frappe.scrub(dimension), 0.0), 3)
if abs(row[frappe.scrub(dimension)]) >= 0.005:
if abs(row[frappe.scrub(dimension)]) >= get_zero_cutoff(company_currency):
# ignore zero values
has_value = True
total += flt(d.get(frappe.scrub(dimension), 0.0), 3)

View File

@@ -34,6 +34,12 @@
</h5>
{% } %}
<hr>
<div class="show-filters">
{% if subtitle %}
{{ subtitle }}
<hr>
{% endif %}
</div>
<table class="table table-bordered">
<thead>
<tr>

View File

@@ -18,7 +18,7 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_dimension_with_children,
)
from erpnext.accounts.report.utils import convert_to_presentation_currency, get_currency
from erpnext.accounts.utils import get_fiscal_year
from erpnext.accounts.utils import get_fiscal_year, get_zero_cutoff
def get_period_list(
@@ -306,7 +306,7 @@ def prepare_data(accounts, balance_must_be, period_list, company_currency, accum
row[period.key] = flt(d.get(period.key, 0.0), 3)
if abs(row[period.key]) >= 0.005:
if abs(row[period.key]) >= get_zero_cutoff(company_currency):
# ignore zero values
has_value = True
total += flt(row[period.key])

View File

@@ -75,6 +75,12 @@
</b>
</div>
</div>
<div class="show-filters">
{% if subtitle %}
{{ subtitle }}
<hr>
{% endif %}
</div>
<table style="width:100%; font-size: 11px">
<thead>
<tr class="title-letter-spacing" style="text-align: center; font-weight:bold">

View File

@@ -667,7 +667,7 @@ def get_columns(filters):
"options": "GL Entry",
"hidden": 1,
},
{"label": _("Posting Date"), "fieldname": "posting_date", "fieldtype": "Date", "width": 100},
{"label": _("Posting Date"), "fieldname": "posting_date", "fieldtype": "Date", "width": 120},
{
"label": _("Account"),
"fieldname": "account",

View File

@@ -12,6 +12,7 @@ from erpnext.accounts.report.financial_statements import (
filter_out_zero_value_rows,
)
from erpnext.accounts.report.trial_balance.trial_balance import validate_filters
from erpnext.accounts.utils import get_zero_cutoff
value_fields = ("income", "expense", "gross_profit_loss")
@@ -149,7 +150,7 @@ def prepare_data(accounts, filters, total_row, parent_children_map, based_on):
for key in value_fields:
row[key] = flt(d.get(key, 0.0), 3)
if abs(row[key]) >= 0.005:
if abs(row[key]) >= get_zero_cutoff(company_currency):
# ignore zero values
has_value = True

View File

@@ -18,6 +18,7 @@ from erpnext.accounts.report.financial_statements import (
set_gl_entries_by_account,
)
from erpnext.accounts.report.utils import convert_to_presentation_currency, get_currency
from erpnext.accounts.utils import get_zero_cutoff
value_fields = (
"opening_debit",
@@ -412,7 +413,7 @@ def prepare_data(accounts, filters, parent_children_map, company_currency):
for key in value_fields:
row[key] = flt(d.get(key, 0.0), 3)
if abs(row[key]) >= 0.005:
if abs(row[key]) >= get_zero_cutoff(company_currency):
# ignore zero values
has_value = True

View File

@@ -10,6 +10,7 @@ from erpnext.accounts.party import get_party_shipping_address
from erpnext.accounts.utils import (
get_future_stock_vouchers,
get_voucherwise_gl_entries,
get_zero_cutoff,
sort_stock_vouchers_by_posting_date,
)
from erpnext.stock.doctype.item.test_item import make_item
@@ -157,6 +158,11 @@ class TestUtils(IntegrationTestCase):
self.assertSequenceEqual(doc_name[0:2], ("SUP", fiscal_year))
frappe.db.set_default("supp_master_name", "Supplier Name")
def test_get_zero_cutoff(self):
self.assertEqual(get_zero_cutoff(None), 0.005)
self.assertEqual(get_zero_cutoff("EUR"), 0.005)
self.assertEqual(get_zero_cutoff("BHD"), 0.0005)
ADDRESS_RECORDS = [
{

View File

@@ -27,6 +27,7 @@ from frappe.utils import (
now,
nowdate,
)
from frappe.utils.caching import site_cache
from pypika import Order
from pypika.functions import Coalesce
from pypika.terms import ExistsCriterion
@@ -1147,6 +1148,29 @@ def get_currency_precision():
return precision
def get_fraction_units(currency: str) -> int:
"""Returns the number of fraction units for a currency."""
fraction_units = frappe.db.get_value("Currency", currency, "fraction_units")
if fraction_units is None:
fraction_units = 100
return fraction_units
@site_cache()
def get_zero_cutoff(currency: str) -> float:
"""Returns the zero cutoff for a currency.
For example, if the Fraction Units for a currency are set to 100, then the zero cutoff is 0.005.
We don't want to display values less than the zero cutoff.
This value was chosen for compatibility with the previous hard-coded value of 0.005.
"""
fraction_units = get_fraction_units(currency)
return 0.5 / (fraction_units or 1)
def get_held_invoices(party_type, party):
"""
Returns a list of names Purchase Invoices for the given party that are on hold
@@ -2482,6 +2506,10 @@ def build_qb_match_conditions(doctype, user=None) -> list:
for filter in match_filters:
for link_option, allowed_values in filter.items():
fieldnames = link_fields_map.get(link_option, [])
cond = None
if link_option == doctype:
cond = _dt["name"].isin(allowed_values)
for fieldname in fieldnames:
field = _dt[fieldname]
@@ -2490,6 +2518,7 @@ def build_qb_match_conditions(doctype, user=None) -> list:
if not apply_strict_user_permissions:
cond = (Coalesce(field, "") == "") | cond
if cond:
criterion.append(cond)
return criterion

View File

@@ -41,6 +41,7 @@
"accounting_dimensions_section",
"cost_center",
"dimension_col_break",
"project",
"target_fixed_asset_account"
],
"fields": [
@@ -275,6 +276,12 @@
"label": "Cost Center",
"options": "Cost Center"
},
{
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
},
{
"fieldname": "dimension_col_break",
"fieldtype": "Column Break"

View File

@@ -18,6 +18,7 @@
"accounting_dimensions_section",
"cost_center",
"dimension_col_break",
"project",
"fixed_asset_account"
],
"fields": [
@@ -98,6 +99,13 @@
"fieldname": "dimension_col_break",
"fieldtype": "Column Break"
},
{
"allow_on_submit": 1,
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
},
{
"fieldname": "finance_book",
"fieldtype": "Link",

View File

@@ -61,7 +61,7 @@ class AssetMovement(Document):
if d.source_location:
if current_location != d.source_location:
frappe.throw(
_("Asset {0} does not belongs to the location {1}").format(d.asset, d.source_location)
_("Asset {0} does not belong to the location {1}").format(d.asset, d.source_location)
)
else:
d.source_location = current_location
@@ -76,11 +76,11 @@ class AssetMovement(Document):
frappe.throw(_("Target Location is required while receiving Asset {0}").format(d.asset))
if d.to_employee and frappe.db.get_value("Employee", d.to_employee, "company") != self.company:
frappe.throw(
_("Employee {0} does not belongs to the company {1}").format(d.to_employee, self.company)
_("Employee {0} does not belong to the company {1}").format(d.to_employee, self.company)
)
def validate_employee(self, d):
if self.purpose == "Tranfer and Issue":
if self.purpose == "Transfer and Issue":
if not d.from_employee:
frappe.throw(_("From Employee is required while issuing Asset {0}").format(d.asset))
@@ -89,7 +89,7 @@ class AssetMovement(Document):
if current_custodian != d.from_employee:
frappe.throw(
_("Asset {0} does not belongs to the custodian {1}").format(d.asset, d.from_employee)
_("Asset {0} does not belong to the custodian {1}").format(d.asset, d.from_employee)
)
if not d.to_employee:
@@ -97,7 +97,7 @@ class AssetMovement(Document):
if d.to_employee and frappe.db.get_value("Employee", d.to_employee, "company") != self.company:
frappe.throw(
_("Employee {0} does not belongs to the company {1}").format(d.to_employee, self.company)
_("Employee {0} does not belong to the company {1}").format(d.to_employee, self.company)
)
def on_submit(self):

View File

@@ -503,6 +503,7 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
}
onload() {
super.onload();
this.frm.set_query("supplier", function () {
return {
filters: {

View File

@@ -16,6 +16,7 @@
"order_confirmation_no",
"order_confirmation_date",
"get_items_from_open_material_requests",
"mps",
"column_break_7",
"transaction_date",
"schedule_date",
@@ -1315,6 +1316,13 @@
"fieldtype": "Data",
"is_virtual": 1,
"label": "Last Scanned Warehouse"
},
{
"fieldname": "mps",
"fieldtype": "Link",
"label": "MPS",
"options": "Master Production Schedule",
"read_only": 1
}
],
"grid_page_length": 50,
@@ -1322,7 +1330,7 @@
"idx": 105,
"is_submittable": 1,
"links": [],
"modified": "2025-07-31 17:19:40.816883",
"modified": "2025-08-28 11:00:56.635116",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Order",

View File

@@ -108,6 +108,7 @@ class PurchaseOrder(BuyingController):
items: DF.Table[PurchaseOrderItem]
language: DF.Data | None
letter_head: DF.Link | None
mps: DF.Link | None
named_place: DF.Data | None
naming_series: DF.Literal["PUR-ORD-.YYYY.-"]
net_total: DF.Currency

View File

@@ -24,3 +24,7 @@ class InvalidAccountDimensionError(frappe.ValidationError):
class MandatoryAccountDimensionError(frappe.ValidationError):
pass
class ReportingCurrencyExchangeNotFoundError(frappe.ValidationError):
pass

View File

@@ -51,7 +51,7 @@ doctype_list_js = {
],
}
override_doctype_class = {"Address": "erpnext.accounts.custom.address.ERPNextAddress"}
extend_doctype_class = {"Address": "erpnext.accounts.custom.address.ERPNextAddress"}
override_whitelisted_methods = {"frappe.www.contact.send_message": "erpnext.templates.utils.send_message"}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1414,7 +1414,7 @@ def get_children(parent=None, is_root=False, **filters):
return bom_items
def add_additional_cost(stock_entry, work_order):
def add_additional_cost(stock_entry, work_order, job_card=None):
# Add non stock items cost in the additional cost
stock_entry.additional_costs = []
company_account = frappe.db.get_value(
@@ -1427,13 +1427,16 @@ def add_additional_cost(stock_entry, work_order):
expense_account = (
company_account.default_operating_cost_account or company_account.default_expense_account
)
add_non_stock_items_cost(stock_entry, work_order, expense_account)
add_operations_cost(stock_entry, work_order, expense_account)
add_non_stock_items_cost(stock_entry, work_order, expense_account, job_card=job_card)
add_operations_cost(stock_entry, work_order, expense_account, job_card=job_card)
def add_non_stock_items_cost(stock_entry, work_order, expense_account):
def add_non_stock_items_cost(stock_entry, work_order, expense_account, job_card=None):
bom = frappe.get_doc("BOM", work_order.bom_no)
table = "exploded_items" if work_order.get("use_multi_level_bom") else "items"
table = "items"
if work_order and not job_card:
table = "exploded_items" if work_order.get("use_multi_level_bom") else "items"
items = {}
for d in bom.get(table):
@@ -1464,13 +1467,16 @@ def add_non_stock_items_cost(stock_entry, work_order, expense_account):
def add_operating_cost_component_wise(
stock_entry, work_order=None, operating_cost_per_unit=None, op_expense_account=None
stock_entry, work_order=None, operating_cost_per_unit=None, op_expense_account=None, job_card=None
):
if not work_order:
return False
cost_added = False
for row in work_order.operations:
if job_card and job_card.operation_id != row.name:
continue
workstation_cost = frappe.get_all(
"Workstation Cost",
fields=["operating_component", "operating_cost"],
@@ -1511,14 +1517,14 @@ def get_component_account(parent):
return frappe.db.get_value("Workstation Operating Component Account", parent, "expense_account")
def add_operations_cost(stock_entry, work_order=None, expense_account=None):
def add_operations_cost(stock_entry, work_order=None, expense_account=None, job_card=None):
from erpnext.stock.doctype.stock_entry.stock_entry import get_operating_cost_per_unit
operating_cost_per_unit = get_operating_cost_per_unit(work_order, stock_entry.bom_no)
if operating_cost_per_unit:
cost_added = add_operating_cost_component_wise(
stock_entry, work_order, operating_cost_per_unit, expense_account
stock_entry, work_order, operating_cost_per_unit, expense_account, job_card=job_card
)
if not cost_added:

View File

@@ -115,7 +115,8 @@
"fieldname": "rate",
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Rate"
"label": "Rate",
"options": "currency"
},
{
"columns": 1,
@@ -158,7 +159,8 @@
"fieldname": "amount",
"fieldtype": "Currency",
"label": "Amount",
"read_only": 1
"read_only": 1,
"options": "currency"
},
{
"fieldname": "column_break_yuca",
@@ -180,13 +182,15 @@
"fieldname": "base_amount",
"fieldtype": "Currency",
"hidden": 1,
"label": "Base Amount"
"label": "Base Amount",
"options": "Company:company:default_currency"
},
{
"fieldname": "base_rate",
"fieldtype": "Currency",
"hidden": 1,
"label": "Base Rate"
"label": "Base Rate",
"options": "Company:company:default_currency"
},
{
"default": "0",

View File

@@ -23,6 +23,7 @@ from frappe.utils import (
time_diff_in_hours,
)
from erpnext.manufacturing.doctype.bom.bom import add_additional_cost
from erpnext.manufacturing.doctype.manufacturing_settings.manufacturing_settings import (
get_mins_between_operations,
)
@@ -817,9 +818,6 @@ class JobCard(Document):
)
def update_work_order(self):
if self.track_semi_finished_goods:
return
if not self.work_order:
return
@@ -849,9 +847,9 @@ class JobCard(Document):
def update_semi_finished_good_details(self):
if self.operation_id:
frappe.db.set_value(
"Work Order Operation", self.operation_id, "completed_qty", self.manufactured_qty
)
qty = max(flt(self.manufactured_qty), flt(self.total_completed_qty))
frappe.db.set_value("Work Order Operation", self.operation_id, "completed_qty", qty)
if (
self.finished_good
and frappe.get_cached_value("Work Order", self.work_order, "production_item")
@@ -1322,6 +1320,9 @@ class JobCard(Document):
ste.make_stock_entry()
ste.stock_entry.flags.ignore_mandatory = True
wo_doc = frappe.get_doc("Work Order", self.work_order)
add_additional_cost(ste.stock_entry, wo_doc, self)
ste.stock_entry.save()
if auto_submit:

View File

@@ -24,6 +24,9 @@
"overproduction_percentage_for_sales_order",
"column_break_16",
"overproduction_percentage_for_work_order",
"section_break_xhtl",
"transfer_extra_materials_percentage",
"column_break_kemp",
"job_card_section",
"add_corrective_operation_cost_in_finished_good_valuation",
"enforce_time_logs",
@@ -243,13 +246,28 @@
"fieldname": "enforce_time_logs",
"fieldtype": "Check",
"label": "Enforce Time Logs"
},
{
"fieldname": "section_break_xhtl",
"fieldtype": "Section Break",
"label": "Extra Material Transfer"
},
{
"fieldname": "column_break_kemp",
"fieldtype": "Column Break"
},
{
"description": "The user will be able to transfer additional materials from the store to the Work in Progress (WIP) warehouse.",
"fieldname": "transfer_extra_materials_percentage",
"fieldtype": "Percent",
"label": "Transfer Extra Raw Materials to WIP (%)"
}
],
"icon": "icon-wrench",
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2025-05-16 11:23:16.916512",
"modified": "2025-09-08 19:48:31.726126",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Manufacturing Settings",

View File

@@ -35,6 +35,7 @@ class ManufacturingSettings(Document):
overproduction_percentage_for_sales_order: DF.Percent
overproduction_percentage_for_work_order: DF.Percent
set_op_cost_and_scrap_from_sub_assemblies: DF.Check
transfer_extra_materials_percentage: DF.Percent
update_bom_costs_automatically: DF.Check
validate_components_quantities_per_bom: DF.Check
# end: auto-generated types

View File

@@ -0,0 +1,186 @@
// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
frappe.ui.form.on("Master Production Schedule", {
refresh(frm) {
frm.trigger("set_query_filters");
frm.set_df_property("items", "cannot_add_rows", true);
frm.fields_dict.items.$wrapper.find("[data-action='duplicate_rows']").css("display", "none");
frm.trigger("set_custom_buttons");
},
setup(frm) {
frm.trigger("set_indicator_for_item");
},
set_indicator_for_item(frm) {
frm.set_indicator_formatter("item_code", function (doc) {
if (doc.order_release_date < frappe.datetime.get_today()) {
return "orange";
} else if (doc.order_release_date > frappe.datetime.get_today()) {
return "blue";
} else {
return "green";
}
});
},
set_query_filters(frm) {
frm.set_query("parent_warehouse", (doc) => {
return {
filters: {
is_group: 1,
company: doc.company,
},
};
});
},
get_actual_demand(frm) {
frm.call({
method: "get_actual_demand",
doc: frm.doc,
freeze: true,
freeze_message: __("Generating Master Production Schedule..."),
callback: (r) => {
frm.reload_doc();
},
});
},
set_custom_buttons(frm) {
if (!frm.is_new()) {
frm.add_custom_button(__("View MRP"), () => {
frappe.set_route("query-report", "Material Requirements Planning Report", {
company: frm.doc.company,
from_date: frm.doc.from_date,
to_date: frm.doc.to_date,
mps: frm.doc.name,
warehouse: frm.doc.parent_warehouse,
sales_forecast: frm.doc.sales_forecast,
});
});
}
},
get_sales_orders(frm) {
frm.sales_order_dialog = new frappe.ui.Dialog({
fields: [
{
fieldtype: "Section Break",
label: __("Filters for Sales Orders"),
},
{
fieldname: "customer",
fieldtype: "Link",
options: "Customer",
label: __("Customer"),
},
{
fieldtype: "Section Break",
},
{
fieldname: "from_date",
fieldtype: "Date",
label: __("From Date"),
},
{
fieldname: "to_date",
fieldtype: "Date",
label: __("To Date"),
},
{
fieldtype: "Column Break",
},
{
fieldname: "delivery_from_date",
fieldtype: "Date",
label: __("Delivery From Date"),
default: frm.doc.from_date,
},
{
fieldname: "delivery_to_date",
fieldtype: "Date",
label: __("Delivery To Date"),
},
],
title: __("Get Sales Orders"),
size: "large",
primary_action_label: __("Get Sales Orders"),
primary_action: (data) => {
frm.sales_order_dialog.hide();
frm.events.fetch_sales_orders(frm, data);
},
});
frm.sales_order_dialog.show();
},
fetch_sales_orders(frm, data) {
frm.call({
method: "fetch_sales_orders",
doc: frm.doc,
freeze: true,
freeze_message: __("Fetching Sales Orders..."),
args: data,
callback: (r) => {
frm.reload_doc();
},
});
},
get_material_requests(frm) {
frm.sales_order_dialog = new frappe.ui.Dialog({
fields: [
{
fieldtype: "Section Break",
label: __("Filters for Material Requests"),
},
{
fieldname: "material_request_type",
fieldtype: "Select",
label: __("Purpose"),
options: "\nPurchase\nManufacture",
default: "Manufacture",
},
{
fieldtype: "Column Break",
},
{
fieldname: "from_date",
fieldtype: "Date",
label: __("From Date"),
},
{
fieldname: "to_date",
fieldtype: "Date",
label: __("To Date"),
},
],
title: __("Get Material Requests"),
size: "large",
primary_action_label: __("Get Material Requests"),
primary_action: (data) => {
frm.sales_order_dialog.hide();
frm.events.fetch_materials_requests(frm, data);
},
});
frm.sales_order_dialog.show();
},
fetch_materials_requests(frm, data) {
frm.call({
method: "fetch_materials_requests",
doc: frm.doc,
freeze: true,
freeze_message: __("Fetching Material Requests..."),
args: data,
callback: (r) => {
frm.reload_doc();
},
});
},
});

View File

@@ -0,0 +1,264 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "naming_series:",
"creation": "2025-08-08 19:54:43.478386",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"mps_tab",
"naming_series",
"company",
"column_break_xdcy",
"posting_date",
"from_date",
"to_date",
"section_break_rrrx",
"column_break_klws",
"select_items",
"column_break_mtqw",
"parent_warehouse",
"sales_orders_and_material_requests_tab",
"open_orders_section",
"get_sales_orders",
"sales_orders",
"get_material_requests",
"material_requests",
"section_break_xtby",
"column_break_yhkr",
"column_break_vvys",
"get_actual_demand",
"section_break_cmgo",
"items",
"forecast_demand_section",
"sales_forecast",
"amended_from"
],
"fields": [
{
"fieldname": "company",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Company",
"options": "Company",
"reqd": 1
},
{
"fieldname": "column_break_xdcy",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_rrrx",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_klws",
"fieldtype": "Column Break"
},
{
"fieldname": "column_break_mtqw",
"fieldtype": "Column Break"
},
{
"allow_bulk_edit": 1,
"fieldname": "items",
"fieldtype": "Table",
"label": "Items",
"options": "Master Production Schedule Item"
},
{
"default": "Today",
"fieldname": "posting_date",
"fieldtype": "Date",
"label": "Posting Date",
"reqd": 1
},
{
"fieldname": "section_break_cmgo",
"fieldtype": "Section Break",
"label": "Actual Demand"
},
{
"default": "MPS.YY.-.######",
"fieldname": "naming_series",
"fieldtype": "Select",
"label": "Naming Series",
"options": "MPS.YY.-.######",
"reqd": 1
},
{
"description": "For projected and forecast quantities, the system will consider all child warehouses under the selected parent warehouse.",
"fieldname": "parent_warehouse",
"fieldtype": "Link",
"label": "Parent Warehouse",
"options": "Warehouse"
},
{
"fieldname": "from_date",
"fieldtype": "Date",
"label": "From Date",
"reqd": 1
},
{
"fieldname": "to_date",
"fieldtype": "Date",
"label": "To Date"
},
{
"fieldname": "sales_orders_and_material_requests_tab",
"fieldtype": "Tab Break",
"label": "Demand"
},
{
"fieldname": "sales_orders",
"fieldtype": "Table",
"label": "Sales Orders",
"options": "Production Plan Sales Order"
},
{
"fieldname": "mps_tab",
"fieldtype": "Tab Break",
"label": "Planning"
},
{
"fieldname": "material_requests",
"fieldtype": "Table",
"label": "Material Requests",
"options": "Production Plan Material Request"
},
{
"fieldname": "open_orders_section",
"fieldtype": "Section Break",
"label": "Open Orders"
},
{
"fieldname": "section_break_xtby",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_vvys",
"fieldtype": "Column Break"
},
{
"fieldname": "amended_from",
"fieldtype": "Link",
"label": "Amended From",
"no_copy": 1,
"options": "Master Production Schedule",
"print_hide": 1,
"read_only": 1,
"search_index": 1
},
{
"fieldname": "sales_forecast",
"fieldtype": "Link",
"label": "Sales Forecast",
"options": "Sales Forecast"
},
{
"fieldname": "get_sales_orders",
"fieldtype": "Button",
"label": "Get Sales Orders"
},
{
"fieldname": "get_material_requests",
"fieldtype": "Button",
"label": "Get Material Requests"
},
{
"fieldname": "select_items",
"fieldtype": "Table MultiSelect",
"label": "Select Items",
"options": "Master Production Schedule Item"
},
{
"fieldname": "column_break_yhkr",
"fieldtype": "Column Break"
},
{
"fieldname": "get_actual_demand",
"fieldtype": "Button",
"label": "Get Actual Demand"
},
{
"fieldname": "forecast_demand_section",
"fieldtype": "Section Break",
"label": "Forecast Demand"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-09-02 19:33:28.244544",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Master Production Schedule",
"naming_rule": "By \"Naming Series\" field",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Manufacturing User",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Manufacturing Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Stock User",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Stock Manager",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View File

@@ -0,0 +1,456 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import math
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.model.mapper import get_mapped_doc
from frappe.query_builder.functions import Sum
from frappe.utils import add_days, flt, getdate, parse_json, today
from frappe.utils.nestedset import get_descendants_of
class MasterProductionSchedule(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.types import DF
from erpnext.manufacturing.doctype.master_production_schedule_item.master_production_schedule_item import (
MasterProductionScheduleItem,
)
from erpnext.manufacturing.doctype.production_plan_material_request.production_plan_material_request import (
ProductionPlanMaterialRequest,
)
from erpnext.manufacturing.doctype.production_plan_sales_order.production_plan_sales_order import (
ProductionPlanSalesOrder,
)
amended_from: DF.Link | None
company: DF.Link
from_date: DF.Date
items: DF.Table[MasterProductionScheduleItem]
material_requests: DF.Table[ProductionPlanMaterialRequest]
naming_series: DF.Literal["MPS.YY.-.######"]
parent_warehouse: DF.Link | None
posting_date: DF.Date
sales_forecast: DF.Link | None
sales_orders: DF.Table[ProductionPlanSalesOrder]
select_items: DF.TableMultiSelect[MasterProductionScheduleItem]
to_date: DF.Date | None
# end: auto-generated types
@frappe.whitelist()
def get_actual_demand(self):
self.set("items", [])
actual_demand_data = self.get_demand_data()
item_wise_data = self.get_item_wise_mps_data(actual_demand_data)
if not item_wise_data:
return []
self.update_item_details(item_wise_data)
self.add_mps_data(item_wise_data)
if not self.is_new():
self.save()
def validate(self):
self.set_to_date()
def set_to_date(self):
self.to_date = None
for row in self.items:
if not self.to_date or getdate(row.delivery_date) > getdate(self.to_date):
self.to_date = row.delivery_date
forecast_delivery_dates = self.get_sales_forecast_data()
for date in forecast_delivery_dates:
if not self.to_date or getdate(date) > getdate(self.to_date):
self.to_date = date
def get_sales_forecast_data(self):
if not self.sales_forecast:
return []
filters = {"parent": self.sales_forecast}
if self.select_items:
items = [d.item_code for d in self.select_items if d.item_code]
filters["item_code"] = ("in", items)
return frappe.get_all(
"Sales Forecast Item",
filters=filters,
pluck="delivery_date",
order_by="delivery_date asc",
)
def update_item_details(self, data):
items = [item[0] for item in data if item[0]]
item_details = self.get_item_details(items)
for key in data:
item_data = data[key]
item_code = key[0]
if item_code in item_details:
item_data.update(item_details[item_code])
def get_item_details(self, items):
doctype = frappe.qb.DocType("Item")
query = (
frappe.qb.from_(doctype)
.select(
doctype.name.as_("item_code"),
doctype.default_bom.as_("bom_no"),
doctype.item_name,
)
.where(doctype.name.isin(items))
)
item_details = query.run(as_dict=True)
item_wise_details = frappe._dict({})
if not item_details:
return item_wise_details
for row in item_details:
row.cumulative_lead_time = self.get_cumulative_lead_time(row.item_code, row.bom_no)
for row in item_details:
item_wise_details.setdefault(row.item_code, row)
return item_wise_details
def get_cumulative_lead_time(self, item_code, bom_no, time_in_days=0):
if not time_in_days:
time_in_days = get_item_lead_time(item_code)
bom_materials = frappe.get_all(
"BOM Item",
filters={"parent": bom_no, "docstatus": 1},
fields=["item_code", "bom_no"],
)
for row in bom_materials:
if row.bom_no:
time_in_days += self.get_cumulative_lead_time(row.item_code, row.bom_no)
else:
lead_time = get_item_lead_time(row.item_code)
time_in_days += lead_time
return time_in_days
def get_demand_data(self):
sales_order_data = self.get_sales_orders_data()
material_request_data = self.get_material_requests_data()
return sales_order_data + material_request_data
def get_material_requests_data(self):
if not self.material_requests:
return []
doctype = frappe.qb.DocType("Material Request Item")
query = (
frappe.qb.from_(doctype)
.select(
doctype.item_code,
doctype.warehouse,
doctype.stock_uom,
doctype.schedule_date.as_("delivery_date"),
doctype.parent.as_("material_request"),
doctype.stock_qty.as_("qty"),
)
.orderby(doctype.schedule_date)
)
if self.material_requests:
material_requests = [m.material_request for m in self.material_requests if m.material_request]
query = query.where(doctype.parent.isin(material_requests))
if self.from_date:
query = query.where(doctype.schedule_date >= self.from_date)
if self.to_date:
query = query.where(doctype.schedule_date <= self.to_date)
return query.run(as_dict=True)
def get_sales_orders_data(self):
sales_order_schedules = self.get_sales_order_schedules()
ignore_orders = []
if sales_order_schedules:
for row in sales_order_schedules:
if row.sales_order not in ignore_orders:
ignore_orders.append(row.sales_order)
sales_orders = self.get_items_from_sales_orders(ignore_orders)
return sales_orders + sales_order_schedules
def get_items_from_sales_orders(self, ignore_orders=None):
doctype = frappe.qb.DocType("Sales Order Item")
query = (
frappe.qb.from_(doctype)
.select(
doctype.item_code,
doctype.warehouse,
doctype.stock_uom,
doctype.delivery_date,
doctype.name.as_("sales_order"),
doctype.stock_qty.as_("qty"),
)
.where(doctype.docstatus == 1)
.orderby(doctype.delivery_date)
)
if self.from_date:
query = query.where(doctype.delivery_date >= self.from_date)
if self.to_date:
query = query.where(doctype.delivery_date <= self.to_date)
if self.sales_orders:
names = [s.sales_order for s in self.sales_orders if s.sales_order]
if ignore_orders:
names = [name for name in names if name not in ignore_orders]
query = query.where(doctype.parent.isin(names))
return query.run(as_dict=True)
def get_sales_order_schedules(self):
doctype = frappe.qb.DocType("Delivery Schedule Item")
query = frappe.qb.from_(doctype).select(
doctype.item_code,
doctype.warehouse,
doctype.stock_uom,
doctype.delivery_date,
doctype.sales_order,
doctype.stock_qty.as_("qty"),
)
if self.sales_orders:
names = [s.sales_order for s in self.sales_orders if s.sales_order]
query = query.where(doctype.sales_order.isin(names))
if self.from_date:
query = query.where(doctype.delivery_date >= self.from_date)
if self.to_date:
query = query.where(doctype.delivery_date <= self.to_date)
return query.run(as_dict=True)
def get_item_wise_mps_data(self, data):
item_wise_data = frappe._dict({})
for item in data:
key = (item.item_code, item.delivery_date)
if key not in item_wise_data:
item_wise_data[key] = frappe._dict(
{
"item_code": item.item_code,
"delivery_date": item.delivery_date,
"stock_uom": item.stock_uom,
"qty": 0.0,
"cumulative_lead_time": 0.0,
"order_release_date": item.delivery_date,
}
)
item_details = item_wise_data[key]
item_details.qty += item.qty
return item_wise_data
def add_mps_data(self, data):
data = frappe._dict(sorted(data.items(), key=lambda x: x[0][1]))
for key in data:
row = data[key]
row.cumulative_lead_time = math.ceil(row.cumulative_lead_time)
row.order_release_date = add_days(row.delivery_date, -row.cumulative_lead_time)
if getdate(row.order_release_date) < getdate(today()):
continue
row.planned_qty = row.qty
row.uom = row.stock_uom
row.warehouse = row.warehouse or self.parent_warehouse
self.append("items", row)
def get_distinct_items(self, data):
items = []
for item in data:
if item.item_code not in items:
items.append(item.item_code)
return items
@frappe.whitelist()
def fetch_materials_requests(self, **data):
if isinstance(data, str):
data = parse_json(data)
self.set("material_requests", [])
materials_requests = self.get_material_requests(data)
if not materials_requests:
frappe.msgprint(
_("No open Material Requests found for the given criteria."),
alert=True,
)
return
for row in materials_requests:
self.append(
"material_requests",
{
"material_request": row.name,
"material_request_date": row.transaction_date,
},
)
if not self.is_new():
self.save()
def get_material_requests(self, data):
doctype = frappe.qb.DocType("Material Request")
query = (
frappe.qb.from_(doctype)
.select(
doctype.name,
doctype.transaction_date,
)
.where((doctype.docstatus == 1) & (doctype.status.notin(["Closed", "Completed"])))
.orderby(doctype.schedule_date)
)
if data.get("material_request_type"):
query = query.where(doctype.material_request_type == data.get("material_request_type"))
if data.get("from_date"):
query = query.where(doctype.transaction_date >= data.get("from_date"))
if data.get("to_date"):
query = query.where(doctype.transaction_date <= data.get("to_date"))
if self.from_date:
query = query.where(doctype.schedule_date >= self.from_date)
if self.to_date:
query = query.where(doctype.schedule_date <= self.to_date)
return query.run(as_dict=True)
@frappe.whitelist()
def fetch_sales_orders(self, **data):
if isinstance(data, str):
data = parse_json(data)
self.set("sales_orders", [])
sales_orders = self.get_sales_orders(data)
if not sales_orders:
return
for row in sales_orders:
self.append(
"sales_orders",
{
"sales_order": row.name,
"sales_order_date": row.transaction_date,
"delivery_date": row.delivery_date,
"customer": row.customer,
"status": row.status,
"grand_total": row.grand_total,
},
)
if not self.is_new():
self.save()
def get_sales_orders(self, kwargs):
doctype = frappe.qb.DocType("Sales Order")
query = (
frappe.qb.from_(doctype)
.select(
doctype.name,
doctype.transaction_date,
doctype.delivery_date,
doctype.customer,
doctype.status,
doctype.grand_total,
)
.where((doctype.docstatus == 1) & (doctype.status.notin(["Closed", "Completed"])))
.orderby(doctype.delivery_date)
)
if kwargs.get("customer"):
query = query.where(doctype.customer == kwargs.get("customer"))
if kwargs.get("from_date"):
query = query.where(doctype.transaction_date >= kwargs.get("from_date"))
if kwargs.get("to_date"):
query = query.where(doctype.transaction_date <= kwargs.get("to_date"))
if kwargs.get("delivery_from_date"):
query = query.where(doctype.delivery_date >= kwargs.get("delivery_from_date"))
if kwargs.get("delivery_to_date"):
query = query.where(doctype.delivery_date <= kwargs.get("to_delivery_date"))
if items := self.get_items_for_mps():
doctype_item = frappe.qb.DocType("Sales Order Item")
query = query.join(doctype_item).on(doctype_item.parent == doctype.name)
query = query.where(doctype_item.item_code.isin(items))
return query.run(as_dict=True)
def get_items_for_mps(self):
if not self.select_items:
return
return [d.item_code for d in self.select_items if d.item_code]
def on_submit(self):
self.enqueue_mrp_creation()
def enqueue_mrp_creation(self):
frappe.enqueue_doc("Master Production Schedule", self.name, "make_mrp", queue="long", timeout=1800)
frappe.msgprint(
_("MRP Log documents are being created in the background."),
alert=True,
)
def get_item_lead_time(item_code):
doctype = frappe.qb.DocType("Item Lead Time")
query = (
frappe.qb.from_(doctype)
.select(
((doctype.manufacturing_time_in_mins / 1440) + doctype.purchase_time + doctype.buffer_time).as_(
"cumulative_lead_time"
)
)
.where(doctype.item_code == item_code)
)
result = query.run(as_dict=True)
if result:
return result[0].cumulative_lead_time or 0
return 0

View File

@@ -0,0 +1,20 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
from frappe.tests import IntegrationTestCase
# On IntegrationTestCase, the doctype test records and all
# link-field test record dependencies are recursively loaded
# Use these module variables to add/remove to/from that list
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
class IntegrationTestMasterProductionSchedule(IntegrationTestCase):
"""
Integration tests for MasterProductionSchedule.
Use this class for testing interactions between multiple components.
"""
pass

View File

@@ -0,0 +1,101 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2025-08-12 17:09:08.171687",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"item_code",
"delivery_date",
"cumulative_lead_time",
"order_release_date",
"planned_qty",
"warehouse",
"item_name",
"bom_no",
"uom"
],
"fields": [
{
"columns": 2,
"fieldname": "item_code",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Item Code",
"options": "Item",
"read_only": 1
},
{
"columns": 2,
"fieldname": "warehouse",
"fieldtype": "Link",
"label": "Warehouse",
"options": "Warehouse",
"read_only": 1
},
{
"columns": 2,
"fieldname": "delivery_date",
"fieldtype": "Date",
"in_list_view": 1,
"label": "Delivery Date",
"read_only": 1
},
{
"columns": 2,
"fieldname": "planned_qty",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Planned Qty"
},
{
"columns": 2,
"fieldname": "order_release_date",
"fieldtype": "Date",
"in_list_view": 1,
"label": "Start Date"
},
{
"fieldname": "item_name",
"fieldtype": "Data",
"label": "Item Name",
"read_only": 1
},
{
"fieldname": "bom_no",
"fieldtype": "Link",
"in_list_view": 1,
"label": "BOM No",
"options": "BOM"
},
{
"fieldname": "uom",
"fieldtype": "Link",
"label": "UOM",
"options": "UOM"
},
{
"columns": 2,
"fieldname": "cumulative_lead_time",
"fieldtype": "Int",
"in_list_view": 1,
"label": "Lead Time",
"read_only": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-09-02 19:41:27.167095",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Master Production Schedule Item",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View File

@@ -0,0 +1,31 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class MasterProductionScheduleItem(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.types import DF
bom_no: DF.Link | None
cumulative_lead_time: DF.Int
delivery_date: DF.Date | None
item_code: DF.Link | None
item_name: DF.Data | None
order_release_date: DF.Date | None
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
planned_qty: DF.Float
uom: DF.Link | None
warehouse: DF.Link | None
# end: auto-generated types
pass

View File

@@ -10,7 +10,8 @@
"sales_order_date",
"col_break1",
"customer",
"grand_total"
"grand_total",
"status"
],
"fields": [
{
@@ -58,18 +59,26 @@
"print_width": "120px",
"read_only": 1,
"width": "120px"
},
{
"fieldname": "status",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Status"
}
],
"idx": 1,
"istable": 1,
"links": [],
"modified": "2024-03-27 13:10:20.746852",
"modified": "2025-08-21 15:16:13.828240",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Production Plan Sales Order",
"naming_rule": "Random",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "ASC",
"states": []
}
}

View File

@@ -21,6 +21,7 @@ class ProductionPlanSalesOrder(Document):
parenttype: DF.Data
sales_order: DF.Link
sales_order_date: DF.Date | None
status: DF.Data | None
# end: auto-generated types
pass

View File

@@ -0,0 +1,56 @@
frappe.ui.form.on("Sales Forecast", {
refresh(frm) {
frm.trigger("set_query_filters");
frm.trigger("set_custom_buttons");
},
set_query_filters(frm) {
frm.set_query("parent_warehouse", (doc) => {
return {
filters: {
is_group: 1,
company: doc.company,
},
};
});
frm.set_query("item_code", "items", () => {
return {
filters: {
disabled: 0,
is_stock_item: 1,
},
};
});
},
generate_demand(frm) {
frm.call({
method: "generate_demand",
doc: frm.doc,
freeze: true,
callback: function (r) {
frm.reload_doc();
},
});
},
set_custom_buttons(frm) {
if (frm.doc.docstatus === 1 && frm.doc.status === "Planned") {
frm.add_custom_button(__("Create MPS"), () => {
frappe.model.open_mapped_doc({
method: "erpnext.manufacturing.doctype.sales_forecast.sales_forecast.create_mps",
frm: frm,
});
}).addClass("btn-primary");
}
},
});
frappe.ui.form.on("Sales Forecast Item", {
adjust_qty(frm, cdt, cdn) {
let row = locals[cdt][cdn];
row.demand_qty = row.forecast_qty + row.adjust_qty;
frappe.model.set_value(cdt, cdn, "demand_qty", row.demand_qty);
},
});

View File

@@ -0,0 +1,252 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "naming_series:",
"creation": "2025-08-12 17:20:16.012501",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"naming_series",
"company",
"posting_date",
"forecasting_method",
"column_break_xdcy",
"from_date",
"frequency",
"demand_number",
"section_break_rrrx",
"column_break_klws",
"selected_items",
"column_break_mtqw",
"parent_warehouse",
"section_break_cmgo",
"generate_demand",
"items",
"section_break_kuzf",
"amended_from",
"column_break_laqr",
"status",
"connections_tab"
],
"fields": [
{
"default": "SF.YY.-.######",
"fieldname": "naming_series",
"fieldtype": "Select",
"label": "Naming Series",
"options": "SF.YY.-.######",
"reqd": 1
},
{
"fieldname": "company",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Company",
"options": "Company",
"reqd": 1
},
{
"fieldname": "column_break_xdcy",
"fieldtype": "Column Break"
},
{
"default": "Today",
"fieldname": "posting_date",
"fieldtype": "Date",
"in_list_view": 1,
"label": "Posting Date"
},
{
"collapsible": 1,
"fieldname": "section_break_rrrx",
"fieldtype": "Section Break",
"label": "Item and Warehouse"
},
{
"fieldname": "column_break_klws",
"fieldtype": "Column Break"
},
{
"fieldname": "selected_items",
"fieldtype": "Table MultiSelect",
"label": "Select Items",
"options": "Sales Forecast Item",
"reqd": 1
},
{
"fieldname": "column_break_mtqw",
"fieldtype": "Column Break"
},
{
"description": "For projected and forecast quantities, the system will consider all child warehouses under the selected parent warehouse.",
"fieldname": "parent_warehouse",
"fieldtype": "Link",
"label": "Parent Warehouse",
"options": "Warehouse",
"reqd": 1
},
{
"fieldname": "section_break_cmgo",
"fieldtype": "Section Break"
},
{
"allow_bulk_edit": 1,
"fieldname": "items",
"fieldtype": "Table",
"label": "Items",
"options": "Sales Forecast Item"
},
{
"default": "Today",
"fieldname": "from_date",
"fieldtype": "Date",
"label": "From Date",
"reqd": 1
},
{
"fieldname": "generate_demand",
"fieldtype": "Button",
"label": "Generate Demand"
},
{
"fieldname": "amended_from",
"fieldtype": "Link",
"label": "Amended From",
"no_copy": 1,
"options": "Sales Forecast",
"print_hide": 1,
"read_only": 1,
"search_index": 1
},
{
"default": "6",
"fieldname": "demand_number",
"fieldtype": "Int",
"label": "Number of Weeks / Months",
"reqd": 1
},
{
"fieldname": "connections_tab",
"fieldtype": "Tab Break",
"label": "Connections",
"show_dashboard": 1
},
{
"fieldname": "section_break_kuzf",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_laqr",
"fieldtype": "Column Break"
},
{
"fieldname": "status",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Status",
"options": "Planned\nMPS Generated",
"read_only": 1
},
{
"default": "Holt-Winters",
"fieldname": "forecasting_method",
"fieldtype": "Select",
"label": "Forecasting Method",
"options": "Holt-Winters\nManual"
},
{
"default": "Monthly",
"fieldname": "frequency",
"fieldtype": "Select",
"label": "Frequency",
"options": "Weekly\nMonthly",
"reqd": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2025-09-21 13:24:34.720794",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Sales Forecast",
"naming_rule": "By \"Naming Series\" field",
"owner": "Administrator",
"permissions": [
{
"cancel": 1,
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"submit": 1,
"write": 1
},
{
"cancel": 1,
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Sales User",
"share": 1,
"submit": 1,
"write": 1
},
{
"cancel": 1,
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Sales Manager",
"share": 1,
"submit": 1,
"write": 1
},
{
"cancel": 1,
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Manufacturing User",
"share": 1,
"submit": 1,
"write": 1
},
{
"cancel": 1,
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Manufacturing Manager",
"share": 1,
"submit": 1,
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View File

@@ -0,0 +1,228 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
import pandas as pd
from frappe import _
from frappe.model.document import Document
from frappe.model.mapper import get_mapped_doc
from frappe.query_builder.functions import DateFormat, Sum, YearWeek
from frappe.utils import add_to_date, cint, date_diff, flt
from frappe.utils.nestedset import get_descendants_of
class SalesForecast(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.types import DF
from erpnext.manufacturing.doctype.sales_forecast_item.sales_forecast_item import SalesForecastItem
amended_from: DF.Link | None
company: DF.Link
demand_number: DF.Int
forecasting_method: DF.Literal["Holt-Winters", "Manual"]
frequency: DF.Literal["Weekly", "Monthly"]
from_date: DF.Date
items: DF.Table[SalesForecastItem]
naming_series: DF.Literal["SF.YY.-.######"]
parent_warehouse: DF.Link
posting_date: DF.Date | None
selected_items: DF.TableMultiSelect[SalesForecastItem]
status: DF.Literal["Planned", "MPS Generated"]
# end: auto-generated types
def validate(self):
self.validate_demand_qty()
def validate_demand_qty(self):
if self.forecasting_method == "Manual":
return
for row in self.items:
demand_qty = row.forecast_qty + flt(row.adjust_qty)
if row.demand_qty != demand_qty:
row.demand_qty = demand_qty
def get_sales_data(self):
to_date = self.from_date
from_date = add_to_date(to_date, years=-3)
doctype = frappe.qb.DocType("Sales Order")
child_doctype = frappe.qb.DocType("Sales Order Item")
query = (
frappe.qb.from_(doctype)
.inner_join(child_doctype)
.on(child_doctype.parent == doctype.name)
.select(child_doctype.item_code, Sum(child_doctype.qty).as_("qty"), doctype.transaction_date)
.where((doctype.docstatus == 1) & (doctype.transaction_date.between(from_date, to_date)))
.groupby(child_doctype.item_code)
)
if self.selected_items:
items = [item.item_code for item in self.selected_items]
query = query.where(child_doctype.item_code.isin(items))
if self.parent_warehouse:
warehouses = get_descendants_of("Warehouse", self.parent_warehouse)
query = query.where(child_doctype.warehouse.isin(warehouses))
query = query.groupby(doctype.transaction_date)
return query.run(as_dict=True)
def generate_manual_demand(self):
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
)
for index in range(self.demand_number):
if self.horizon_type == "Monthly":
delivery_date = add_to_date(self.from_date, months=index + 1)
else:
delivery_date = add_to_date(self.from_date, weeks=index + 1)
forecast_demand.append(
{
"item_code": row.item_code,
"delivery_date": delivery_date,
"item_name": item_details.item_name,
"uom": item_details.uom,
"demand_qty": 0.0,
}
)
for demand in forecast_demand:
self.append("items", demand)
self.save()
@frappe.whitelist()
def generate_demand(self):
from statsmodels.tsa.holtwinters import ExponentialSmoothing
self.set("items", [])
if self.forecasting_method == "Manual":
self.generate_manual_demand()
return
sales_data = self.get_sales_data()
if not sales_data:
frappe.throw(_("No sales data found for the selected items."))
itemwise_data = self.group_sales_data_by_item(sales_data)
for item_code, data in itemwise_data.items():
seasonal_periods = self.get_seasonal_periods(data)
pd_sales_data = pd.DataFrame({"item": data.item, "date": data.date, "qty": data.qty})
resample_val = "M" if self.horizon_type == "Monthly" else "W"
_sales_data = pd_sales_data.set_index("date").resample(resample_val).sum()["qty"]
model = ExponentialSmoothing(
_sales_data, trend="add", seasonal="mul", seasonal_periods=seasonal_periods
)
fit = model.fit()
forecast = fit.forecast(self.demand_number)
forecast_data = forecast.to_dict()
if forecast_data:
self.add_sales_forecast_item(item_code, forecast_data)
self.save()
def add_sales_forecast_item(self, item_code, forecast_data):
item_details = frappe.db.get_value(
"Item", item_code, ["item_name", "stock_uom as uom", "name as item_code"], as_dict=True
)
uom_whole_number = frappe.get_cached_value("UOM", item_details.uom, "must_be_whole_number")
for date, qty in forecast_data.items():
if uom_whole_number:
qty = round(qty)
item_details.update(
{
"delivery_date": date,
"forecast_qty": qty,
"demand_qty": qty,
"warehouse": self.parent_warehouse,
}
)
self.append("items", item_details)
def get_seasonal_periods(self, data):
days = date_diff(data["end_date"], data["start_date"])
if self.horizon_type == "Monthly":
months = (days / 365) * 12
seasonal_periods = cint(months / 2)
if seasonal_periods > 12:
seasonal_periods = 12
else:
weeks = days / 7
seasonal_periods = cint(weeks / 2)
if seasonal_periods > 52:
seasonal_periods = 52
return seasonal_periods
def group_sales_data_by_item(self, sales_data):
"""
Group sales data by item code and calculate total quantity sold.
"""
itemwise_data = frappe._dict({})
for row in sales_data:
if row.item_code not in itemwise_data:
itemwise_data[row.item_code] = frappe._dict(
{
"start_date": row.transaction_date,
"item": [],
"date": [],
"qty": [],
"end_date": "",
}
)
item_data = itemwise_data[row.item_code]
item_data["item"].append(row.item_code)
item_data["date"].append(pd.to_datetime(row.transaction_date))
item_data["qty"].append(row.qty)
item_data["end_date"] = row.transaction_date
return itemwise_data
@frappe.whitelist()
def create_mps(source_name, target_doc=None):
def postprocess(source, doc):
doc.naming_series = "MPS.YY.-.######"
doc = get_mapped_doc(
"Sales Forecast",
source_name,
{
"Sales Forecast": {
"doctype": "Master Production Schedule",
"validation": {"docstatus": ["=", 1]},
"field_map": {
"name": "sales_forecast",
"from_date": "from_date",
},
},
},
target_doc,
postprocess,
)
return doc

View File

@@ -0,0 +1,13 @@
from frappe import _
def get_data():
return {
"fieldname": "demand_planning",
"transactions": [
{
"label": _("MPS"),
"items": ["Master Production Schedule"],
},
],
}

View File

@@ -0,0 +1,12 @@
frappe.listview_settings["Sales Forecast"] = {
add_fields: ["status"],
get_indicator: function (doc) {
if (doc.status === "Planned") {
// Closed
return [__("Planned"), "orange", "status,=,Planned"];
} else if (doc.status === "MPS Generated") {
// on hold
return [__("MPS Generated"), "green", "status,=,MPS Generated"];
}
},
};

View File

@@ -0,0 +1,20 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
from frappe.tests import IntegrationTestCase
# On IntegrationTestCase, the doctype test records and all
# link-field test record dependencies are recursively loaded
# Use these module variables to add/remove to/from that list
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
class IntegrationTestSalesForecast(IntegrationTestCase):
"""
Integration tests for SalesForecast.
Use this class for testing interactions between multiple components.
"""
pass

View File

@@ -0,0 +1,107 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2025-08-18 20:57:19.816490",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"item_code",
"item_name",
"uom",
"delivery_date",
"forecast_qty",
"adjust_qty",
"demand_qty",
"warehouse"
],
"fields": [
{
"columns": 2,
"fieldname": "item_code",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Item Code",
"options": "Item",
"read_only": 1,
"reqd": 1
},
{
"columns": 2,
"fetch_from": "item_code.item_name",
"fetch_if_empty": 1,
"fieldname": "item_name",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Item Name",
"read_only": 1
},
{
"columns": 2,
"fetch_from": "item_code.sales_uom",
"fetch_if_empty": 1,
"fieldname": "uom",
"fieldtype": "Link",
"in_list_view": 1,
"label": "UOM",
"options": "UOM",
"read_only": 1
},
{
"columns": 2,
"fieldname": "delivery_date",
"fieldtype": "Date",
"in_list_view": 1,
"label": "Delivery Date",
"read_only": 1
},
{
"columns": 2,
"fieldname": "forecast_qty",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Forecast Qty",
"non_negative": 1,
"read_only": 1
},
{
"columns": 2,
"fieldname": "adjust_qty",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Adjust Qty"
},
{
"columns": 3,
"fieldname": "demand_qty",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Demand Qty",
"non_negative": 1,
"reqd": 1
},
{
"columns": 2,
"fieldname": "warehouse",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Warehouse",
"options": "Warehouse",
"read_only": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-08-18 21:59:38.859082",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Sales Forecast Item",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View File

@@ -0,0 +1,30 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class SalesForecastItem(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.types import DF
adjust_qty: DF.Float
delivery_date: DF.Date | None
demand_qty: DF.Float
forecast_qty: DF.Float
item_code: DF.Link
item_name: DF.Data | None
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
uom: DF.Link | None
warehouse: DF.Link | None
# end: auto-generated types
pass

View File

@@ -3005,6 +3005,36 @@ class TestWorkOrder(IntegrationTestCase):
wo.operations[3].planned_start_time, add_to_date(wo.operations[1].planned_end_time, minutes=10)
)
def test_allow_additional_material_transfer(self):
from erpnext.stock.doctype.stock_entry.test_stock_entry import (
make_stock_entry as make_stock_entry_test_record,
)
frappe.db.set_single_value("Manufacturing Settings", "transfer_extra_materials_percentage", 50)
wo_order = make_wo_order_test_record(planned_start_date=now(), qty=2)
for row in wo_order.required_items:
make_stock_entry_test_record(
item_code=row.item_code,
target=row.source_warehouse,
qty=row.required_qty * 2,
basic_rate=100,
)
stock_entry = frappe.get_doc(make_stock_entry(wo_order.name, "Material Transfer for Manufacture", 2))
stock_entry.insert()
stock_entry.submit()
wo_order.reload()
self.assertEqual(wo_order.material_transferred_for_manufacturing, 2)
stock_entry = frappe.get_doc(make_stock_entry(wo_order.name, "Material Transfer for Manufacture", 1))
stock_entry.insert()
stock_entry.submit()
wo_order.reload()
self.assertEqual(wo_order.material_transferred_for_manufacturing, 3)
frappe.db.set_single_value("Manufacturing Settings", "transfer_extra_materials_percentage", 0)
def make_stock_in_entries_and_get_batches(rm_item, source_warehouse, wip_warehouse):
from erpnext.stock.doctype.stock_entry.test_stock_entry import (

View File

@@ -732,6 +732,19 @@ erpnext.work_order = {
let pending_to_transfer = frm.doc.required_items.some(
(item) => flt(item.transferred_qty) < flt(item.required_qty)
);
let transfer_extra_materials_percentage =
frm.doc.__onload?.transfer_extra_materials_percentage;
let allowed_qty = 0;
let transfer_extra_materials = false;
if (!pending_to_transfer && transfer_extra_materials_percentage) {
allowed_qty = frm.doc.qty + (transfer_extra_materials_percentage / 100) * frm.doc.qty;
if (allowed_qty > frm.doc.material_transferred_for_manufacturing) {
transfer_extra_materials = true;
}
}
if (pending_to_transfer && frm.doc.status != "Stopped") {
frm.has_start_btn = true;
frm.add_custom_button(__("Create Pick List"), function () {
@@ -742,6 +755,14 @@ erpnext.work_order = {
erpnext.work_order.make_se(frm, "Material Transfer for Manufacture");
});
start_btn.addClass("btn-primary");
} else if (transfer_extra_materials && allowed_qty) {
let qty = allowed_qty - flt(frm.doc.material_transferred_for_manufacturing);
if (qty > 0) {
frm.add_custom_button(__("Transfer Extra Material"), function () {
erpnext.work_order.make_se(frm, "Material Transfer for Manufacture", qty);
});
}
}
}
}
@@ -967,19 +988,35 @@ erpnext.work_order = {
});
},
make_se: function (frm, purpose) {
this.show_prompt_for_qty_input(frm, purpose)
.then((data) => {
return frappe.xcall("erpnext.manufacturing.doctype.work_order.work_order.make_stock_entry", {
make_se: function (frm, purpose, qty) {
if (qty) {
frappe
.xcall("erpnext.manufacturing.doctype.work_order.work_order.make_stock_entry", {
work_order_id: frm.doc.name,
purpose: purpose,
qty: data.qty,
qty: qty,
})
.then((stock_entry) => {
frappe.model.sync(stock_entry);
frappe.set_route("Form", stock_entry.doctype, stock_entry.name);
});
})
.then((stock_entry) => {
frappe.model.sync(stock_entry);
frappe.set_route("Form", stock_entry.doctype, stock_entry.name);
});
} else {
this.show_prompt_for_qty_input(frm, purpose)
.then((data) => {
return frappe.xcall(
"erpnext.manufacturing.doctype.work_order.work_order.make_stock_entry",
{
work_order_id: frm.doc.name,
purpose: purpose,
qty: data.qty,
}
);
})
.then((stock_entry) => {
frappe.model.sync(stock_entry);
frappe.set_route("Form", stock_entry.doctype, stock_entry.name);
});
}
},
create_pick_list: function (frm, purpose = "Material Transfer for Manufacture") {

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