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

This commit is contained in:
Diptanil Saha
2025-12-09 22:28:48 +05:30
committed by GitHub
36 changed files with 855 additions and 231 deletions

View File

@@ -33,6 +33,7 @@ from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_sched
get_depr_schedule,
)
from erpnext.controllers.accounts_controller import AccountsController
from erpnext.setup.utils import get_exchange_rate as _get_exchange_rate
class StockAccountInvalidTransaction(frappe.ValidationError):
@@ -273,93 +274,7 @@ class JournalEntry(AccountsController):
)
def apply_tax_withholding(self):
from erpnext.accounts.report.general_ledger.general_ledger import get_account_type_map
if not self.apply_tds or self.voucher_type not in ("Debit Note", "Credit Note"):
return
parties = [d.party for d in self.get("accounts") if d.party]
parties = list(set(parties))
if len(parties) > 1:
frappe.throw(_("Cannot apply TDS against multiple parties in one entry"))
account_type_map = get_account_type_map(self.company)
party_type = "supplier" if self.voucher_type == "Credit Note" else "customer"
doctype = "Purchase Invoice" if self.voucher_type == "Credit Note" else "Sales Invoice"
debit_or_credit = (
"debit_in_account_currency"
if self.voucher_type == "Credit Note"
else "credit_in_account_currency"
)
rev_debit_or_credit = (
"credit_in_account_currency"
if debit_or_credit == "debit_in_account_currency"
else "debit_in_account_currency"
)
party_account = get_party_account(party_type.title(), parties[0], self.company)
net_total = sum(
d.get(debit_or_credit)
for d in self.get("accounts")
if account_type_map.get(d.account) not in ("Tax", "Chargeable")
)
party_amount = sum(
d.get(rev_debit_or_credit) for d in self.get("accounts") if d.account == party_account
)
inv = frappe._dict(
{
party_type: parties[0],
"doctype": doctype,
"company": self.company,
"posting_date": self.posting_date,
"net_total": net_total,
}
)
tax_withholding_details, advance_taxes, voucher_wise_amount = get_party_tax_withholding_details(
inv, self.tax_withholding_category
)
if not tax_withholding_details:
return
accounts = []
for d in self.get("accounts"):
if d.get("account") == tax_withholding_details.get("account_head"):
d.update(
{
"account": tax_withholding_details.get("account_head"),
debit_or_credit: tax_withholding_details.get("tax_amount"),
}
)
accounts.append(d.get("account"))
if d.get("account") == party_account:
d.update({rev_debit_or_credit: party_amount - tax_withholding_details.get("tax_amount")})
if not accounts or tax_withholding_details.get("account_head") not in accounts:
self.append(
"accounts",
{
"account": tax_withholding_details.get("account_head"),
rev_debit_or_credit: tax_withholding_details.get("tax_amount"),
"against_account": parties[0],
},
)
to_remove = [
d
for d in self.get("accounts")
if not d.get(rev_debit_or_credit) and d.account == tax_withholding_details.get("account_head")
]
for d in to_remove:
self.remove(d)
JournalEntryTaxWithholding(self).apply()
def update_asset_value(self):
if self.flags.planned_depr_entry or self.voucher_type != "Depreciation Entry":
@@ -1281,6 +1196,230 @@ class JournalEntry(AccountsController):
frappe.throw(_("Accounts table cannot be blank."))
class JournalEntryTaxWithholding:
def __init__(self, journal_entry):
self.doc: JournalEntry = journal_entry
self.party = None
self.party_type = None
self.party_account = None
self.party_row = None
self.existing_tds_rows = []
self.precision = None
self.has_multiple_parties = False
# Direction fields based on party type
self.party_field = None # "credit" for Supplier, "debit" for Customer
self.reverse_field = None # opposite of party_field
def apply(self):
if not self._set_party_info():
return
self._setup_direction_fields()
self._reset_existing_tds()
if not self._should_apply_tds():
self._cleanup_duplicate_tds_rows(None)
return
if self.has_multiple_parties:
frappe.throw(_("Cannot apply TDS against multiple parties in one entry"))
net_total = self._calculate_net_total()
if net_total <= 0:
return
tds_details = self._get_tds_details(net_total)
if not tds_details or not tds_details.get("tax_amount"):
return
self._create_or_update_tds_row(tds_details)
self._update_party_amount(tds_details.get("tax_amount"), is_reversal=False)
self._recalculate_totals()
def _should_apply_tds(self):
return self.doc.apply_tds and self.doc.voucher_type in ("Debit Note", "Credit Note")
def _set_party_info(self):
for row in self.doc.get("accounts"):
if row.party_type in ("Customer", "Supplier") and row.party:
if self.party and row.party != self.party:
self.has_multiple_parties = True
if not self.party:
self.party = row.party
self.party_type = row.party_type
self.party_account = row.account
self.party_row = row
if row.get("is_tax_withholding_account"):
self.existing_tds_rows.append(row)
return bool(self.party)
def _setup_direction_fields(self):
"""
For Supplier (TDS): party has credit, TDS reduces credit
For Customer (TCS): party has debit, TCS increases debit
"""
if self.party_type == "Supplier":
self.party_field = "credit"
self.reverse_field = "debit"
else: # Customer
self.party_field = "debit"
self.reverse_field = "credit"
self.precision = self.doc.precision(self.party_field, self.party_row)
def _reset_existing_tds(self):
for row in self.existing_tds_rows:
# TDS amount is always in credit (liability to government)
tds_amount = flt(row.get("credit") - row.get("debit"), self.precision)
if not tds_amount:
continue
self._update_party_amount(tds_amount, is_reversal=True)
# zero_out_tds_row
row.update(
{
"credit": 0,
"credit_in_account_currency": 0,
"debit": 0,
"debit_in_account_currency": 0,
}
)
def _update_party_amount(self, amount, is_reversal=False):
amount = flt(amount, self.precision)
amount_in_party_currency = flt(amount / self.party_row.get("exchange_rate", 1), self.precision)
# Determine which field the party amount is in
active_field = self.party_field if self.party_row.get(self.party_field) else self.reverse_field
# If amount is in reverse field, flip the signs
if active_field == self.reverse_field:
amount = -amount
amount_in_party_currency = -amount_in_party_currency
# Direction multiplier based on party type:
# Customer (TCS): +1 (add to debit)
# Supplier (TDS): -1 (subtract from credit)
direction = 1 if self.party_type == "Customer" else -1
# Reversal inverts the direction
if is_reversal:
direction = -direction
adjustment = amount * direction
adjustment_in_party_currency = amount_in_party_currency * direction
active_field_account_currency = f"{active_field}_in_account_currency"
self.party_row.update(
{
active_field: flt(self.party_row.get(active_field) + adjustment, self.precision),
active_field_account_currency: flt(
self.party_row.get(active_field_account_currency) + adjustment_in_party_currency,
self.precision,
),
}
)
def _calculate_net_total(self):
from erpnext.accounts.report.general_ledger.general_ledger import get_account_type_map
account_type_map = get_account_type_map(self.doc.company)
return flt(
sum(
d.get(self.reverse_field) - d.get(self.party_field)
for d in self.doc.get("accounts")
if account_type_map.get(d.account) not in ("Tax", "Chargeable")
and d.account != self.party_account
and not d.get("is_tax_withholding_account")
),
self.precision,
)
def _get_tds_details(self, net_total):
return get_party_tax_withholding_details(
frappe._dict(
{
"party_type": self.party_type,
"party": self.party,
"doctype": self.doc.doctype,
"company": self.doc.company,
"posting_date": self.doc.posting_date,
"tax_withholding_net_total": net_total,
"base_tax_withholding_net_total": net_total,
"grand_total": net_total,
}
),
self.doc.tax_withholding_category,
)
def _create_or_update_tds_row(self, tds_details):
tax_account = tds_details.get("account_head")
account_currency = get_account_currency(tax_account)
company_currency = frappe.get_cached_value("Company", self.doc.company, "default_currency")
exchange_rate = _get_exchange_rate(account_currency, company_currency, self.doc.posting_date)
tax_amount = flt(tds_details.get("tax_amount"), self.precision)
tax_amount_in_account_currency = flt(tax_amount / exchange_rate, self.precision)
# Find existing TDS row for this account
tax_row = None
for row in self.doc.get("accounts"):
if row.account == tax_account and row.get("is_tax_withholding_account"):
tax_row = row
break
if not tax_row:
tax_row = self.doc.append(
"accounts",
{
"account": tax_account,
"account_currency": account_currency,
"exchange_rate": exchange_rate,
"cost_center": tds_details.get("cost_center"),
"credit": 0,
"credit_in_account_currency": 0,
"debit": 0,
"debit_in_account_currency": 0,
"is_tax_withholding_account": 1,
},
)
# TDS/TCS is always credited (liability to government)
tax_row.update(
{
"credit": tax_amount,
"credit_in_account_currency": tax_amount_in_account_currency,
"debit": 0,
"debit_in_account_currency": 0,
}
)
self._cleanup_duplicate_tds_rows(tax_row)
def _cleanup_duplicate_tds_rows(self, current_tax_row):
rows_to_remove = [
row
for row in self.doc.get("accounts")
if row.get("is_tax_withholding_account") and row != current_tax_row
]
for row in rows_to_remove:
self.doc.remove(row)
def _recalculate_totals(self):
self.doc.set_amounts_in_company_currency()
self.doc.set_total_debit_credit()
self.doc.set_against_account()
@frappe.whitelist()
def get_default_bank_cash_account(company, account_type=None, mode_of_payment=None, account=None):
from erpnext.accounts.doctype.sales_invoice.sales_invoice import get_bank_cash_account
@@ -1649,8 +1788,6 @@ def get_exchange_rate(
credit=None,
exchange_rate=None,
):
from erpnext.setup.utils import get_exchange_rate
account_details = frappe.get_cached_value(
"Account", account, ["account_type", "root_type", "account_currency", "company"], as_dict=1
)
@@ -1672,8 +1809,8 @@ def get_exchange_rate(
# The date used to retreive the exchange rate here is the date passed
# in as an argument to this function.
elif (not exchange_rate or flt(exchange_rate) == 1) and account_currency and posting_date:
exchange_rate = get_exchange_rate(account_currency, company_currency, posting_date)
elif (not flt(exchange_rate) or flt(exchange_rate) == 1) and account_currency and posting_date:
exchange_rate = _get_exchange_rate(account_currency, company_currency, posting_date)
else:
exchange_rate = 1

View File

@@ -34,6 +34,7 @@
"reference_detail_no",
"advance_voucher_type",
"advance_voucher_no",
"is_tax_withholding_account",
"col_break3",
"is_advance",
"user_remark",
@@ -282,12 +283,19 @@
"options": "advance_voucher_type",
"read_only": 1,
"search_index": 1
},
{
"default": "0",
"fieldname": "is_tax_withholding_account",
"fieldtype": "Check",
"label": "Is Tax Withholding Account",
"read_only": 1
}
],
"idx": 1,
"istable": 1,
"links": [],
"modified": "2025-10-27 13:48:32.805100",
"modified": "2025-11-27 12:23:33.157655",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Journal Entry Account",

