Merge pull request #47528 from frappe/version-15-hotfix

chore: release v15
This commit is contained in:
ruthra kumar
2025-05-13 19:32:35 +05:30
committed by GitHub
30 changed files with 381 additions and 303 deletions

View File

@@ -21,7 +21,6 @@
"party_name",
"book_advance_payments_in_separate_party_account",
"reconcile_on_advance_payment_date",
"advance_reconciliation_takes_effect_on",
"column_break_11",
"bank_account",
"party_bank_account",
@@ -786,18 +785,9 @@
"options": "No\nYes",
"print_hide": 1,
"search_index": 1
},
{
"default": "Oldest Of Invoice Or Advance",
"fetch_from": "company.reconciliation_takes_effect_on",
"fieldname": "advance_reconciliation_takes_effect_on",
"fieldtype": "Select",
"hidden": 1,
"label": "Advance Reconciliation Takes Effect On",
"no_copy": 1,
"options": "Advance Payment Date\nOldest Of Invoice Or Advance\nReconciliation Date"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [
@@ -809,7 +799,7 @@
"table_fieldname": "payment_entries"
}
],
"modified": "2025-03-24 16:18:19.920701",
"modified": "2025-05-08 11:18:10.238085",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Entry",
@@ -849,6 +839,7 @@
"write": 1
}
],
"row_format": "Dynamic",
"show_name_in_global_search": 1,
"sort_field": "modified",
"sort_order": "DESC",

View File

@@ -1492,9 +1492,12 @@ class PaymentEntry(AccountsController):
else:
# For backwards compatibility
# Supporting reposting on payment entries reconciled before select field introduction
if self.advance_reconciliation_takes_effect_on == "Advance Payment Date":
reconciliation_takes_effect_on = frappe.get_cached_value(
"Company", self.company, "reconciliation_takes_effect_on"
)
if reconciliation_takes_effect_on == "Advance Payment Date":
posting_date = self.posting_date
elif self.advance_reconciliation_takes_effect_on == "Oldest Of Invoice Or Advance":
elif reconciliation_takes_effect_on == "Oldest Of Invoice Or Advance":
date_field = "posting_date"
if invoice.reference_doctype in ["Sales Order", "Purchase Order"]:
date_field = "transaction_date"
@@ -1504,7 +1507,7 @@ class PaymentEntry(AccountsController):
if getdate(posting_date) < getdate(self.posting_date):
posting_date = self.posting_date
elif self.advance_reconciliation_takes_effect_on == "Reconciliation Date":
elif reconciliation_takes_effect_on == "Reconciliation Date":
posting_date = nowdate()
frappe.db.set_value("Payment Entry Reference", invoice.name, "reconcile_effect_on", posting_date)
@@ -2361,7 +2364,7 @@ def get_outstanding_reference_documents(args, validate=False):
accounts = get_party_account(
args.get("party_type"), args.get("party"), args.get("company"), include_advance=True
)
advance_account = accounts[1] if len(accounts) >= 1 else None
advance_account = accounts[1] if len(accounts) > 1 else None
if party_account == advance_account:
party_account = accounts[0]

View File

@@ -133,7 +133,12 @@ class PeriodClosingVoucher(AccountsController):
self.make_gl_entries()
def on_cancel(self):
self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Payment Ledger Entry")
self.ignore_linked_doctypes = (
"GL Entry",
"Stock Ledger Entry",
"Payment Ledger Entry",
"Account Closing Balance",
)
self.block_if_future_closing_voucher_exists()
self.db_set("gle_processing_status", "In Progress")
self.cancel_gl_entries()

View File

@@ -1695,6 +1695,9 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
# Configure Buying Settings to allow rate change
frappe.db.set_single_value("Buying Settings", "maintain_same_rate", 0)
# Configure Accounts Settings to allow 300% over billing
frappe.db.set_single_value("Accounts Settings", "over_billing_allowance", 300)
# Create PR: rate = 1000, qty = 5
pr = make_purchase_receipt(
item_code="_Test Non Stock Item", rate=1000, posting_date=add_days(nowdate(), -2)
@@ -2756,6 +2759,43 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
self.assertEqual(invoice.grand_total, 300)
def test_pr_pi_over_billing(self):
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
make_purchase_invoice as make_purchase_invoice_from_pr,
)
# Configure Buying Settings to allow rate change
frappe.db.set_single_value("Buying Settings", "maintain_same_rate", 0)
pr = make_purchase_receipt(qty=10, rate=10)
pi = make_purchase_invoice_from_pr(pr.name)
pi.items[0].rate = 12
# Test 1 - This will fail because over billing is not allowed
self.assertRaises(frappe.ValidationError, pi.submit)
frappe.db.set_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", 1)
# Test 2 - This will now submit because over billing allowance is ignored when set_landed_cost_based_on_purchase_invoice_rate is checked
pi.submit()
frappe.db.set_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", 0)
frappe.db.set_single_value("Accounts Settings", "over_billing_allowance", 20)
pi.cancel()
pi = make_purchase_invoice_from_pr(pr.name)
pi.items[0].rate = 12
# Test 3 - This will now submit because over billing is allowed upto 20%
pi.submit()
pi.reload()
pi.cancel()
pi = make_purchase_invoice_from_pr(pr.name)
pi.items[0].rate = 13
# Test 4 - Since this PI is overbilled by 130% and only 120% is allowed, it will fail
self.assertRaises(frappe.ValidationError, pi.submit)
def set_advance_flag(company, flag, default_account):
frappe.db.set_value(

View File

@@ -563,17 +563,20 @@ def get_account_type_map(company):
def get_result_as_list(data, filters):
balance, _balance_in_account_currency = 0, 0
balance = 0
for d in data:
if not d.get("posting_date"):
balance, _balance_in_account_currency = 0, 0
balance = 0
balance = get_balance(d, balance, "debit", "credit")
d["balance"] = balance
d["account_currency"] = filters.account_currency
d["presentation_currency"] = filters.presentation_currency
return data
@@ -599,11 +602,8 @@ def get_columns(filters):
if filters.get("presentation_currency"):
currency = filters["presentation_currency"]
else:
if filters.get("company"):
currency = get_company_currency(filters["company"])
else:
company = get_default_company()
currency = get_company_currency(company)
company = filters.get("company") or get_default_company()
filters["presentation_currency"] = currency = get_company_currency(company)
columns = [
{
@@ -624,19 +624,22 @@ def get_columns(filters):
{
"label": _("Debit ({0})").format(currency),
"fieldname": "debit",
"fieldtype": "Float",
"fieldtype": "Currency",
"options": "presentation_currency",
"width": 130,
},
{
"label": _("Credit ({0})").format(currency),
"fieldname": "credit",
"fieldtype": "Float",
"fieldtype": "Currency",
"options": "presentation_currency",
"width": 130,
},
{
"label": _("Balance ({0})").format(currency),
"fieldname": "balance",
"fieldtype": "Float",
"fieldtype": "Currency",
"options": "presentation_currency",
"width": 130,
},
]

View File

@@ -35,7 +35,6 @@ def execute(filters=None):
filters=filters,
accumulated_values=filters.accumulated_values,
ignore_closing_entries=True,
ignore_accumulated_values_for_fy=True,
)
expense = get_data(
@@ -46,7 +45,6 @@ def execute(filters=None):
filters=filters,
accumulated_values=filters.accumulated_values,
ignore_closing_entries=True,
ignore_accumulated_values_for_fy=True,
)
net_profit_loss = get_net_profit_loss(

View File

@@ -2,8 +2,9 @@
# MIT License. See license.txt
import frappe
from frappe.desk.query_report import export_query
from frappe.tests.utils import FrappeTestCase
from frappe.utils import getdate, today
from frappe.utils import add_days, getdate, today
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.report.financial_statements import get_period_list
@@ -57,7 +58,7 @@ class TestProfitAndLossStatement(AccountsTestMixin, FrappeTestCase):
period_end_date=fy.year_end_date,
filter_based_on="Fiscal Year",
periodicity="Monthly",
accumulated_vallues=True,
accumulated_values=False,
)
def test_profit_and_loss_output_and_summary(self):
@@ -90,3 +91,82 @@ class TestProfitAndLossStatement(AccountsTestMixin, FrappeTestCase):
with self.subTest(current_period_key=current_period_key):
self.assertEqual(acc[current_period_key], 150)
self.assertEqual(acc["total"], 150)
def test_p_and_l_export(self):
self.create_sales_invoice(qty=1, rate=150)
filters = self.get_report_filters()
frappe.local.form_dict = frappe._dict(
{
"report_name": "Profit and Loss Statement",
"file_format_type": "CSV",
"filters": filters,
"visible_idx": [0, 1, 2, 3, 4, 5, 6],
}
)
export_query()
contents = frappe.response["filecontent"].decode()
sales_account = frappe.db.get_value("Company", self.company, "default_income_account")
self.assertIn(sales_account, contents)
def test_accumulate_filter(self):
# ensure 2 fiscal years
cur_fy = self.get_fiscal_year()
find_for = add_days(cur_fy.year_start_date, -1)
_x = frappe.db.get_all(
"Fiscal Year",
filters={"disabled": 0, "year_start_date": ("<=", find_for), "year_end_date": (">=", find_for)},
)[0]
prev_fy = frappe.get_doc("Fiscal Year", _x.name)
prev_fy.append("companies", {"company": self.company})
prev_fy.save()
# make SI on both of them
prev_fy_si = self.create_sales_invoice(qty=1, rate=450, do_not_submit=True)
prev_fy_si.posting_date = add_days(prev_fy.year_end_date, -1)
prev_fy_si.save().submit()
income_acc = prev_fy_si.items[0].income_account
self.create_sales_invoice(qty=1, rate=120)
# Unaccumualted
filters = frappe._dict(
company=self.company,
from_fiscal_year=prev_fy.name,
to_fiscal_year=cur_fy.name,
period_start_date=prev_fy.year_start_date,
period_end_date=cur_fy.year_end_date,
filter_based_on="Date Range",
periodicity="Yearly",
accumulated_values=False,
)
result = execute(filters)
columns = [result[0][2], result[0][3]]
expected = {
"account": income_acc,
columns[0].get("fieldname"): 450.0,
columns[1].get("fieldname"): 120.0,
}
actual = [x for x in result[1] if x.get("account") == income_acc]
self.assertEqual(len(actual), 1)
actual = actual[0]
for key in expected.keys():
with self.subTest(key=key):
self.assertEqual(expected.get(key), actual.get(key))
# accumualted
filters.update({"accumulated_values": True})
expected = {
"account": income_acc,
columns[0].get("fieldname"): 450.0,
columns[1].get("fieldname"): 570.0,
}
result = execute(filters)
columns = [result[0][2], result[0][3]]
actual = [x for x in result[1] if x.get("account") == income_acc]
self.assertEqual(len(actual), 1)
actual = actual[0]
for key in expected.keys():
with self.subTest(key=key):
self.assertEqual(expected.get(key), actual.get(key))

View File

@@ -713,10 +713,13 @@ def update_reference_in_payment_entry(
update_advance_paid = []
# Update Reconciliation effect date in reference
reconciliation_takes_effect_on = frappe.get_cached_value(
"Company", payment_entry.company, "reconciliation_takes_effect_on"
)
if payment_entry.book_advance_payments_in_separate_party_account:
if payment_entry.advance_reconciliation_takes_effect_on == "Advance Payment Date":
if reconciliation_takes_effect_on == "Advance Payment Date":
reconcile_on = payment_entry.posting_date
elif payment_entry.advance_reconciliation_takes_effect_on == "Oldest Of Invoice Or Advance":
elif reconciliation_takes_effect_on == "Oldest Of Invoice Or Advance":
date_field = "posting_date"
if d.against_voucher_type in ["Sales Order", "Purchase Order"]:
date_field = "transaction_date"
@@ -724,7 +727,7 @@ def update_reference_in_payment_entry(
if getdate(reconcile_on) < getdate(payment_entry.posting_date):
reconcile_on = payment_entry.posting_date
elif payment_entry.advance_reconciliation_takes_effect_on == "Reconciliation Date":
elif reconciliation_takes_effect_on == "Reconciliation Date":
reconcile_on = nowdate()
reference_details.update({"reconcile_effect_on": reconcile_on})

View File

@@ -122,6 +122,7 @@ class Asset(AccountsController):
# end: auto-generated types
def validate(self):
self.validate_category()
self.validate_precision()
self.set_purchase_doc_row_item()
self.validate_asset_values()
@@ -343,6 +344,17 @@ class Asset(AccountsController):
title=_("Missing Finance Book"),
)
def validate_category(self):
non_depreciable_category = frappe.db.get_value(
"Asset Category", self.asset_category, "non_depreciable_category"
)
if self.calculate_depreciation and non_depreciable_category:
frappe.throw(
_(
"This asset category is marked as non-depreciable. Please disable depreciation calculation or choose a different category."
)
)
def validate_precision(self):
if self.gross_purchase_amount:
self.gross_purchase_amount = flt(

View File

@@ -330,45 +330,6 @@ def _make_journal_entry_for_depreciation(
row.db_update()
def get_depreciation_accounts(asset_category, company):
fixed_asset_account = accumulated_depreciation_account = depreciation_expense_account = None
accounts = frappe.db.get_value(
"Asset Category Account",
filters={"parent": asset_category, "company_name": company},
fieldname=[
"fixed_asset_account",
"accumulated_depreciation_account",
"depreciation_expense_account",
],
as_dict=1,
)
if accounts:
fixed_asset_account = accounts.fixed_asset_account
accumulated_depreciation_account = accounts.accumulated_depreciation_account
depreciation_expense_account = accounts.depreciation_expense_account
if not accumulated_depreciation_account or not depreciation_expense_account:
accounts = frappe.get_cached_value(
"Company", company, ["accumulated_depreciation_account", "depreciation_expense_account"]
)
if not accumulated_depreciation_account:
accumulated_depreciation_account = accounts[0]
if not depreciation_expense_account:
depreciation_expense_account = accounts[1]
if not fixed_asset_account or not accumulated_depreciation_account or not depreciation_expense_account:
frappe.throw(
_("Please set Depreciation related Accounts in Asset Category {0} or Company {1}").format(
asset_category, company
)
)
return fixed_asset_account, accumulated_depreciation_account, depreciation_expense_account
def get_credit_and_debit_accounts(accumulated_depreciation_account, depreciation_expense_account):
root_type = frappe.get_value("Account", depreciation_expense_account, "root_type")
@@ -721,8 +682,8 @@ def get_asset_details(asset, finance_book=None):
value_after_depreciation = asset.get_value_after_depreciation(finance_book)
accumulated_depr_amount = flt(asset.gross_purchase_amount) - flt(value_after_depreciation)
fixed_asset_account, accumulated_depr_account, _ = get_asset_accounts(
asset.asset_category, asset.company, accumulated_depr_amount
fixed_asset_account, accumulated_depr_account, _ = get_depreciation_accounts(
asset.asset_category, asset.company
)
disposal_account, depreciation_cost_center = get_disposal_account_and_cost_center(asset.company)
depreciation_cost_center = asset.cost_center or depreciation_cost_center
@@ -738,9 +699,13 @@ def get_asset_details(asset, finance_book=None):
)
def get_asset_accounts(asset_category, company, accumulated_depr_amount):
def get_depreciation_accounts(asset_category, company):
fixed_asset_account = accumulated_depreciation_account = depreciation_expense_account = None
non_depreciable_category = frappe.db.get_value(
"Asset Category", asset_category, "non_depreciable_category"
)
accounts = frappe.db.get_value(
"Asset Category Account",
filters={"parent": asset_category, "company_name": company},
@@ -760,7 +725,7 @@ def get_asset_accounts(asset_category, company, accumulated_depr_amount):
if not fixed_asset_account:
frappe.throw(_("Please set Fixed Asset Account in Asset Category {0}").format(asset_category))
if accumulated_depr_amount:
if not non_depreciable_category:
accounts = frappe.get_cached_value(
"Company", company, ["accumulated_depreciation_account", "depreciation_expense_account"]
)

View File

@@ -12,6 +12,7 @@
"column_break_3",
"depreciation_options",
"enable_cwip_accounting",
"non_depreciable_category",
"finance_book_detail",
"finance_books",
"section_break_2",
@@ -63,10 +64,16 @@
"fieldname": "enable_cwip_accounting",
"fieldtype": "Check",
"label": "Enable Capital Work in Progress Accounting"
},
{
"default": "0",
"fieldname": "non_depreciable_category",
"fieldtype": "Check",
"label": "Non Depreciable Category"
}
],
"links": [],
"modified": "2021-02-24 15:05:38.621803",
"modified": "2025-05-13 15:33:03.791814",
"modified_by": "Administrator",
"module": "Assets",
"name": "Asset Category",
@@ -111,8 +118,9 @@
"write": 1
}
],
"row_format": "Dynamic",
"show_name_in_global_search": 1,
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}
}