View File

@@ -28,6 +28,7 @@ class JournalEntryAccount(Document):
debit_in_account_currency: DF.Currency
exchange_rate: DF.Float
is_advance: DF.Literal["No", "Yes"]
is_tax_withholding_account: DF.Check
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data

View File

@@ -850,6 +850,7 @@ def update_payment_requests_as_per_pe_references(references=None, cancel=False):
)
referenced_payment_requests = {pr.name: pr for pr in referenced_payment_requests}
doc_updates = {}
for ref in references:
if not ref.payment_request:
@@ -875,7 +876,7 @@ def update_payment_requests_as_per_pe_references(references=None, cancel=False):
title=_("Invalid Allocated Amount"),
)
# update status
# determine status
if new_outstanding_amount == payment_request["grand_total"]:
status = "Initiated" if payment_request["payment_request_type"] == "Outward" else "Requested"
elif new_outstanding_amount == 0:
@@ -883,12 +884,15 @@ def update_payment_requests_as_per_pe_references(references=None, cancel=False):
elif new_outstanding_amount > 0:
status = "Partially Paid"
# update database
frappe.db.set_value(
"Payment Request",
ref.payment_request,
{"outstanding_amount": new_outstanding_amount, "status": status},
)
# prepare bulk update data
doc_updates[ref.payment_request] = {
"outstanding_amount": new_outstanding_amount,
"status": status,
}
# bulk update all payment requests
if doc_updates:
frappe.db.bulk_update("Payment Request", doc_updates)
def get_dummy_message(doc):

View File