View File

@@ -17,15 +17,14 @@ class AssetCategory(Document):
if TYPE_CHECKING:
from frappe.types import DF
from erpnext.assets.doctype.asset_category_account.asset_category_account import (
AssetCategoryAccount,
)
from erpnext.assets.doctype.asset_category_account.asset_category_account import AssetCategoryAccount
from erpnext.assets.doctype.asset_finance_book.asset_finance_book import AssetFinanceBook
accounts: DF.Table[AssetCategoryAccount]
asset_category_name: DF.Data
enable_cwip_accounting: DF.Check
finance_books: DF.Table[AssetFinanceBook]
non_depreciable_category: DF.Check
# end: auto-generated types
def validate(self):

View File

@@ -111,6 +111,13 @@ frappe.ui.form.on("Opportunity", {
},
__("Create")
);
let company_currency = erpnext.get_currency(frm.doc.company);
if (company_currency != frm.doc.currency) {
frm.add_custom_button(__("Fetch Latest Exchange Rate"), function () {
frm.trigger("currency");
});
}
}
if (!frm.doc.__islocal && frm.perm[0].write && frm.doc.docstatus == 0) {
@@ -152,7 +159,7 @@ frappe.ui.form.on("Opportunity", {
currency: function (frm) {
let company_currency = erpnext.get_currency(frm.doc.company);
if (company_currency != frm.doc.company) {
if (company_currency != frm.doc.currency) {
frappe.call({
method: "erpnext.setup.utils.get_exchange_rate",
args: {
@@ -278,7 +285,6 @@ erpnext.crm.Opportunity = class Opportunity extends frappe.ui.form.Controller {
}
this.setup_queries();
this.frm.trigger("currency");
}
refresh() {

View File

@@ -2665,6 +2665,81 @@ class TestWorkOrder(FrappeTestCase):
)
frappe.db.set_single_value("Stock Settings", "pick_serial_and_batch_based_on", original_based_on)
def test_operations_time_planning_calculation(self):
from erpnext.manufacturing.doctype.routing.test_routing import create_routing, setup_operations
operations = [
{"operation": "Test Operation A", "workstation": "Test Workstation A", "time_in_mins": 1},
{"operation": "Test Operation B", "workstation": "Test Workstation A", "time_in_mins": 4},
{"operation": "Test Operation C", "workstation": "Test Workstation A", "time_in_mins": 3},
{"operation": "Test Operation D", "workstation": "Test Workstation A", "time_in_mins": 2},
]
setup_operations(operations)
routing_doc = create_routing(routing_name="Testing Route", operations=operations)
bom = make_bom(
item="_Test FG Item", raw_materials=["_Test Item"], with_operations=1, routing=routing_doc.name
)
wo = make_wo_order_test_record(
item="_Test FG Item",
bom_no=bom.name,
qty=5,
source_warehouse="_Test Warehouse 1 - _TC",
skip_transfer=1,
fg_warehouse="_Test Warehouse 2 - _TC",
)
# Initial check
self.assertEqual(wo.operations[0].operation, "Test Operation A")
self.assertEqual(wo.operations[1].operation, "Test Operation B")
self.assertEqual(wo.operations[2].operation, "Test Operation C")
self.assertEqual(wo.operations[3].operation, "Test Operation D")
wo = frappe.copy_doc(wo)
wo.operations[3].sequence_id = 2
wo.submit()
# Test 2 : Sort line items in child table based on sequence ID
self.assertEqual(wo.operations[0].operation, "Test Operation A")
self.assertEqual(wo.operations[1].operation, "Test Operation B")
self.assertEqual(wo.operations[2].operation, "Test Operation D")
self.assertEqual(wo.operations[3].operation, "Test Operation C")
wo = frappe.copy_doc(wo)
wo.operations[3].sequence_id = 1
wo.submit()
self.assertEqual(wo.operations[0].operation, "Test Operation A")
self.assertEqual(wo.operations[1].operation, "Test Operation C")
self.assertEqual(wo.operations[2].operation, "Test Operation B")
self.assertEqual(wo.operations[3].operation, "Test Operation D")
wo = frappe.copy_doc(wo)
wo.operations[0].sequence_id = 3
wo.submit()
self.assertEqual(wo.operations[0].operation, "Test Operation C")
self.assertEqual(wo.operations[1].operation, "Test Operation B")
self.assertEqual(wo.operations[2].operation, "Test Operation D")
self.assertEqual(wo.operations[3].operation, "Test Operation A")
wo = frappe.copy_doc(wo)
wo.operations[1].sequence_id = 0
# Test 3 - Error should be thrown if any one operation does not have sequence id but others do
self.assertRaises(frappe.ValidationError, wo.submit)
workstation = frappe.get_doc("Workstation", "Test Workstation A")
workstation.production_capacity = 4
workstation.save()
wo = frappe.copy_doc(wo)
wo.operations[1].sequence_id = 2
wo.submit()
# Test 4 - If Sequence ID is same then planned start time for both operations should be same
self.assertEqual(wo.operations[1].planned_start_time, wo.operations[2].planned_start_time)
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

@@ -2,7 +2,7 @@
"actions": [],
"allow_import": 1,
"autoname": "naming_series:",
"creation": "2013-01-10 16:34:16",
"creation": "2025-04-09 12:09:40.634472",
"doctype": "DocType",
"document_type": "Setup",
"engine": "InnoDB",

View File

@@ -624,19 +624,30 @@ class WorkOrder(Document):
enable_capacity_planning = not cint(manufacturing_settings_doc.disable_capacity_planning)
plan_days = cint(manufacturing_settings_doc.capacity_planning_for_days) or 30
for index, row in enumerate(self.operations):
if all([op.sequence_id for op in self.operations]):
self.operations = sorted(self.operations, key=lambda op: op.sequence_id)
for idx, op in enumerate(self.operations):
op.idx = idx + 1
elif any([op.sequence_id for op in self.operations]):
frappe.throw(
_(
"Row #{0}: Incorrect Sequence ID. If any single operation has a Sequence ID then all other operations must have one too."
).format(next((op.idx for op in self.operations if not op.sequence_id), None))
)
for idx, row in enumerate(self.operations):
qty = self.qty
while qty > 0:
qty = split_qty_based_on_batch_size(self, row, qty)
if row.job_card_qty > 0:
self.prepare_data_for_job_card(row, index, plan_days, enable_capacity_planning)
self.prepare_data_for_job_card(row, idx, plan_days, enable_capacity_planning)
planned_end_date = self.operations and self.operations[-1].planned_end_time
if planned_end_date:
self.db_set("planned_end_date", planned_end_date)
def prepare_data_for_job_card(self, row, index, plan_days, enable_capacity_planning):
self.set_operation_start_end_time(index, row)
def prepare_data_for_job_card(self, row, idx, plan_days, enable_capacity_planning):
self.set_operation_start_end_time(row, idx)
job_card_doc = create_job_card(
self, row, auto_create=True, enable_capacity_planning=enable_capacity_planning
@@ -661,12 +672,24 @@ class WorkOrder(Document):
row.db_update()
def set_operation_start_end_time(self, idx, row):
def set_operation_start_end_time(self, row, idx):
"""Set start and end time for given operation. If first operation, set start as
`planned_start_date`, else add time diff to end time of earlier operation."""
if idx == 0:
# first operation at planned_start date
row.planned_start_time = self.planned_start_date
elif self.operations[idx - 1].sequence_id:
if self.operations[idx - 1].sequence_id == row.sequence_id:
row.planned_start_time = self.operations[idx - 1].planned_start_time
else:
last_ops_with_same_sequence_ids = sorted(
[op for op in self.operations if op.sequence_id == self.operations[idx - 1].sequence_id],
key=lambda op: get_datetime(op.planned_end_time),
)
row.planned_start_time = (
get_datetime(last_ops_with_same_sequence_ids[-1].planned_end_time)
+ get_mins_between_operations()
)
else:
row.planned_start_time = (
get_datetime(self.operations[idx - 1].planned_end_time) + get_mins_between_operations()

View File

@@ -1,6 +1,6 @@
{
"actions": [],
"creation": "2014-10-16 14:35:41.950175",
"creation": "2025-04-09 12:12:19.824560",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
@@ -102,13 +102,15 @@
"fieldname": "planned_start_time",
"fieldtype": "Datetime",
"label": "Planned Start Time",
"no_copy": 1
"no_copy": 1,
"read_only": 1
},
{
"fieldname": "planned_end_time",
"fieldtype": "Datetime",
"label": "Planned End Time",
"no_copy": 1
"no_copy": 1,
"read_only": 1
},
{
"fieldname": "column_break_10",
@@ -191,7 +193,6 @@
{
"fieldname": "sequence_id",
"fieldtype": "Int",
"hidden": 1,
"label": "Sequence ID",
"print_hide": 1
},
@@ -219,10 +220,11 @@
"read_only": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2023-06-09 14:03:01.612909",
"modified": "2025-04-09 16:21:47.110564",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Work Order Operation",
@@ -232,4 +234,4 @@
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -389,7 +389,6 @@ erpnext.patches.v15_0.enable_allow_existing_serial_no
erpnext.patches.v15_0.update_cc_in_process_statement_of_accounts
erpnext.patches.v15_0.update_asset_status_to_work_in_progress
erpnext.patches.v15_0.rename_manufacturing_settings_field
erpnext.patches.v15_0.migrate_checkbox_to_select_for_reconciliation_effect
erpnext.patches.v15_0.sync_auto_reconcile_config
execute:frappe.db.set_single_value("Accounts Settings", "exchange_gain_loss_posting_date", "Payment")
erpnext.patches.v14_0.disable_add_row_in_gross_profit

View File

@@ -1,18 +0,0 @@
import frappe
def execute():
"""
A New select field 'reconciliation_takes_effect_on' has been added to control Advance Payment Reconciliation dates.
Migrate old checkbox configuration to new select field on 'Company' and 'Payment Entry'
"""
companies = frappe.db.get_all("Company", fields=["name", "reconciliation_takes_effect_on"])
for x in companies:
new_value = (
"Advance Payment Date" if x.reconcile_on_advance_payment_date else "Oldest Of Invoice Or Advance"
)
frappe.db.set_value("Company", x.name, "reconciliation_takes_effect_on", new_value)
frappe.db.sql(
"""update `tabPayment Entry` set advance_reconciliation_takes_effect_on = if(reconcile_on_advance_payment_date = 0, 'Oldest Of Invoice Or Advance', 'Advance Payment Date')"""
)

View File

@@ -535,7 +535,7 @@ def get_timesheets_list(doctype, txt, filters, limit_start, limit_page_length=20
table.name,
child_table.activity_type,
table.status,
table.total_billable_hours,
child_table.billing_hours,
(table.sales_invoice | child_table.sales_invoice).as_("sales_invoice"),
child_table.project,
)

View File

@@ -47,7 +47,7 @@ frappe.ui.form.on("Event", {
frm.add_custom_button(
__("Add Sales Partners"),
function () {
new frappe.desk.eventParticipants(frm, "Sales Partners");
new frappe.desk.eventParticipants(frm, "Sales Partner");
},
__("Add Participants")
);

View File

@@ -147,10 +147,8 @@ def get_items(start, page_length, price_list, item_group, pos_profile, search_te
bin_join_selection, bin_join_condition = "", ""
if hide_unavailable_items:
bin_join_selection = ", `tabBin` bin"
bin_join_condition = (
"AND bin.warehouse = %(warehouse)s AND bin.item_code = item.name AND bin.actual_qty > 0"
)
bin_join_selection = "LEFT JOIN `tabBin` bin ON bin.item_code = item.name"
bin_join_condition = "AND item.is_stock_item = 0 OR (item.is_stock_item = 1 AND bin.warehouse = %(warehouse)s AND bin.actual_qty > 0)"
items_data = frappe.db.sql(
"""

View File

@@ -214,13 +214,12 @@ def get_or_create_account(company_name, account):
default_root_type = "Liability"
root_type = account.get("root_type", default_root_type)
or_filters = {"account_name": account.get("account_name")}
if account.get("account_number"):
or_filters.update({"account_number": account.get("account_number")})
existing_accounts = frappe.get_all(
"Account",
filters={"company": company_name, "root_type": root_type},
or_filters={
"account_name": account.get("account_name"),
"account_number": account.get("account_number"),
},
"Account", filters={"company": company_name, "root_type": root_type}, or_filters=or_filters
)
if existing_accounts:

View File

@@ -1081,6 +1081,7 @@ def get_billed_amount_against_po(po_items):
def update_billing_percentage(pr_doc, update_modified=True, adjust_incoming_rate=False):
# Update Billing % based on pending accepted qty
buying_settings = frappe.get_single("Buying Settings")
over_billing_allowance = frappe.db.get_single_value("Accounts Settings", "over_billing_allowance")
total_amount, total_billed_amount = 0, 0
item_wise_returned_qty = get_item_wise_returned_qty(pr_doc)
@@ -1119,6 +1120,14 @@ def update_billing_percentage(pr_doc, update_modified=True, adjust_incoming_rate
adjusted_amt = flt(adjusted_amt * flt(pr_doc.conversion_rate), item.precision("amount"))
item.db_set("amount_difference_with_purchase_invoice", adjusted_amt, update_modified=False)
elif item.billed_amt > item.amount:
per_over_billed = (flt(item.billed_amt / item.amount, 2) * 100) - 100
if per_over_billed > over_billing_allowance:
frappe.throw(
_("Over Billing Allowance exceeded for Purchase Receipt Item {0} ({1}) by {2}%").format(
item.name, frappe.bold(item.item_code), per_over_billed - over_billing_allowance
)
)
percent_billed = round(100 * (total_billed_amount / (total_amount or 1)), 6)
pr_doc.db_set("per_billed", percent_billed)

View File

@@ -342,7 +342,7 @@ def remove_attached_file(docname):
if file_name := frappe.db.get_value(
"File", {"attached_to_name": docname, "attached_to_doctype": "Repost Item Valuation"}, "name"
):
frappe.delete_doc("File", file_name, ignore_permissions=True, delete_permanently=True)
frappe.delete_doc("File", file_name, ignore_permissions=True, delete_permanently=True, force=True)
def repost_sl_entries(doc):

View File

@@ -6,7 +6,7 @@ import json
from collections import defaultdict
import frappe
from frappe import _
from frappe import _, bold
from frappe.model.mapper import get_mapped_doc
from frappe.query_builder.functions import Sum
from frappe.utils import (
@@ -526,6 +526,14 @@ class StockEntry(StockController):
OpeningEntryAccountError,
)
if self.purpose != "Material Issue" and acc_details.account_type == "Cost of Goods Sold":
frappe.msgprint(
_(
"At row {0}: You have selected the Difference Account {1}, which is a Cost of Goods Sold type account. Please select a different account"
).format(d.idx, bold(get_link_to_form("Account", d.expense_account))),
title=_("Warning : Cost of Goods Sold Account"),
)
def validate_warehouse(self):
"""perform various (sometimes conditional) validations on warehouse"""

View File

@@ -203,9 +203,19 @@ class StockReconciliation(StockController):
)
)
if self.docstatus == 1:
bundle.voucher_no = self.name
bundle.submit()
if (
self.docstatus == 1
and item.current_serial_and_batch_bundle
and frappe.db.get_value(
"Serial and Batch Bundle", item.current_serial_and_batch_bundle, "docstatus"
)
== 0
):
sabb_doc = frappe.get_doc(
"Serial and Batch Bundle", item.current_serial_and_batch_bundle
)
sabb_doc.voucher_no = self.name
sabb_doc.submit()
item.db_set(
{

View File

@@ -51,49 +51,11 @@ frappe.query_reports["Available Serial No"] = {
};
},
},
{
fieldname: "item_group",
label: __("Item Group"),
fieldtype: "Link",
options: "Item Group",
},
{
fieldname: "batch_no",
label: __("Batch No"),
fieldtype: "Link",
options: "Batch",
on_change() {
const batch_no = frappe.query_report.get_filter_value("batch_no");
if (batch_no) {
frappe.query_report.set_filter_value("segregate_serial_batch_bundle", 1);
} else {
frappe.query_report.set_filter_value("segregate_serial_batch_bundle", 0);
}
},
},
{
fieldname: "brand",
label: __("Brand"),
fieldtype: "Link",
options: "Brand",
},
{
fieldname: "voucher_no",
label: __("Voucher #"),
fieldtype: "Data",
},
{
fieldname: "project",
label: __("Project"),
fieldtype: "Link",
options: "Project",
},
{
fieldname: "include_uom",
label: __("Include UOM"),
fieldtype: "Link",
options: "UOM",
},
{
fieldname: "valuation_field_type",
label: __("Valuation Field Type"),

View File

@@ -3,108 +3,62 @@
import frappe
from frappe import _
from frappe.query_builder.functions import Sum
from frappe.utils import cint, flt
from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos, get_serial_nos_from_sle_list
from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import get_stock_balance_for
from erpnext.stock.report.stock_ledger.stock_ledger import (
check_inventory_dimension_filters_applied,
get_item_details,
get_item_group_condition,
get_opening_balance,
get_opening_balance_from_batch,
get_stock_ledger_entries,
)
from erpnext.stock.utils import (
is_reposting_item_valuation_in_progress,
update_included_uom_in_report,
)
from erpnext.stock.utils import is_reposting_item_valuation_in_progress
def execute(filters=None):
is_reposting_item_valuation_in_progress()
include_uom = filters.get("include_uom")
columns = get_columns(filters)
items = get_items(filters)
sl_entries = get_stock_ledger_entries(filters, items)
item_details = get_item_details(items, sl_entries, include_uom)
item_details = get_item_details(items, sl_entries, False)
opening_row, actual_qty, stock_value = get_opening_balance_data(filters, columns, sl_entries)
opening_row = get_opening_balance_data(filters, columns, sl_entries)
precision = cint(frappe.db.get_single_value("System Settings", "float_precision"))
data, conversion_factors = process_stock_ledger_entries(
filters, sl_entries, item_details, opening_row, actual_qty, stock_value, precision
)
data = process_stock_ledger_entries(sl_entries, item_details, opening_row, precision)
update_included_uom_in_report(columns, data, include_uom, conversion_factors)
return columns, data
def get_opening_balance_data(filters, columns, sl_entries):
if filters.get("batch_no"):
opening_row = get_opening_balance_from_batch(filters, columns, sl_entries)
else:
opening_row = get_opening_balance(filters, columns, sl_entries)
actual_qty = opening_row.get("qty_after_transaction") if opening_row else 0
stock_value = opening_row.get("stock_value") if opening_row else 0
return opening_row, actual_qty, stock_value
opening_row = get_opening_balance(filters, columns, sl_entries)
return opening_row
def process_stock_ledger_entries(
filters, sl_entries, item_details, opening_row, actual_qty, stock_value, precision
):
def process_stock_ledger_entries(sl_entries, item_details, opening_row, precision):
data = []
conversion_factors = []
if opening_row:
data.append(opening_row)
conversion_factors.append(0)
batch_balance_dict = frappe._dict({})
available_serial_nos = {}
if sabb_list := [sle.serial_and_batch_bundle for sle in sl_entries if sle.serial_and_batch_bundle]:
available_serial_nos = get_serial_nos_from_sle_list(sabb_list)
if actual_qty and filters.get("batch_no"):
batch_balance_dict[filters.batch_no] = [actual_qty, stock_value]
available_serial_nos = get_serial_nos_from_sle_list(
[sle.serial_and_batch_bundle for sle in sl_entries if sle.serial_and_batch_bundle]
)
if not available_serial_nos:
return [], []
for sle in sl_entries:
update_stock_ledger_entry(
sle, item_details, filters, actual_qty, stock_value, batch_balance_dict, precision
)
update_stock_ledger_entry(sle, item_details, precision)
update_available_serial_nos(available_serial_nos, sle)
data.append(sle)
if filters.get("include_uom"):
conversion_factors.append(item_details[sle.item_code].conversion_factor)
return data, conversion_factors
return data
def update_stock_ledger_entry(
sle, item_details, filters, actual_qty, stock_value, batch_balance_dict, precision
):
def update_stock_ledger_entry(sle, item_details, precision):
item_detail = item_details[sle.item_code]
sle.update(item_detail)
if filters.get("batch_no") or check_inventory_dimension_filters_applied(filters):
actual_qty += flt(sle.actual_qty, precision)
stock_value += sle.stock_value_difference
if sle.batch_no:
batch_balance_dict.setdefault(sle.batch_no, [0, 0])
batch_balance_dict[sle.batch_no][0] += sle.actual_qty
if sle.voucher_type == "Stock Reconciliation" and not sle.actual_qty:
actual_qty = sle.qty_after_transaction
stock_value = sle.stock_value
sle.update({"qty_after_transaction": actual_qty, "stock_value": stock_value})
sle.update({"in_qty": max(sle.actual_qty, 0), "out_qty": min(sle.actual_qty, 0)})
if sle.actual_qty:
@@ -120,13 +74,10 @@ def update_available_serial_nos(available_serial_nos, sle):
else available_serial_nos.get(sle.serial_and_batch_bundle)
)
key = (sle.item_code, sle.warehouse)
sle.serial_no = "\n".join(serial_nos) if serial_nos else ""
if key not in available_serial_nos:
stock_balance = get_stock_balance_for(
sle.item_code, sle.warehouse, sle.posting_date, sle.posting_time
)
serials = get_serial_nos(stock_balance["serial_nos"]) if stock_balance["serial_nos"] else []
available_serial_nos.setdefault(key, serials)
sle.balance_serial_no = "\n".join(serials)
available_serial_nos.setdefault(key, serial_nos)
sle.balance_serial_no = "\n".join(serial_nos)
return
existing_serial_no = available_serial_nos[key]
@@ -151,25 +102,14 @@ def get_columns(filters):
},
{"label": _("Item Name"), "fieldname": "item_name", "width": 100},
{
"label": _("Stock UOM"),
"label": _("UOM"),
"fieldname": "stock_uom",
"fieldtype": "Link",
"options": "UOM",
"width": 90,
"width": 60,
},
]
for dimension in get_inventory_dimensions():
columns.append(
{
"label": _(dimension.doctype),
"fieldname": dimension.fieldname,
"fieldtype": "Link",
"options": dimension.doctype,
"width": 110,
}
)
columns.extend(
[
{
@@ -201,20 +141,11 @@ def get_columns(filters):
"width": 150,
},
{
"label": _("Item Group"),
"fieldname": "item_group",
"fieldtype": "Link",
"options": "Item Group",
"width": 100,
"label": _("Serial No (In/Out)"),
"fieldname": "serial_no",
"width": 150,
},
{
"label": _("Brand"),
"fieldname": "brand",
"fieldtype": "Link",
"options": "Brand",
"width": 100,
},
{"label": _("Description"), "fieldname": "description", "width": 200},
{"label": _("Balance Serial No"), "fieldname": "balance_serial_no", "width": 150},
{
"label": _("Incoming Rate"),
"fieldname": "incoming_rate",
@@ -257,28 +188,6 @@ def get_columns(filters):
"width": 110,
"options": "Company:company:default_currency",
},
{"label": _("Voucher Type"), "fieldname": "voucher_type", "width": 110},
{
"label": _("Voucher #"),
"fieldname": "voucher_no",
"fieldtype": "Dynamic Link",
"options": "voucher_type",
"width": 100,
},
{
"label": _("Batch"),
"fieldname": "batch_no",
"fieldtype": "Link",
"options": "Batch",
"width": 100,
},
{
"label": _("Serial No"),
"fieldname": "serial_no",
"fieldtype": "Link",
"options": "Serial No",
"width": 100,
},
{
"label": _("Serial and Batch Bundle"),
"fieldname": "serial_and_batch_bundle",
@@ -286,12 +195,12 @@ def get_columns(filters):
"options": "Serial and Batch Bundle",
"width": 100,
},
{"label": _("Balance Serial No"), "fieldname": "balance_serial_no", "width": 100},
{"label": _("Voucher Type"), "fieldname": "voucher_type", "width": 110},
{
"label": _("Project"),
"fieldname": "project",
"fieldtype": "Link",
"options": "Project",
"label": _("Voucher #"),
"fieldname": "voucher_no",
"fieldtype": "Dynamic Link",
"options": "voucher_type",
"width": 100,
},
{
@@ -310,19 +219,8 @@ def get_columns(filters):
def get_items(filters):
item = frappe.qb.DocType("Item")
query = frappe.qb.from_(item).select(item.name).where(item.has_serial_no == 1)
conditions = []
if item_code := filters.get("item_code"):
conditions.append(item.name == item_code)
else:
if brand := filters.get("brand"):
conditions.append(item.brand == brand)
if item_group := filters.get("item_group"):
if condition := get_item_group_condition(item_group, item):
conditions.append(condition)
if conditions:
for condition in conditions:
query = query.where(condition)
query = query.where(item.name == item_code)
return query.run(pluck=True)

View File

@@ -5,7 +5,7 @@
{{ doc.name }}
</span>
</div>
<div class="col-xs-2 small"> {{ doc.total_billable_hours }} </div>
<div class="col-xs-2 small"> {{ doc.billing_hours }} </div>
<div class="col-xs-2 small"> {{ doc.project or '' }} </div>
<div class="col-xs-2 small"> {{ doc.sales_invoice or '' }} </div>
<div class="col-xs-2 small"> {{ _(doc.activity_type) }} </div>