@@ -126,8 +126,8 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
}
}
if (doc.outstanding_amount > 0 && !cint(doc.is_return) && !doc.on_hold) {
cur_frm.add_custom_button(
if (doc.docstatus == 1 && doc.outstanding_amount > 0 && !cint(doc.is_return) && !doc.on_hold) {
this.frm.add_custom_button(
__("Payment Request"),
function () {
me.make_payment_request();

View File

@@ -1349,7 +1349,11 @@ class SalesInvoice(SellingController):
)
for item in self.get("items"):
if flt(item.base_net_amount, item.precision("base_net_amount")) or item.is_fixed_asset:
if (
flt(item.base_net_amount, item.precision("base_net_amount"))
or item.is_fixed_asset
or enable_discount_accounting
):
# Do not book income for transfer within same company
if self.is_internal_transfer():
continue

View File

@@ -85,6 +85,9 @@ def get_party_details(inv):
if inv.doctype == "Sales Invoice":
party_type = "Customer"
party = inv.customer
elif inv.doctype == "Journal Entry":
party_type = inv.party_type
party = inv.party
else:
party_type = "Supplier"
party = inv.supplier
@@ -155,7 +158,7 @@ def get_party_tax_withholding_details(inv, tax_withholding_category=None):
party_type, parties, inv, tax_details, posting_date, pan_no
)
if party_type == "Supplier":
if party_type == "Supplier" or inv.doctype == "Journal Entry":
tax_row = get_tax_row_for_tds(tax_details, tax_amount)
else:
tax_row = get_tax_row_for_tcs(inv, tax_details, tax_amount, tax_deducted)
@@ -346,7 +349,10 @@ def get_tax_amount(party_type, parties, inv, tax_details, posting_date, pan_no=N
elif party_type == "Customer":
if tax_deducted:
# if already TCS is charged, then amount will be calculated based on 'Previous Row Total'
tax_amount = 0
if inv.doctype == "Sales Invoice":
tax_amount = 0
else:
tax_amount = inv.base_tax_withholding_net_total * tax_details.rate / 100
else:
# if no TCS has been charged in FY,
# then chargeable value is "prev invoices + advances - advance_adjusted" value which cross the threshold
@@ -718,7 +724,7 @@ def get_advance_adjusted_in_invoice(inv):
def get_invoice_total_without_tcs(inv, tax_details):
tcs_tax_row = [d for d in inv.taxes if d.account_head == tax_details.account_head]
tcs_tax_row = [d for d in inv.get("taxes") or [] if d.account_head == tax_details.account_head]
tcs_tax_row_amount = tcs_tax_row[0].base_tax_amount if tcs_tax_row else 0
return inv.grand_total - tcs_tax_row_amount

View File

@@ -848,6 +848,90 @@ class TestTaxWithholdingCategory(FrappeTestCase):
self.assertEqual(payment.taxes[0].tax_amount, 6000)
self.assertEqual(payment.taxes[0].allocated_amount, 6000)
def test_tds_on_journal_entry_for_supplier(self):
"""Test TDS deduction for Supplier in Debit Note"""
frappe.db.set_value(
"Supplier", "Test TDS Supplier", "tax_withholding_category", "Cumulative Threshold TDS"
)
jv = make_journal_entry_with_tax_withholding(
party_type="Supplier",
party="Test TDS Supplier",
voucher_type="Debit Note",
amount=50000,
save=False,
)
jv.apply_tds = 1
jv.tax_withholding_category = "Cumulative Threshold TDS"
jv.save()
# Again saving should not change tds amount
jv.user_remark = "Test TDS on Journal Entry for Supplier"
jv.save()
jv.submit()
# TDS = 50000 * 10% = 5000
self.assertEqual(len(jv.accounts), 3)
# Find TDS account row
tds_row = None
supplier_row = None
for row in jv.accounts:
if row.account == "TDS - _TC":
tds_row = row
elif row.party == "Test TDS Supplier":
supplier_row = row
self.assertEqual(tds_row.credit, 5000)
self.assertEqual(tds_row.debit, 0)
# Supplier amount should be reduced by TDS
self.assertEqual(supplier_row.credit, 45000)
jv.cancel()
def test_tcs_on_journal_entry_for_customer(self):
"""Test TCS collection for Customer in Credit Note"""
frappe.db.set_value(
"Customer", "Test TCS Customer", "tax_withholding_category", "Cumulative Threshold TCS"
)
# Create Credit Note with amount exceeding threshold
jv = make_journal_entry_with_tax_withholding(
party_type="Customer",
party="Test TCS Customer",
voucher_type="Credit Note",
amount=50000,
save=False,
)
jv.apply_tds = 1
jv.tax_withholding_category = "Cumulative Threshold TCS"
jv.save()
# Again saving should not change tds amount
jv.user_remark = "Test TCS on Journal Entry for Customer"
jv.save()
jv.submit()
# Assert TCS calculation (10% on amount above threshold of 30000)
self.assertEqual(len(jv.accounts), 3)
# Find TCS account row
tcs_row = None
customer_row = None
for row in jv.accounts:
if row.account == "TCS - _TC":
tcs_row = row
elif row.party == "Test TCS Customer":
customer_row = row
# TCS should be credited (liability to government)
self.assertEqual(tcs_row.credit, 2000) # above threshold 20000*10%
self.assertEqual(tcs_row.debit, 0)
# Customer amount should be increased by TCS
self.assertEqual(customer_row.debit, 52000)
jv.cancel()
def cancel_invoices():
purchase_invoices = frappe.get_all(
@@ -996,6 +1080,88 @@ def create_payment_entry(**args):
return pe
def make_journal_entry_with_tax_withholding(
party_type,
party,
voucher_type,
amount,
cost_center=None,
posting_date=None,
save=True,
submit=False,
):
"""Helper function to create Journal Entry for tax withholding"""
if not cost_center:
cost_center = "_Test Cost Center - _TC"
jv = frappe.new_doc("Journal Entry")
jv.posting_date = posting_date or today()
jv.company = "_Test Company"
jv.voucher_type = voucher_type
jv.multi_currency = 0
if party_type == "Supplier":
# Debit Note: Expense Dr, Supplier Cr
expense_account = "Stock Received But Not Billed - _TC"
party_account = "Creditors - _TC"
jv.append(
"accounts",
{
"account": expense_account,
"cost_center": cost_center,
"debit_in_account_currency": amount,
"exchange_rate": 1,
},
)
jv.append(
"accounts",
{
"account": party_account,
"party_type": party_type,
"party": party,
"cost_center": cost_center,
"credit_in_account_currency": amount,
"exchange_rate": 1,
},
)
else: # Customer
# Credit Note: Customer Dr, Income Cr
party_account = "Debtors - _TC"
income_account = "Sales - _TC"
jv.append(
"accounts",
{
"account": party_account,
"party_type": party_type,
"party": party,
"cost_center": cost_center,
"debit_in_account_currency": amount,
"exchange_rate": 1,
},
)
jv.append(
"accounts",
{
"account": income_account,
"cost_center": cost_center,
"credit_in_account_currency": amount,
"exchange_rate": 1,
},
)
if save or submit:
jv.insert()
if submit:
jv.submit()
return jv
def create_records():
# create a new suppliers
for name in [

View File

@@ -5,7 +5,6 @@
import frappe
from frappe import _
from frappe.utils import flt
from pypika import Order
import erpnext
from erpnext.accounts.report.item_wise_sales_register.item_wise_sales_register import (
@@ -16,7 +15,7 @@ from erpnext.accounts.report.item_wise_sales_register.item_wise_sales_register i
get_group_by_and_display_fields,
get_tax_accounts,
)
from erpnext.accounts.report.utils import get_query_columns, get_values_for_columns
from erpnext.accounts.report.utils import get_values_for_columns
def execute(filters=None):
@@ -41,16 +40,6 @@ def _execute(filters=None, additional_table_columns=None):
tax_doctype="Purchase Taxes and Charges",
)
scrubbed_tax_fields = {}
for tax in tax_columns:
scrubbed_tax_fields.update(
{
tax + " Rate": frappe.scrub(tax + " Rate"),
tax + " Amount": frappe.scrub(tax + " Amount"),
}
)
po_pr_map = get_purchase_receipts_against_purchase_order(item_list)
data = []
@@ -100,8 +89,8 @@ def _execute(filters=None, additional_table_columns=None):
item_tax = itemised_tax.get(d.name, {}).get(tax, {})
row.update(
{
scrubbed_tax_fields[tax + " Rate"]: item_tax.get("tax_rate", 0),
scrubbed_tax_fields[tax + " Amount"]: item_tax.get("tax_amount", 0),
f"{tax}_rate": item_tax.get("tax_rate", 0),
f"{tax}_amount": item_tax.get("tax_amount", 0),
}
)
total_tax += flt(item_tax.get("tax_amount"))

View File

@@ -6,7 +6,7 @@ import frappe
from frappe import _
from frappe.model.meta import get_field_precision
from frappe.query_builder import functions as fn
from frappe.utils import cstr, flt
from frappe.utils import flt
from frappe.utils.nestedset import get_descendants_of
from frappe.utils.xlsxutils import handle_html
@@ -32,16 +32,6 @@ def _execute(filters=None, additional_table_columns=None, additional_conditions=
if item_list:
itemised_tax, tax_columns = get_tax_accounts(item_list, columns, company_currency)
scrubbed_tax_fields = {}
for tax in tax_columns:
scrubbed_tax_fields.update(
{
tax + " Rate": frappe.scrub(tax + " Rate"),
tax + " Amount": frappe.scrub(tax + " Amount"),
}
)
mode_of_payments = get_mode_of_payments(set(d.parent for d in item_list))
so_dn_map = get_delivery_notes_against_sales_order(item_list)
@@ -102,8 +92,8 @@ def _execute(filters=None, additional_table_columns=None, additional_conditions=
item_tax = itemised_tax.get(d.name, {}).get(tax, {})
row.update(
{
scrubbed_tax_fields[tax + " Rate"]: item_tax.get("tax_rate", 0),
scrubbed_tax_fields[tax + " Amount"]: item_tax.get("tax_amount", 0),
f"{tax}_rate": item_tax.get("tax_rate", 0),
f"{tax}_amount": item_tax.get("tax_amount", 0),
}
)
if item_tax.get("is_other_charges"):
@@ -546,9 +536,10 @@ def get_tax_accounts(
import json
item_row_map = {}
tax_columns = []
tax_columns = {}
invoice_item_row = {}
itemised_tax = {}
scrubbed_description_map = {}
add_deduct_tax = "charge_type"
tax_amount_precision = (
@@ -605,9 +596,14 @@ def get_tax_accounts(
tax_amount,
) in tax_details:
description = handle_html(description)
if description not in tax_columns and tax_amount:
scrubbed_description = scrubbed_description_map.get(description)
if not scrubbed_description:
scrubbed_description = frappe.scrub(description)
scrubbed_description_map[description] = scrubbed_description
if scrubbed_description not in tax_columns and tax_amount:
# as description is text editor earlier and markup can break the column convention in reports
tax_columns.append(description)
tax_columns[scrubbed_description] = description
if item_wise_tax_detail:
try:
@@ -641,7 +637,7 @@ def get_tax_accounts(
else tax_value
)
itemised_tax.setdefault(d.name, {})[description] = frappe._dict(
itemised_tax.setdefault(d.name, {})[scrubbed_description] = frappe._dict(
{
"tax_rate": tax_rate,
"tax_amount": tax_value,
@@ -653,7 +649,7 @@ def get_tax_accounts(
continue
elif charge_type == "Actual" and tax_amount:
for d in invoice_item_row.get(parent, []):
itemised_tax.setdefault(d.name, {})[description] = frappe._dict(
itemised_tax.setdefault(d.name, {})[scrubbed_description] = frappe._dict(
{
"tax_rate": "NA",
"tax_amount": flt(
@@ -662,12 +658,14 @@ def get_tax_accounts(
}
)
tax_columns.sort()
for desc in tax_columns:
tax_columns_list = list(tax_columns.keys())
tax_columns_list.sort()
for scrubbed_desc in tax_columns_list:
desc = tax_columns[scrubbed_desc]
columns.append(
{
"label": _(desc + " Rate"),
"fieldname": frappe.scrub(desc + " Rate"),
"fieldname": f"{scrubbed_desc}_rate",
"fieldtype": "Float",
"width": 100,
}
@@ -676,7 +674,7 @@ def get_tax_accounts(
columns.append(
{
"label": _(desc + " Amount"),
"fieldname": frappe.scrub(desc + " Amount"),
"fieldname": f"{scrubbed_desc}_amount",
"fieldtype": "Currency",
"options": "currency",
"width": 100,
@@ -714,7 +712,7 @@ def get_tax_accounts(
},
]
return itemised_tax, tax_columns
return itemised_tax, tax_columns_list
def add_total_row(
@@ -807,5 +805,5 @@ def add_sub_total_row(item, total_row_map, group_by_value, tax_columns):
total_row["percent_gt"] += item["percent_gt"]
for tax in tax_columns:
total_row.setdefault(frappe.scrub(tax + " Amount"), 0.0)
total_row[frappe.scrub(tax + " Amount")] += flt(item[frappe.scrub(tax + " Amount")])
total_row.setdefault(f"{tax}_amount", 0.0)
total_row[f"{tax}_amount"] += flt(item[f"{tax}_amount"])

View File

@@ -537,6 +537,7 @@ def modify_depreciation_schedule_for_asset_repairs(asset, notes):
for repair in asset_repairs:
if repair.increase_in_asset_life:
asset_repair = frappe.get_doc("Asset Repair", repair.name)
asset_repair.asset_doc = asset
asset_repair.modify_depreciation_schedule()
make_new_active_asset_depr_schedules_and_cancel_current_ones(asset, notes)

View File

@@ -139,6 +139,7 @@ class AssetCapitalization(StockController):
self.make_gl_entries()
self.repost_future_sle_and_gle()
self.restore_consumed_asset_items()
self.update_target_asset()
def set_title(self):
self.title = self.target_asset_name or self.target_item_name or self.target_item_code
@@ -607,8 +608,12 @@ class AssetCapitalization(StockController):
total_target_asset_value = flt(self.total_value, self.precision("total_value"))
asset_doc = frappe.get_doc("Asset", self.target_asset)
asset_doc.gross_purchase_amount += total_target_asset_value
asset_doc.purchase_amount += total_target_asset_value
if self.docstatus == 2:
asset_doc.gross_purchase_amount -= total_target_asset_value
asset_doc.purchase_amount -= total_target_asset_value
else:
asset_doc.gross_purchase_amount += total_target_asset_value
asset_doc.purchase_amount += total_target_asset_value
asset_doc.set_status("Work In Progress")
asset_doc.flags.ignore_validate = True
asset_doc.save()

View File

@@ -86,6 +86,7 @@ status_map = {
["To Bill", "eval:self.per_billed < 100 and self.docstatus == 1"],
["Completed", "eval:self.per_billed == 100 and self.docstatus == 1"],
["Return Issued", "eval:self.per_returned == 100 and self.docstatus == 1"],
["Return", "eval:self.is_return == 1 and self.per_billed == 0 and self.docstatus == 1"],
["Cancelled", "eval:self.docstatus==2"],
["Closed", "eval:self.status=='Closed' and self.docstatus != 2"],
],

View File

@@ -7,6 +7,7 @@ import json
import frappe
from frappe import _, scrub
from frappe.model.document import Document
from frappe.query_builder import functions
from frappe.utils import cint, flt, round_based_on_smallest_currency_fraction
from frappe.utils.deprecations import deprecated
@@ -685,6 +686,22 @@ class calculate_taxes_and_totals:
discount_amount = self.doc.discount_amount or 0
grand_total = self.doc.grand_total
if self.doc.get("is_return") and self.doc.get("return_against"):
doctype = frappe.qb.DocType(self.doc.doctype)
result = (
frappe.qb.from_(doctype)
.select(functions.Sum(doctype.discount_amount).as_("total_return_discount"))
.where(
(doctype.return_against == self.doc.return_against)
& (doctype.is_return == 1)
& (doctype.docstatus == 1)
)
).run(as_dict=True)
total_return_discount = abs(result[0].get("total_return_discount") or 0)
discount_amount += total_return_discount
# validate that discount amount cannot exceed the total before discount
if (
(grand_total >= 0 and discount_amount > grand_total)

View File

@@ -389,10 +389,12 @@ frappe.ui.form.on("BOM", {
);
has_template_rm.forEach((d) => {
let bom_qty = dialog.fields_dict.qty?.value || 1;
dialog.fields_dict.items.df.data.push({
item_code: d.item_code,
variant_item_code: "",
qty: (d.qty / frm.doc.quantity) * (dialog.fields_dict.qty.value || 1),
qty: flt(d.qty / frm.doc.quantity) * flt(bom_qty),
source_warehouse: d.source_warehouse,
operation: d.operation,
});

View File

@@ -178,17 +178,12 @@ class JobCard(Document):
if job_card_qty and ((job_card_qty - completed_qty) > wo_qty):
form_link = get_link_to_form("Manufacturing Settings", "Manufacturing Settings")
msg = f"""
Qty To Manufacture in the job card
cannot be greater than Qty To Manufacture in the
work order for the operation {bold(self.operation)}.
<br><br><b>Solution: </b> Either you can reduce the
Qty To Manufacture in the job card or set the
'Overproduction Percentage For Work Order'
in the {form_link}."""
frappe.throw(_(msg), title=_("Extra Job Card Quantity"))
frappe.throw(
_(
"Qty To Manufacture in the job card cannot be greater than Qty To Manufacture in the work order for the operation {0}. <br><br><b>Solution: </b> Either you can reduce the Qty To Manufacture in the job card or set the 'Overproduction Percentage For Work Order' in the {1}."
).format(bold(self.operation), form_link),
title=_("Extra Job Card Quantity"),
)
def set_sub_operations(self):
if not self.sub_operations and self.operation:
@@ -1064,14 +1059,16 @@ class JobCard(Document):
)
if row.completed_qty < current_operation_qty:
msg = f"""The completed quantity {bold(current_operation_qty)}
of an operation {bold(self.operation)} cannot be greater
than the completed quantity {bold(row.completed_qty)}
of a previous operation
{bold(row.operation)}.
"""
frappe.throw(_(msg))
frappe.throw(
_(
"The completed quantity {0} of an operation {1} cannot be greater than the completed quantity {2} of a previous operation {3}."
).format(
bold(current_operation_qty),
bold(self.operation),
bold(row.completed_qty),
bold(row.operation),
)
)
def validate_work_order(self):
if self.is_work_order_closed():

View File

@@ -229,7 +229,8 @@ frappe.ui.form.on("Work Order", {
if (
frm.doc.docstatus === 1 &&
["Closed", "Completed"].includes(frm.doc.status) &&
frm.doc.produced_qty > 0
frm.doc.produced_qty > 0 &&
frm.doc.produced_qty > frm.doc.disassembled_qty
) {
frm.add_custom_button(
__("Disassemble Order"),
@@ -406,7 +407,6 @@ frappe.ui.form.on("Work Order", {
work_order_id: frm.doc.name,
purpose: "Disassemble",
qty: data.qty,
target_warehouse: data.target_warehouse,
});
})
.then((stock_entry) => {
@@ -863,24 +863,6 @@ erpnext.work_order = {
},
];
if (purpose === "Disassemble") {
fields.push({
fieldtype: "Link",
options: "Warehouse",
fieldname: "target_warehouse",
label: __("Target Warehouse"),
default: frm.doc.source_warehouse || frm.doc.wip_warehouse,
get_query() {
return {
filters: {
company: frm.doc.company,
is_group: 0,
},
};
},
});
}
return new Promise((resolve, reject) => {
frm.qty_prompt = frappe.prompt(
fields,

View File

@@ -1373,6 +1373,13 @@ def make_work_order(bom_no, item, qty=0, project=None, variant_items=None, use_m
item_details = get_item_details(item, project)
if frappe.db.get_value("Item", item, "variant_of"):
if variant_bom := frappe.db.get_value(
"BOM",
{"item": item, "is_default": 1, "docstatus": 1},
):
bom_no = variant_bom
wo_doc = frappe.new_doc("Work Order")
wo_doc.production_item = item
wo_doc.update(item_details)

View File

@@ -1093,6 +1093,14 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
this.frm.refresh_field("payment_schedule");
}
cost_center(doc) {
this.frm.doc.items.forEach((item) => {
item.cost_center = doc.cost_center;
});
this.frm.refresh_field("items");
}
due_date(doc, cdt, cdn) {
// due_date is to be changed, payment terms template and/or payment schedule must
// be removed as due_date is automatically changed based on payment terms

View File

@@ -1091,7 +1091,7 @@
"no_copy": 1,
"oldfieldname": "status",
"oldfieldtype": "Select",
"options": "\nDraft\nTo Bill\nCompleted\nReturn Issued\nCancelled\nClosed",
"options": "\nDraft\nTo Bill\nCompleted\nReturn\nReturn Issued\nCancelled\nClosed",
"print_hide": 1,
"print_width": "150px",
"read_only": 1,
@@ -1404,7 +1404,7 @@
"idx": 146,
"is_submittable": 1,
"links": [],
"modified": "2025-08-04 19:20:47.724218",
"modified": "2025-12-02 23:55:25.415443",
"modified_by": "Administrator",
"module": "Stock",
"name": "Delivery Note",

View File

@@ -126,7 +126,9 @@ class DeliveryNote(SellingController):
shipping_address_name: DF.Link | None
shipping_rule: DF.Link | None
source: DF.Link | None
status: DF.Literal["", "Draft", "To Bill", "Completed", "Return Issued", "Cancelled", "Closed"]
status: DF.Literal[
"", "Draft", "To Bill", "Completed", "Return", "Return Issued", "Cancelled", "Closed"
]
tax_category: DF.Link | None
tax_id: DF.Data | None
taxes: DF.Table[SalesTaxesandCharges]

View File

@@ -12,8 +12,8 @@ frappe.listview_settings["Delivery Note"] = {
"currency",
],
get_indicator: function (doc) {
if (cint(doc.is_return) == 1) {
return [__("Return"), "gray", "is_return,=,Yes"];
if (cint(doc.is_return) == 1 && doc.status == "Return") {
return [__("Return"), "gray", "is_return,=,1"];
} else if (doc.status === "Closed") {
return [__("Closed"), "green", "status,=,Closed"];
} else if (doc.status === "Return Issued") {

View File

@@ -2581,6 +2581,7 @@ class TestDeliveryNote(FrappeTestCase):
dn = make_delivery_note(so.name)
dn.submit()
self.assertEqual(dn.per_billed, 0)
self.assertEqual(dn.status, "To Bill")
si = make_sales_invoice(dn.name)
si.location = "Test Location"
@@ -2595,6 +2596,7 @@ class TestDeliveryNote(FrappeTestCase):
dn.load_from_db()
self.assertEqual(dn.per_billed, 100)
self.assertEqual(dn.per_returned, 100)
self.assertEqual(returned.status, "Return")
def test_sales_return_for_product_bundle(self):
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle

View File

@@ -224,7 +224,7 @@ frappe.ui.form.on("Item", {
["is_stock_item", "has_serial_no", "has_batch_no", "has_variants"].forEach((fieldname) => {
frm.set_df_property(fieldname, "read_only", stock_exists);
});
frm.set_df_property("is_fixed_asset", "read_only", frm.doc.__onload?.asset_exists ? 1 : 0);
frm.toggle_reqd("customer", frm.doc.is_customer_provided_item ? 1 : 0);
frm.set_query("item_group", () => {
return {

View File

@@ -243,8 +243,7 @@
"default": "0",
"fieldname": "is_fixed_asset",
"fieldtype": "Check",
"label": "Is Fixed Asset",
"set_only_once": 1
"label": "Is Fixed Asset"
},
{
"allow_in_quick_entry": 1,
@@ -895,7 +894,7 @@
"image_field": "image",
"links": [],
"make_attachments_public": 1,
"modified": "2025-08-08 14:58:48.674193",
"modified": "2025-12-04 09:11:56.029567",
"modified_by": "Administrator",
"module": "Stock",
"name": "Item",

View File

@@ -155,6 +155,7 @@ class Item(Document):
self.set_onload("stock_exists", self.stock_ledger_created())
self.set_onload("asset_naming_series", get_asset_naming_series())
self.set_onload("current_valuation_method", get_valuation_method(self.name))
self.set_onload("asset_exists", self.has_submitted_assets())
def autoname(self):
if frappe.db.get_default("item_naming_by") == "Naming Series":
@@ -306,9 +307,8 @@ class Item(Document):
if self.stock_ledger_created():
frappe.throw(_("Cannot be a fixed asset item as Stock Ledger is created."))
if not self.is_fixed_asset:
asset = frappe.db.get_all("Asset", filters={"item_code": self.name, "docstatus": 1}, limit=1)
if asset:
if not self.is_fixed_asset and not self.is_new():
if self.has_submitted_assets():
frappe.throw(
_('"Is Fixed Asset" cannot be unchecked, as Asset record exists against the item')
)
@@ -525,6 +525,9 @@ class Item(Document):
)
return self._stock_ledger_created
def has_submitted_assets(self):
return bool(frappe.db.exists("Asset", {"item_code": self.name, "docstatus": 1}))
def update_item_price(self):
frappe.db.sql(
"""

View File

@@ -743,7 +743,10 @@ class PickList(TransactionBase):
pi_item.serial_no,
(
Case()
.when((pi_item.picked_qty > 0) & (pi_item.docstatus == 1), pi_item.picked_qty)
.when(
(pi_item.picked_qty > 0) & (pi_item.docstatus == 1),
pi_item.picked_qty - pi_item.delivered_qty,
)
.else_(pi_item.stock_qty)
).as_("picked_qty"),
)

View File

@@ -645,6 +645,46 @@ class TestPickList(FrappeTestCase):
if dn_item.item_code == "_Test Item 2":
self.assertEqual(dn_item.qty, 2)
def test_picklist_reserved_qty_validation(self):
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
warehouse = "_Test Warehouse - _TC"
test_stock_item = "_Test Stock Item"
# Ensure stock item exists
if not frappe.db.exists("Item", test_stock_item):
create_item(
item_code=test_stock_item,
is_stock_item=1,
)
# Add initial stock qty
make_stock_entry(item_code=test_stock_item, to_warehouse=warehouse, qty=15)
# Create SO for 10 qty
sales_order_1 = make_sales_order(item_code=test_stock_item, warehouse=warehouse, qty=10)
# Create and Submit picklist for SO
picklist_1 = create_pick_list(sales_order_1.name)
picklist_1.submit()
# Create DN for 5 qty
dn = create_delivery_note(picklist_1.name)
dn.items[0].qty = 5
dn.save()
dn.submit()
# Verify partly delivered state
picklist_1.reload()
self.assertEqual(picklist_1.status, "Partly Delivered")
# Create another SO (10 qty)
sales_order_2 = make_sales_order(item_code=test_stock_item, warehouse=warehouse, qty=10)
# Expected pick qty = 5
picklist_2 = create_pick_list(sales_order_2.name)
self.assertEqual(picklist_2.locations[0].qty, 5)
def test_picklist_with_multi_uom(self):
warehouse = "_Test Warehouse - _TC"
item = make_item(properties={"uoms": [dict(uom="Box", conversion_factor=24)]}).name

View File

@@ -12,7 +12,7 @@ frappe.listview_settings["Purchase Receipt"] = {
],
get_indicator: function (doc) {
if (cint(doc.is_return) == 1 && doc.status == "Return") {
return [__("Return"), "gray", "is_return,=,Yes"];
return [__("Return"), "gray", "is_return,=,1"];
} else if (doc.status === "Closed") {
return [__("Closed"), "green", "status,=,Closed"];
} else if (flt(doc.per_returned, 2) === 100) {

View File

@@ -4448,6 +4448,87 @@ class TestPurchaseReceipt(FrappeTestCase):
self.assertEqual(srbnb_cost, 1000)
def test_lcv_for_repack_entry(self):
from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import (
create_landed_cost_voucher,
)
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
for item in [
"Potatoes Raw Material Item",
"Fries Finished Goods Item",
]:
create_item(item)
pr = make_purchase_receipt(
item_code="Potatoes Raw Material Item",
warehouse="_Test Warehouse - _TC",
qty=100,
rate=50,
)
wh1 = create_warehouse("WH A1", company=pr.company)
wh2 = create_warehouse("WH A2", company=pr.company)
ste = make_stock_entry(
purpose="Repack",
source="_Test Warehouse - _TC",
item_code="Potatoes Raw Material Item",
qty=100,
company=pr.company,
do_not_save=1,
)
ste.append(
"items",
{
"item_code": "Fries Finished Goods Item",
"qty": 50,
"t_warehouse": wh1,
},
)
ste.append(
"items",
{
"item_code": "Fries Finished Goods Item",
"qty": 50,
"t_warehouse": wh2,
},
)
ste.insert()
ste.submit()
ste.reload()
for row in ste.items:
if row.t_warehouse:
self.assertEqual(row.valuation_rate, 50)
sles = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_type": ste.doctype, "voucher_no": ste.name, "actual_qty": (">", 0)},
pluck="stock_value_difference",
)
self.assertEqual(sles, [2500.0, 2500.0])
create_landed_cost_voucher("Purchase Receipt", pr.name, pr.company, charges=2000 * -1)
ste.reload()
for row in ste.items:
if row.t_warehouse:
self.assertEqual(row.valuation_rate, 30)
sles = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_type": ste.doctype, "voucher_no": ste.name, "actual_qty": (">", 0)},
pluck="stock_value_difference",
)
self.assertEqual(sles, [1500.0, 1500.0])
def prepare_data_for_internal_transfer():
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier

View File

@@ -9,7 +9,7 @@ frappe.ui.form.on("Quality Inspection", {
},
set_default_company(frm) {
if (!frm.doc.company) {
if (frm.doc.docstatus === 0 && !frm.doc.company) {
frm.set_value("company", frappe.defaults.get_default("company"));
}
},

View File

@@ -2532,6 +2532,9 @@ def get_voucher_wise_serial_batch_from_bundle(**kwargs) -> dict[str, dict]:
child_row = group_by_voucher[key]
if row.serial_no:
child_row["serial_nos"].append(row.serial_no)
child_row["item_row"].qty = len(child_row["serial_nos"]) * (
-1 if row.type_of_transaction == "Outward" else 1
)
if row.batch_no:
child_row["batch_nos"][row.batch_no] += row.qty

View File

@@ -571,6 +571,14 @@ frappe.ui.form.on("Stock Entry", {
}
},
set_rate_and_fg_qty: function (frm, cdt, cdn) {
frm.events.set_basic_rate(frm, cdt, cdn);
let item = frappe.get_doc(cdt, cdn);
if (item.is_finished_item) {
frm.events.set_fg_completed_qty(frm);
}
},
get_warehouse_details: function (frm, cdt, cdn) {
var child = locals[cdt][cdn];
if (!child.bom_no) {
@@ -833,7 +841,7 @@ frappe.ui.form.on("Stock Entry", {
frm.doc.items.forEach((item) => {
if (item.is_finished_item) {
fg_completed_qty += flt(item.qty);
fg_completed_qty += flt(item.transfer_qty);
}
});
@@ -859,15 +867,11 @@ frappe.ui.form.on("Stock Entry Detail", {
},
qty(frm, cdt, cdn) {
frm.events.set_basic_rate(frm, cdt, cdn);
let item = frappe.get_doc(cdt, cdn);
if (item.is_finished_item) {
frm.events.set_fg_completed_qty(frm);
}
frm.events.set_rate_and_fg_qty(frm, cdt, cdn);
},
conversion_factor(frm, cdt, cdn) {
frm.events.set_basic_rate(frm, cdt, cdn);
frm.events.set_rate_and_fg_qty(frm, cdt, cdn);
},
s_warehouse(frm, cdt, cdn) {

View File

@@ -243,7 +243,66 @@ class StockEntry(StockController):
self.validate_same_source_target_warehouse_during_material_transfer()
def set_serial_batch_for_disassembly(self):
if self.purpose != "Disassemble":
return
available_materials = get_available_materials(self.work_order, self)
for row in self.items:
warehouse = row.s_warehouse or row.t_warehouse
materials = available_materials.get((row.item_code, warehouse))
if not materials:
continue
batches = defaultdict(float)
serial_nos = []
qty = row.transfer_qty
for batch_no, batch_qty in materials.batch_details.items():
if qty <= 0:
break
batch_qty = abs(batch_qty)
if batch_qty <= qty:
batches[batch_no] = batch_qty
qty -= batch_qty
else:
batches[batch_no] = qty
qty = 0
if materials.serial_nos:
serial_nos = materials.serial_nos[: int(row.transfer_qty)]
if not serial_nos and not batches:
continue
bundle_doc = SerialBatchCreation(
{
"item_code": row.item_code,
"warehouse": warehouse,
"posting_date": self.posting_date,
"posting_time": self.posting_time,
"voucher_type": self.doctype,
"voucher_no": self.name,
"voucher_detail_no": row.name,
"qty": row.transfer_qty,
"type_of_transaction": "Inward" if row.t_warehouse else "Outward",
"company": self.company,
"do_not_submit": True,
}
).make_serial_and_batch_bundle(serial_nos=serial_nos, batch_nos=batches)
row.serial_and_batch_bundle = bundle_doc.name
row.use_serial_batch_fields = 0
row.db_set(
{
"serial_and_batch_bundle": bundle_doc.name,
"use_serial_batch_fields": 0,
}
)
def on_submit(self):
self.set_serial_batch_for_disassembly()
self.validate_closed_subcontracting_order()
self.make_bundle_using_old_serial_batch_fields()
self.update_disassembled_order()
@@ -1856,7 +1915,13 @@ class StockEntry(StockController):
s_warehouse = frappe.db.get_value("Work Order", self.work_order, "fg_warehouse")
items_dict = get_bom_items_as_dict(self.bom_no, self.company, disassemble_qty)
items_dict = get_bom_items_as_dict(
self.bom_no,
self.company,
disassemble_qty,
fetch_exploded=self.use_multi_level_bom,
fetch_qty_in_stock_uom=False,
)
for row in items:
child_row = self.append("items", {})
@@ -1874,7 +1939,7 @@ class StockEntry(StockController):
child_row.qty = disassemble_qty
child_row.s_warehouse = (self.from_warehouse or s_warehouse) if row.is_finished_item else ""
child_row.t_warehouse = self.to_warehouse if not row.is_finished_item else ""
child_row.t_warehouse = row.s_warehouse
child_row.is_finished_item = 0 if row.is_finished_item else 1
def get_items_from_manufacture_entry(self):
@@ -1893,6 +1958,8 @@ class StockEntry(StockController):
"`tabStock Entry Detail`.`is_finished_item`",
"`tabStock Entry Detail`.`batch_no`",
"`tabStock Entry Detail`.`serial_no`",
"`tabStock Entry Detail`.`s_warehouse`",
"`tabStock Entry Detail`.`t_warehouse`",
"`tabStock Entry Detail`.`use_serial_batch_fields`",
],
filters=[
@@ -3259,8 +3326,8 @@ def get_items_from_subcontract_order(source_name, target_doc=None):
return target_doc
def get_available_materials(work_order) -> dict:
data = get_stock_entry_data(work_order)
def get_available_materials(work_order, stock_entry_doc=None) -> dict:
data = get_stock_entry_data(work_order, stock_entry_doc=stock_entry_doc)
available_materials = {}
for row in data:
@@ -3268,6 +3335,9 @@ def get_available_materials(work_order) -> dict:
if row.purpose != "Material Transfer for Manufacture":
key = (row.item_code, row.s_warehouse)
if stock_entry_doc and stock_entry_doc.purpose == "Disassemble":
key = (row.item_code, row.s_warehouse or row.warehouse)
if key not in available_materials:
available_materials.setdefault(
key,
@@ -3278,7 +3348,9 @@ def get_available_materials(work_order) -> dict:
item_data = available_materials[key]
if row.purpose == "Material Transfer for Manufacture":
if row.purpose == "Material Transfer for Manufacture" or (
stock_entry_doc and stock_entry_doc.purpose == "Disassemble" and row.purpose == "Manufacture"
):
item_data.qty += row.qty
if row.batch_no:
item_data.batch_details[row.batch_no] += row.qty
@@ -3318,7 +3390,7 @@ def get_available_materials(work_order) -> dict:
return available_materials
def get_stock_entry_data(work_order):
def get_stock_entry_data(work_order, stock_entry_doc=None):
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
get_voucher_wise_serial_batch_from_bundle,
)
@@ -3350,19 +3422,35 @@ def get_stock_entry_data(work_order):
(stock_entry.name == stock_entry_detail.parent)
& (stock_entry.work_order == work_order)
& (stock_entry.docstatus == 1)
& (stock_entry_detail.s_warehouse.isnotnull())
& (
stock_entry.purpose.isin(
[
"Manufacture",
"Material Consumption for Manufacture",
"Material Transfer for Manufacture",
]
)
)
)
.orderby(stock_entry.creation, stock_entry_detail.item_code, stock_entry_detail.idx)
).run(as_dict=1)
)
if stock_entry_doc and stock_entry_doc.purpose == "Disassemble":
data = data.where(
stock_entry.purpose.isin(
[
"Disassemble",
"Manufacture",
]
)
)
data = data.where(stock_entry.name != stock_entry_doc.name)
else:
data = data.where(
stock_entry.purpose.isin(
[
"Manufacture",
"Material Consumption for Manufacture",
"Material Transfer for Manufacture",
]
)
)
data = data.where(stock_entry_detail.s_warehouse.isnotnull())
data = data.run(as_dict=1)
if not data:
return []
@@ -3375,6 +3463,9 @@ def get_stock_entry_data(work_order):
if row.purpose != "Material Transfer for Manufacture":
key = (row.item_code, row.s_warehouse, row.name)
if stock_entry_doc and stock_entry_doc.purpose == "Disassemble":
key = (row.item_code, row.s_warehouse or row.warehouse, row.name)
if bundle_data.get(key):
row.update(bundle_data.get(key))

View File

@@ -647,10 +647,32 @@ class update_entries_after:
if sle.dependant_sle_voucher_detail_no:
entries_to_fix = self.get_dependent_entries_to_fix(entries_to_fix, sle)
if sle.voucher_type == "Stock Entry" and is_repack_entry(sle.voucher_no):
# for repack entries, we need to repost both source and target warehouses
self.update_distinct_item_warehouses_for_repack(sle)
if self.exceptions:
self.raise_exceptions()
def update_distinct_item_warehouses_for_repack(self, sle):
sles = (
frappe.get_all(
"Stock Ledger Entry",
filters={
"voucher_type": "Stock Entry",
"voucher_no": sle.voucher_no,
"actual_qty": (">", 0),
"is_cancelled": 0,
"voucher_detail_no": ("!=", sle.dependant_sle_voucher_detail_no),
},
fields=["*"],
)
or []
)
for dependant_sle in sles:
self.update_distinct_item_warehouses(dependant_sle)
def has_stock_reco_with_serial_batch(self, sle):
if (
sle.voucher_type == "Stock Reconciliation"
@@ -696,6 +718,13 @@ class update_entries_after:
{"item_code": self.item_code, "warehouse": self.args.warehouse}
)
key = (self.item_code, self.args.warehouse)
if key in self.distinct_item_warehouses and self.distinct_item_warehouses[key].get(
"transfer_entry_to_repost"
):
# only repost stock entries
args["filter_voucher_type"] = "Stock Entry"
return list(self.get_sle_after_datetime(args))
def get_dependent_entries_to_fix(self, entries_to_fix, sle):
@@ -729,8 +758,10 @@ class update_entries_after:
if getdate(existing_sle.get("posting_date")) > getdate(dependant_sle.posting_date):
self.distinct_item_warehouses[key] = val
self.new_items_found = True
elif dependant_sle.voucher_type == "Stock Entry" and is_transfer_stock_entry(
dependant_sle.voucher_no
elif (
dependant_sle.actual_qty > 0
and dependant_sle.voucher_type == "Stock Entry"
and is_transfer_stock_entry(dependant_sle.voucher_no)
):
if self.distinct_item_warehouses[key].get("transfer_entry_to_repost"):
return
@@ -1156,7 +1187,11 @@ class update_entries_after:
def get_dynamic_incoming_outgoing_rate(self, sle):
# Get updated incoming/outgoing rate from transaction
if sle.recalculate_rate or self.has_landed_cost_based_on_pi(sle):
if (
sle.recalculate_rate
or self.has_landed_cost_based_on_pi(sle)
or (sle.voucher_type == "Stock Entry" and sle.actual_qty > 0 and is_repack_entry(sle.voucher_no))
):
rate = self.get_incoming_outgoing_rate_from_transaction(sle)
if flt(sle.actual_qty) >= 0:
@@ -1813,6 +1848,9 @@ def get_stock_ledger_entries(
if operator in (">", "<=") and previous_sle.get("name"):
conditions += " and name!=%(name)s"
if previous_sle.get("filter_voucher_type"):
conditions += " and voucher_type = %(filter_voucher_type)s"
if extra_cond:
conditions += f"{extra_cond}"
@@ -1908,8 +1946,7 @@ def get_valuation_rate(
& (table.warehouse == warehouse)
& (table.batch_no == batch_no)
& (table.is_cancelled == 0)
& (table.voucher_no != voucher_no)
& (table.voucher_type != voucher_type)
& ((table.voucher_no != voucher_no) | (table.voucher_type != voucher_type))
)
)
@@ -2454,3 +2491,8 @@ def get_incoming_rate_for_serial_and_batch(item_code, row, sn_obj):
incoming_rate = abs(flt(sn_obj.batch_avg_rate.get(row.batch_no)))
return incoming_rate
@frappe.request_cache
def is_repack_entry(stock_entry_id):
return frappe.get_cached_value("Stock Entry", stock_entry_id, "purpose") == "Repack"

View File

@@ -16,9 +16,30 @@ def transaction_processing(data, from_doctype, to_doctype):
else:
deserialized_data = data
skipped_records = [d for d in deserialized_data if d.get("status") in ("On Hold", "Closed")]
deserialized_data = [d for d in deserialized_data if d.get("status") not in ("On Hold", "Closed")]
length_of_data = len(deserialized_data)
frappe.msgprint(_("Started a background job to create {1} {0}").format(to_doctype, length_of_data))
skipped_msg = ""
if skipped_records:
skipped_msg = _("{0} creation for the following records will be skipped.").format(to_doctype)
skipped_msg += (
"<br><br><ul>"
+ "".join(_("<li>{}</li>").format(frappe.bold(row.get("name"))) for row in skipped_records)
+ "</ul>"
)
if not length_of_data:
frappe.msgprint(skipped_msg)
return
frappe.msgprint(
_("Started a background job to create {1} {0}. {2}").format(to_doctype, length_of_data, skipped_msg)
)
frappe.enqueue(
job,
deserialized_data=deserialized_data,