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

chore: release v15
This commit is contained in:
ruthra kumar
2025-01-22 09:01:34 +05:30
committed by GitHub
85 changed files with 112081 additions and 183 deletions

View File

@@ -0,0 +1,34 @@
import json
from pathlib import Path
syscohada_countries = [
"bj", # Bénin
"bf", # Burkina-Faso
"cm", # Cameroun
"cf", # Centrafrique
"ci", # Côte d'Ivoire
"cg", # Congo
"km", # Comores
"ga", # Gabon
"gn", # Guinée
"gw", # Guinée-Bissau
"gq", # Guinée Equatoriale
"ml", # Mali
"ne", # Niger
"cd", # République Démocratique du Congo
"sn", # Sénégal
"td", # Tchad
"tg", # Togo
]
folder = Path(__file__).parent
generic_charts = Path(folder).glob("syscohada*.json")
for file in generic_charts:
with open(file) as f:
chart = json.load(f)
for country in syscohada_countries:
chart["country_code"] = country
json_object = json.dumps(chart, indent=4)
with open(Path(folder, file.name.replace("syscohada", country)), "w") as outfile:
outfile.write(json_object)

View File

@@ -12,7 +12,7 @@ frappe.ui.form.on("Accounts Settings", {
msg += " "; msg += " ";
msg += __("Please enable only if the understand the effects of enabling this."); msg += __("Please enable only if the understand the effects of enabling this.");
msg += "<br>"; msg += "<br>";
msg += "Do you still want to enable immutable ledger?"; msg += __("Do you still want to enable immutable ledger?");
frappe.confirm( frappe.confirm(
msg, msg,

View File

@@ -76,6 +76,7 @@
"reports_tab", "reports_tab",
"remarks_section", "remarks_section",
"general_ledger_remarks_length", "general_ledger_remarks_length",
"ignore_is_opening_check_for_reporting",
"column_break_lvjk", "column_break_lvjk",
"receivable_payable_remarks_length", "receivable_payable_remarks_length",
"payment_request_settings", "payment_request_settings",
@@ -515,6 +516,13 @@
"fieldname": "reconciliation_queue_size", "fieldname": "reconciliation_queue_size",
"fieldtype": "Int", "fieldtype": "Int",
"label": "Reconciliation Queue Size" "label": "Reconciliation Queue Size"
},
{
"default": "0",
"description": "Ignores legacy Is Opening field in GL Entry that allows adding opening balance post the system is in use while generating reports",
"fieldname": "ignore_is_opening_check_for_reporting",
"fieldtype": "Check",
"label": "Ignore Is Opening check for reporting"
} }
], ],
"icon": "icon-cog", "icon": "icon-cog",
@@ -522,7 +530,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2025-01-13 17:38:39.661320", "modified": "2025-01-18 21:24:19.840745",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Accounts Settings", "name": "Accounts Settings",

View File

@@ -48,6 +48,7 @@ class AccountsSettings(Document):
frozen_accounts_modifier: DF.Link | None frozen_accounts_modifier: DF.Link | None
general_ledger_remarks_length: DF.Int general_ledger_remarks_length: DF.Int
ignore_account_closing_balance: DF.Check ignore_account_closing_balance: DF.Check
ignore_is_opening_check_for_reporting: DF.Check
make_payment_via_journal_entry: DF.Check make_payment_via_journal_entry: DF.Check
merge_similar_account_heads: DF.Check merge_similar_account_heads: DF.Check
over_billing_allowance: DF.Currency over_billing_allowance: DF.Currency

View File

@@ -114,10 +114,10 @@ class Subscription(Document):
if self.trial_period_end and getdate(self.trial_period_end) > getdate(self.start_date): if self.trial_period_end and getdate(self.trial_period_end) > getdate(self.start_date):
_current_invoice_start = add_days(self.trial_period_end, 1) _current_invoice_start = add_days(self.trial_period_end, 1)
elif self.trial_period_start and self.is_trialling():
_current_invoice_start = self.trial_period_start
elif date: elif date:
_current_invoice_start = date _current_invoice_start = date
elif self.trial_period_start and self.is_trialling():
_current_invoice_start = self.trial_period_start
else: else:
_current_invoice_start = nowdate() _current_invoice_start = nowdate()
@@ -414,8 +414,8 @@ class Subscription(Document):
if frappe.db.get_value("Supplier", self.party, "tax_withholding_category"): if frappe.db.get_value("Supplier", self.party, "tax_withholding_category"):
invoice.apply_tds = 1 invoice.apply_tds = 1
# Add party currency to invoice # Add currency to invoice
invoice.currency = get_party_account_currency(self.party_type, self.party, self.company) invoice.currency = frappe.db.get_value("Subscription Plan", {"name": self.plans[0].plan}, "currency")
# Add dimensions in invoice for subscription: # Add dimensions in invoice for subscription:
accounting_dimensions = get_accounting_dimensions() accounting_dimensions = get_accounting_dimensions()

View File

@@ -3,7 +3,7 @@
import frappe import frappe
from frappe.tests.utils import FrappeTestCase from frappe.tests.utils import FrappeTestCase, change_settings
from frappe.utils.data import ( from frappe.utils.data import (
add_days, add_days,
add_months, add_months,
@@ -470,6 +470,28 @@ class TestSubscription(FrappeTestCase):
currency = frappe.db.get_value("Sales Invoice", subscription.invoices[0].name, "currency") currency = frappe.db.get_value("Sales Invoice", subscription.invoices[0].name, "currency")
self.assertEqual(currency, "USD") self.assertEqual(currency, "USD")
@change_settings(
"Accounts Settings",
{"allow_multi_currency_invoices_against_single_party_account": 1},
)
def test_multi_currency_subscription_with_default_company_currency(self):
party = "Test Subscription Customer Multi Currency"
frappe.db.set_value("Customer", party, "default_currency", "USD")
subscription = create_subscription(
start_date="2018-01-01",
generate_invoice_at="Beginning of the current subscription period",
plans=[{"plan": "_Test Plan Multicurrency", "qty": 1, "currency": "USD"}],
party=party,
)
subscription.process(posting_date="2018-01-01")
self.assertEqual(len(subscription.invoices), 1)
self.assertEqual(subscription.status, "Unpaid")
# Check the currency of the created invoice
currency = frappe.db.get_value("Sales Invoice", subscription.invoices[0].name, "currency")
self.assertEqual(currency, "USD")
def test_subscription_recovery(self): def test_subscription_recovery(self):
"""Test if Subscription recovers when start/end date run out of sync with created invoices.""" """Test if Subscription recovers when start/end date run out of sync with created invoices."""
subscription = create_subscription( subscription = create_subscription(
@@ -581,6 +603,12 @@ def create_parties():
customer.append("accounts", {"company": "_Test Company", "account": "_Test Receivable USD - _TC"}) customer.append("accounts", {"company": "_Test Company", "account": "_Test Receivable USD - _TC"})
customer.insert() customer.insert()
if not frappe.db.exists("Customer", "_Test Subscription Customer Multi Currency"):
customer = frappe.new_doc("Customer")
customer.customer_name = "Test Subscription Customer Multi Currency"
customer.default_currency = "USD"
customer.insert()
if not frappe.db.exists("Customer", "_Test Subscription Customer John Doe"): if not frappe.db.exists("Customer", "_Test Subscription Customer John Doe"):
customer = frappe.new_doc("Customer") customer = frappe.new_doc("Customer")
customer.customer_name = "_Test Subscription Customer John Doe" customer.customer_name = "_Test Subscription Customer John Doe"

View File

@@ -156,6 +156,9 @@ def get_party_tax_withholding_details(inv, tax_withholding_category=None):
} }
) )
if cint(tax_details.round_off_tax_amount):
inv.round_off_applicable_accounts_for_tax_withholding = tax_details.account_head
if inv.doctype == "Purchase Invoice": if inv.doctype == "Purchase Invoice":
return tax_row, tax_deducted_on_advances, voucher_wise_amount return tax_row, tax_deducted_on_advances, voucher_wise_amount
else: else:
@@ -302,6 +305,10 @@ def get_tax_amount(party_type, parties, inv, tax_details, posting_date, pan_no=N
tax_amount = 0 tax_amount = 0
if party_type == "Supplier": if party_type == "Supplier":
# if tds account is changed.
if not tax_deducted:
tax_deducted = is_tax_deducted_on_the_basis_of_inv(vouchers)
ldc = get_lower_deduction_certificate(inv.company, posting_date, tax_details, pan_no) ldc = get_lower_deduction_certificate(inv.company, posting_date, tax_details, pan_no)
if tax_deducted: if tax_deducted:
net_total = inv.tax_withholding_net_total net_total = inv.tax_withholding_net_total
@@ -336,6 +343,18 @@ def get_tax_amount(party_type, parties, inv, tax_details, posting_date, pan_no=N
return tax_amount, tax_deducted, tax_deducted_on_advances, voucher_wise_amount return tax_amount, tax_deducted, tax_deducted_on_advances, voucher_wise_amount
def is_tax_deducted_on_the_basis_of_inv(vouchers):
return frappe.db.exists(
"Purchase Taxes and Charges",
{
"parent": ["in", vouchers],
"is_tax_withholding_account": 1,
"parenttype": "Purchase Invoice",
"base_tax_amount_after_discount_amount": [">", 0],
},
)
def get_invoice_vouchers(parties, tax_details, company, party_type="Supplier"): def get_invoice_vouchers(parties, tax_details, company, party_type="Supplier"):
doctype = "Purchase Invoice" if party_type == "Supplier" else "Sales Invoice" doctype = "Purchase Invoice" if party_type == "Supplier" else "Sales Invoice"
field = ( field = (

View File

@@ -61,6 +61,49 @@ class TestTaxWithholdingCategory(FrappeTestCase):
for d in reversed(invoices): for d in reversed(invoices):
d.cancel() d.cancel()
def test_tds_with_account_changed(self):
frappe.db.set_value(
"Supplier", "Test TDS Supplier", "tax_withholding_category", "Multi Account TDS Category"
)
invoices = []
# create invoices for lower than single threshold tax rate
for _ in range(2):
pi = create_purchase_invoice(supplier="Test TDS Supplier")
pi.submit()
invoices.append(pi)
# create another invoice whose total when added to previously created invoice,
# surpasses cumulative threshhold
pi = create_purchase_invoice(supplier="Test TDS Supplier")
pi.submit()
# assert equal tax deduction on total invoice amount until now
self.assertEqual(pi.taxes_and_charges_deducted, 3000)
self.assertEqual(pi.grand_total, 7000)
invoices.append(pi)
# account changed
frappe.db.set_value(
"Tax Withholding Account",
{"parent": "Multi Account TDS Category"},
"account",
"_Test Account VAT - _TC",
)
# TDS should be on invoice only even though account is changed
pi = create_purchase_invoice(supplier="Test TDS Supplier", rate=5000)
pi.submit()
# assert equal tax deduction on total invoice amount until now
self.assertEqual(pi.taxes_and_charges_deducted, 500)
invoices.append(pi)
# delete invoices to avoid clashing
for d in reversed(invoices):
d.cancel()
def test_single_threshold_tds(self): def test_single_threshold_tds(self):
invoices = [] invoices = []
frappe.db.set_value( frappe.db.set_value(
@@ -1061,6 +1104,16 @@ def create_tax_withholding_category_records():
consider_party_ledger_amount=1, consider_party_ledger_amount=1,
) )
create_tax_withholding_category(
category_name="Multi Account TDS Category",
rate=10,
from_date=from_date,
to_date=to_date,
account="TDS - _TC",
single_threshold=0,
cumulative_threshold=30000,
)
def create_tax_withholding_category( def create_tax_withholding_category(
category_name, category_name,

View File

@@ -676,7 +676,7 @@ def set_taxes(
): ):
from erpnext.accounts.doctype.tax_rule.tax_rule import get_party_details, get_tax_template from erpnext.accounts.doctype.tax_rule.tax_rule import get_party_details, get_tax_template
args = {party_type.lower(): party, "company": company} args = {frappe.scrub(party_type): party, "company": company}
if tax_category: if tax_category:
args["tax_category"] = tax_category args["tax_category"] = tax_category
@@ -696,10 +696,10 @@ def set_taxes(
else: else:
args.update(get_party_details(party, party_type)) args.update(get_party_details(party, party_type))
if party_type in ("Customer", "Lead", "Prospect"): if party_type in ("Customer", "Lead", "Prospect", "CRM Deal"):
args.update({"tax_type": "Sales"}) args.update({"tax_type": "Sales"})
if party_type in ["Lead", "Prospect"]: if party_type in ["Lead", "Prospect", "CRM Deal"]:
args["customer"] = None args["customer"] = None
del args[frappe.scrub(party_type)] del args[frappe.scrub(party_type)]
else: else:

View File

@@ -510,12 +510,16 @@ def get_accounting_entries(
.where(gl_entry.company == filters.company) .where(gl_entry.company == filters.company)
) )
ignore_is_opening = frappe.db.get_single_value(
"Accounts Settings", "ignore_is_opening_check_for_reporting"
)
if doctype == "GL Entry": if doctype == "GL Entry":
query = query.select(gl_entry.posting_date, gl_entry.is_opening, gl_entry.fiscal_year) query = query.select(gl_entry.posting_date, gl_entry.is_opening, gl_entry.fiscal_year)
query = query.where(gl_entry.is_cancelled == 0) query = query.where(gl_entry.is_cancelled == 0)
query = query.where(gl_entry.posting_date <= to_date) query = query.where(gl_entry.posting_date <= to_date)
if ignore_opening_entries: if ignore_opening_entries and not ignore_is_opening:
query = query.where(gl_entry.is_opening == "No") query = query.where(gl_entry.is_opening == "No")
else: else:
query = query.select(gl_entry.closing_date.as_("posting_date")) query = query.select(gl_entry.closing_date.as_("posting_date"))

View File

@@ -208,6 +208,10 @@ def get_gl_entries(filters, accounting_dimensions):
def get_conditions(filters): def get_conditions(filters):
conditions = [] conditions = []
ignore_is_opening = frappe.db.get_single_value(
"Accounts Settings", "ignore_is_opening_check_for_reporting"
)
if filters.get("account"): if filters.get("account"):
filters.account = get_accounts_with_children(filters.account) filters.account = get_accounts_with_children(filters.account)
if filters.account: if filters.account:
@@ -270,9 +274,15 @@ def get_conditions(filters):
or filters.get("party") or filters.get("party")
or filters.get("group_by") in ["Group by Account", "Group by Party"] or filters.get("group_by") in ["Group by Account", "Group by Party"]
): ):
conditions.append("(posting_date >=%(from_date)s or is_opening = 'Yes')") if not ignore_is_opening:
conditions.append("(posting_date >=%(from_date)s or is_opening = 'Yes')")
else:
conditions.append("posting_date >=%(from_date)s")
conditions.append("(posting_date <=%(to_date)s or is_opening = 'Yes')") if not ignore_is_opening:
conditions.append("(posting_date <=%(to_date)s or is_opening = 'Yes')")
else:
conditions.append("posting_date <=%(to_date)s")
if filters.get("project"): if filters.get("project"):
conditions.append("project in %(project)s") conditions.append("project in %(project)s")

View File

@@ -14,14 +14,14 @@
"owner": "Administrator", "owner": "Administrator",
"ref_doctype": "GL Entry", "ref_doctype": "GL Entry",
"report_name": "Trial Balance", "report_name": "Trial Balance",
"report_type": "Script Report", "report_type": "Script Report",
"roles": [ "roles": [
{ {
"role": "Accounts User" "role": "Accounts User"
}, },
{ {
"role": "Accounts Manager" "role": "Accounts Manager"
}, },
{ {
"role": "Auditor" "role": "Auditor"
} }

View File

@@ -89,6 +89,10 @@ def get_data(filters):
) )
company_currency = filters.presentation_currency or erpnext.get_company_currency(filters.company) company_currency = filters.presentation_currency or erpnext.get_company_currency(filters.company)
ignore_is_opening = frappe.db.get_single_value(
"Accounts Settings", "ignore_is_opening_check_for_reporting"
)
if not accounts: if not accounts:
return None return None
@@ -96,7 +100,7 @@ def get_data(filters):
gl_entries_by_account = {} gl_entries_by_account = {}
opening_balances = get_opening_balances(filters) opening_balances = get_opening_balances(filters, ignore_is_opening)
# add filter inside list so that the query in financial_statements.py doesn't break # add filter inside list so that the query in financial_statements.py doesn't break
if filters.project: if filters.project:
@@ -114,7 +118,13 @@ def get_data(filters):
ignore_opening_entries=True, ignore_opening_entries=True,
) )
calculate_values(accounts, gl_entries_by_account, opening_balances, filters.get("show_net_values")) calculate_values(
accounts,
gl_entries_by_account,
opening_balances,
filters.get("show_net_values"),
ignore_is_opening=ignore_is_opening,
)
accumulate_values_into_parents(accounts, accounts_by_name) accumulate_values_into_parents(accounts, accounts_by_name)
data = prepare_data(accounts, filters, parent_children_map, company_currency) data = prepare_data(accounts, filters, parent_children_map, company_currency)
@@ -125,15 +135,15 @@ def get_data(filters):
return data return data
def get_opening_balances(filters): def get_opening_balances(filters, ignore_is_opening):
balance_sheet_opening = get_rootwise_opening_balances(filters, "Balance Sheet") balance_sheet_opening = get_rootwise_opening_balances(filters, "Balance Sheet", ignore_is_opening)
pl_opening = get_rootwise_opening_balances(filters, "Profit and Loss") pl_opening = get_rootwise_opening_balances(filters, "Profit and Loss", ignore_is_opening)
balance_sheet_opening.update(pl_opening) balance_sheet_opening.update(pl_opening)
return balance_sheet_opening return balance_sheet_opening
def get_rootwise_opening_balances(filters, report_type): def get_rootwise_opening_balances(filters, report_type, ignore_is_opening):
gle = [] gle = []
last_period_closing_voucher = "" last_period_closing_voucher = ""
@@ -159,16 +169,24 @@ def get_rootwise_opening_balances(filters, report_type):
report_type, report_type,
accounting_dimensions, accounting_dimensions,
period_closing_voucher=last_period_closing_voucher[0].name, period_closing_voucher=last_period_closing_voucher[0].name,
ignore_is_opening=ignore_is_opening,
) )
# Report getting generate from the mid of a fiscal year # Report getting generate from the mid of a fiscal year
if getdate(last_period_closing_voucher[0].period_end_date) < getdate(add_days(filters.from_date, -1)): if getdate(last_period_closing_voucher[0].period_end_date) < getdate(add_days(filters.from_date, -1)):
start_date = add_days(last_period_closing_voucher[0].period_end_date, 1) start_date = add_days(last_period_closing_voucher[0].period_end_date, 1)
gle += get_opening_balance( gle += get_opening_balance(
"GL Entry", filters, report_type, accounting_dimensions, start_date=start_date "GL Entry",
filters,
report_type,
accounting_dimensions,
start_date=start_date,
ignore_is_opening=ignore_is_opening,
) )
else: else:
gle = get_opening_balance("GL Entry", filters, report_type, accounting_dimensions) gle = get_opening_balance(
"GL Entry", filters, report_type, accounting_dimensions, ignore_is_opening=ignore_is_opening
)
opening = frappe._dict() opening = frappe._dict()
for d in gle: for d in gle:
@@ -187,7 +205,13 @@ def get_rootwise_opening_balances(filters, report_type):
def get_opening_balance( def get_opening_balance(
doctype, filters, report_type, accounting_dimensions, period_closing_voucher=None, start_date=None doctype,
filters,
report_type,
accounting_dimensions,
period_closing_voucher=None,
start_date=None,
ignore_is_opening=0,
): ):
closing_balance = frappe.qb.DocType(doctype) closing_balance = frappe.qb.DocType(doctype)
account = frappe.qb.DocType("Account") account = frappe.qb.DocType("Account")
@@ -223,11 +247,16 @@ def get_opening_balance(
(closing_balance.posting_date >= start_date) (closing_balance.posting_date >= start_date)
& (closing_balance.posting_date < filters.from_date) & (closing_balance.posting_date < filters.from_date)
) )
opening_balance = opening_balance.where(closing_balance.is_opening == "No")
if not ignore_is_opening:
opening_balance = opening_balance.where(closing_balance.is_opening == "No")
else: else:
opening_balance = opening_balance.where( if not ignore_is_opening:
(closing_balance.posting_date < filters.from_date) | (closing_balance.is_opening == "Yes") opening_balance = opening_balance.where(
) (closing_balance.posting_date < filters.from_date) | (closing_balance.is_opening == "Yes")
)
else:
opening_balance = opening_balance.where(closing_balance.posting_date < filters.from_date)
if doctype == "GL Entry": if doctype == "GL Entry":
opening_balance = opening_balance.where(closing_balance.is_cancelled == 0) opening_balance = opening_balance.where(closing_balance.is_cancelled == 0)
@@ -298,7 +327,7 @@ def get_opening_balance(
return gle return gle
def calculate_values(accounts, gl_entries_by_account, opening_balances, show_net_values): def calculate_values(accounts, gl_entries_by_account, opening_balances, show_net_values, ignore_is_opening=0):
init = { init = {
"opening_debit": 0.0, "opening_debit": 0.0,
"opening_credit": 0.0, "opening_credit": 0.0,
@@ -316,7 +345,7 @@ def calculate_values(accounts, gl_entries_by_account, opening_balances, show_net
d["opening_credit"] = opening_balances.get(d.name, {}).get("opening_credit", 0) d["opening_credit"] = opening_balances.get(d.name, {}).get("opening_credit", 0)
for entry in gl_entries_by_account.get(d.name, []): for entry in gl_entries_by_account.get(d.name, []):
if cstr(entry.is_opening) != "Yes": if cstr(entry.is_opening) != "Yes" or ignore_is_opening:
d["debit"] += flt(entry.debit) d["debit"] += flt(entry.debit)
d["credit"] += flt(entry.credit) d["credit"] += flt(entry.credit)

View File

@@ -110,7 +110,7 @@ def validate_returned_items(doc):
for d in doc.get("items"): for d in doc.get("items"):
key = d.item_code key = d.item_code
raise_exception = False raise_exception = False
if doc.doctype in ["Purchase Receipt", "Purchase Invoice", "Sales Invoice"]: if doc.doctype in ["Purchase Receipt", "Purchase Invoice", "Sales Invoice", "POS Invoice"]:
field = frappe.scrub(doc.doctype) + "_item" field = frappe.scrub(doc.doctype) + "_item"
if d.get(field): if d.get(field):
key = (d.item_code, d.get(field)) key = (d.item_code, d.get(field))
@@ -259,7 +259,7 @@ def get_already_returned_items(doc):
) )
data = frappe.db.sql( data = frappe.db.sql(
f""" f"""
select {column}, {field} select {column}, child.{field}
from from
`tab{doc.doctype} Item` child, `tab{doc.doctype}` par `tab{doc.doctype} Item` child, `tab{doc.doctype}` par
where where
@@ -1020,7 +1020,7 @@ def get_serial_and_batch_bundle(field, doctype, reference_ids, is_rejected=False
def filter_serial_batches(parent_doc, data, row, warehouse_field=None, qty_field=None): def filter_serial_batches(parent_doc, data, row, warehouse_field=None, qty_field=None):
if not qty_field: if not qty_field:
qty_field = "qty" qty_field = "stock_qty"
if not warehouse_field: if not warehouse_field:
warehouse_field = "warehouse" warehouse_field = "warehouse"
@@ -1109,7 +1109,7 @@ def make_serial_batch_bundle_for_return(data, child_doc, parent_doc, warehouse_f
warehouse_field = "warehouse" warehouse_field = "warehouse"
if not qty_field: if not qty_field:
qty_field = "qty" qty_field = "stock_qty"
warehouse = child_doc.get(warehouse_field) warehouse = child_doc.get(warehouse_field)
if parent_doc.get("is_internal_customer"): if parent_doc.get("is_internal_customer"):

View File

@@ -30,6 +30,11 @@ class calculate_taxes_and_totals:
"Accounts Settings", "round_row_wise_tax" "Accounts Settings", "round_row_wise_tax"
) )
if doc.get("round_off_applicable_accounts_for_tax_withholding"):
frappe.flags.round_off_applicable_accounts.append(
doc.round_off_applicable_accounts_for_tax_withholding
)
self._items = self.filter_rows() if self.doc.doctype == "Quotation" else self.doc.get("items") self._items = self.filter_rows() if self.doc.doctype == "Quotation" else self.doc.get("items")
get_round_off_applicable_accounts(self.doc.company, frappe.flags.round_off_applicable_accounts) get_round_off_applicable_accounts(self.doc.company, frappe.flags.round_off_applicable_accounts)

View File

@@ -17,7 +17,9 @@
"quotation_section", "quotation_section",
"default_valid_till", "default_valid_till",
"section_break_13", "section_break_13",
"carry_forward_communication_and_comments" "carry_forward_communication_and_comments",
"column_break_junk",
"update_timestamp_on_new_communication"
], ],
"fields": [ "fields": [
{ {
@@ -77,7 +79,7 @@
{ {
"fieldname": "section_break_13", "fieldname": "section_break_13",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "Other Settings" "label": "Activity"
}, },
{ {
"default": "0", "default": "0",
@@ -85,13 +87,24 @@
"fieldname": "carry_forward_communication_and_comments", "fieldname": "carry_forward_communication_and_comments",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Carry Forward Communication and Comments" "label": "Carry Forward Communication and Comments"
},
{
"fieldname": "column_break_junk",
"fieldtype": "Column Break"
},
{
"default": "0",
"description": "Update the modified timestamp on new communications received in Lead & Opportunity.",
"fieldname": "update_timestamp_on_new_communication",
"fieldtype": "Check",
"label": "Update timestamp on new communication"
} }
], ],
"icon": "fa fa-cog", "icon": "fa fa-cog",
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2022-06-06 11:22:08.464253", "modified": "2025-01-16 16:12:14.889455",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "CRM", "module": "CRM",
"name": "CRM Settings", "name": "CRM Settings",

View File

@@ -20,6 +20,7 @@ class CRMSettings(Document):
carry_forward_communication_and_comments: DF.Check carry_forward_communication_and_comments: DF.Check
close_opportunity_after_days: DF.Int close_opportunity_after_days: DF.Int
default_valid_till: DF.Data | None default_valid_till: DF.Data | None
update_timestamp_on_new_communication: DF.Check
# end: auto-generated types # end: auto-generated types
def validate(self): def validate(self):

View File

@@ -84,6 +84,20 @@ def link_communications_with_prospect(communication, method):
row.db_update() row.db_update()
def update_modified_timestamp(communication, method):
if communication.reference_doctype and communication.reference_name:
if communication.sent_or_received == "Received" and frappe.db.get_single_value(
"CRM Settings", "update_timestamp_on_new_communication"
):
frappe.db.set_value(
dt=communication.reference_doctype,
dn=communication.reference_name,
field="modified",
val=now(),
update_modified=False,
)
def get_linked_prospect(reference_doctype, reference_name): def get_linked_prospect(reference_doctype, reference_name):
prospect = None prospect = None
if reference_doctype == "Lead": if reference_doctype == "Lead":

View File

@@ -29,7 +29,7 @@ frappe.ui.form.on("Plaid Settings", {
"Bank Transaction", "Bank Transaction",
"", "",
true, true,
"Bank Transaction" __("Bank Transaction")
); );
frappe.msgprint({ frappe.msgprint({

View File

@@ -351,7 +351,10 @@ doc_events = {
"erpnext.support.doctype.service_level_agreement.service_level_agreement.on_communication_update", "erpnext.support.doctype.service_level_agreement.service_level_agreement.on_communication_update",
"erpnext.support.doctype.issue.issue.set_first_response_time", "erpnext.support.doctype.issue.issue.set_first_response_time",
], ],
"after_insert": "erpnext.crm.utils.link_communications_with_prospect", "after_insert": [
"erpnext.crm.utils.link_communications_with_prospect",
"erpnext.crm.utils.update_modified_timestamp",
],
}, },
"Event": { "Event": {
"after_insert": "erpnext.crm.utils.link_events_with_prospect", "after_insert": "erpnext.crm.utils.link_events_with_prospect",

View File

@@ -43,6 +43,7 @@ class BlanketOrder(Document):
def validate(self): def validate(self):
self.validate_dates() self.validate_dates()
self.validate_duplicate_items() self.validate_duplicate_items()
self.validate_item_qty()
self.set_party_item_code() self.set_party_item_code()
def validate_dates(self): def validate_dates(self):
@@ -117,6 +118,11 @@ class BlanketOrder(Document):
for d in self.items: for d in self.items:
d.db_set("ordered_qty", item_ordered_qty.get(d.item_code, 0)) d.db_set("ordered_qty", item_ordered_qty.get(d.item_code, 0))
def validate_item_qty(self):
for d in self.items:
if d.qty < 0:
frappe.throw(_("Row {0}: Quantity cannot be negative.").format(d.idx))
@frappe.whitelist() @frappe.whitelist()
def make_order(source_name): def make_order(source_name):
@@ -148,7 +154,7 @@ def make_order(source_name):
"doctype": doctype + " Item", "doctype": doctype + " Item",
"field_map": {"rate": "blanket_order_rate", "parent": "blanket_order"}, "field_map": {"rate": "blanket_order_rate", "parent": "blanket_order"},
"postprocess": update_item, "postprocess": update_item,
"condition": lambda item: (flt(item.qty) - flt(item.ordered_qty)) > 0, "condition": lambda item: not (flt(item.qty)) or (flt(item.qty) - flt(item.ordered_qty)) > 0,
}, },
}, },
) )
@@ -186,7 +192,7 @@ def validate_against_blanket_order(order_doc):
if item.item_code in item_data: if item.item_code in item_data:
remaining_qty = item.qty - item.ordered_qty remaining_qty = item.qty - item.ordered_qty
allowed_qty = remaining_qty + (remaining_qty * (allowance / 100)) allowed_qty = remaining_qty + (remaining_qty * (allowance / 100))
if allowed_qty < item_data[item.item_code]: if item.qty and allowed_qty < item_data[item.item_code]:
frappe.throw( frappe.throw(
_( _(
"Item {0} cannot be ordered more than {1} against Blanket Order {2}." "Item {0} cannot be ordered more than {1} against Blanket Order {2}."

View File

@@ -1549,6 +1549,9 @@ def get_scrap_items_from_sub_assemblies(bom_no, company, qty, scrap_items=None):
fields=["bom_no", "qty"], fields=["bom_no", "qty"],
order_by="idx asc", order_by="idx asc",
) )
# fetch Scrap Items for Parent Bom
items = get_bom_items_as_dict(bom_no, company, qty=qty, fetch_exploded=0, fetch_scrap_items=1)
scrap_items.update(items)
for row in bom_items: for row in bom_items:
if not row.bom_no: if not row.bom_no:

View File

@@ -755,6 +755,19 @@ class TestBOM(FrappeTestCase):
self.assertTrue("_Test RM Item 2 Fixed Asset Item" not in items) self.assertTrue("_Test RM Item 2 Fixed Asset Item" not in items)
self.assertTrue("_Test RM Item 3 Manufacture Item" in items) self.assertTrue("_Test RM Item 3 Manufacture Item" in items)
def test_get_scrap_items_from_sub_assemblies(self):
from erpnext.manufacturing.doctype.bom.bom import get_scrap_items_from_sub_assemblies
bom = frappe.copy_doc(test_records[1])
bom.insert(ignore_mandatory=True)
bom_scraped_items = [i.get("item_code") for i in bom.get("scrap_items", [])]
# get scrapted items for parent bom
scraped_items = get_scrap_items_from_sub_assemblies(bom.name, bom.company, 2, None)
for item_code in scraped_items.keys():
self.assertIn(item_code, bom_scraped_items, f"Item {item_code} not found in BOM scrap items")
def test_bom_raw_materials_stock_uom(self): def test_bom_raw_materials_stock_uom(self):
rm_item = make_item( rm_item = make_item(
properties={"is_stock_item": 1, "valuation_rate": 1000.0, "stock_uom": "Nos"} properties={"is_stock_item": 1, "valuation_rate": 1000.0, "stock_uom": "Nos"}

View File

@@ -204,7 +204,7 @@ class BOMCreator(Document):
for field, label in fields.items(): for field, label in fields.items():
if not self.get(field): if not self.get(field):
frappe.throw(_("Please set {0} in BOM Creator {1}").format(label, self.name)) frappe.throw(_("Please set {0} in BOM Creator {1}").format(_(label), self.name))
def on_submit(self): def on_submit(self):
self.enqueue_create_boms() self.enqueue_create_boms()
@@ -365,12 +365,6 @@ def get_children(doctype=None, parent=None, **kwargs):
return frappe.get_all("BOM Creator Item", fields=fields, filters=query_filters, order_by="idx") return frappe.get_all("BOM Creator Item", fields=fields, filters=query_filters, order_by="idx")
def get_parent_row_no(doc, name):
for row in doc.items:
if row.name == name:
return row.idx
@frappe.whitelist() @frappe.whitelist()
def add_item(**kwargs): def add_item(**kwargs):
if isinstance(kwargs, str): if isinstance(kwargs, str):
@@ -418,6 +412,8 @@ def add_sub_assembly(**kwargs):
parent_row_no = "" parent_row_no = ""
if not kwargs.convert_to_sub_assembly: if not kwargs.convert_to_sub_assembly:
item_info = get_item_details(bom_item.item_code) item_info = get_item_details(bom_item.item_code)
parent_row_no = get_parent_row_no(doc, kwargs.fg_reference_id)
item_row = doc.append( item_row = doc.append(
"items", "items",
{ {
@@ -426,6 +422,7 @@ def add_sub_assembly(**kwargs):
"uom": item_info.stock_uom, "uom": item_info.stock_uom,
"fg_item": kwargs.fg_item, "fg_item": kwargs.fg_item,
"conversion_factor": 1, "conversion_factor": 1,
"parent_row_no": parent_row_no,
"fg_reference_id": name, "fg_reference_id": name,
"stock_qty": bom_item.qty, "stock_qty": bom_item.qty,
"do_not_explode": 1, "do_not_explode": 1,
@@ -437,9 +434,7 @@ def add_sub_assembly(**kwargs):
parent_row_no = item_row.idx parent_row_no = item_row.idx
name = "" name = ""
else: else:
parent_row_no = [row.idx for row in doc.items if row.name == kwargs.fg_reference_id] parent_row_no = get_parent_row_no(doc, kwargs.fg_reference_id)
if parent_row_no:
parent_row_no = parent_row_no[0]
for row in bom_item.get("items"): for row in bom_item.get("items"):
row = frappe._dict(row) row = frappe._dict(row)
@@ -471,6 +466,14 @@ def get_item_details(item_code):
) )
def get_parent_row_no(doc, name):
for row in doc.items:
if row.name == name:
return row.idx
frappe.msgprint(_("Parent Row No not found for {0}").format(name))
@frappe.whitelist() @frappe.whitelist()
def delete_node(**kwargs): def delete_node(**kwargs):
if isinstance(kwargs, str): if isinstance(kwargs, str):

View File

@@ -37,7 +37,7 @@
"capacity_planning_for_days", "capacity_planning_for_days",
"mins_between_operations", "mins_between_operations",
"other_settings_section", "other_settings_section",
"set_op_cost_and_scrape_from_sub_assemblies", "set_op_cost_and_scrap_from_sub_assemblies",
"column_break_23", "column_break_23",
"make_serial_no_batch_from_work_order" "make_serial_no_batch_from_work_order"
], ],
@@ -202,13 +202,6 @@
"fieldtype": "Check", "fieldtype": "Check",
"label": "Allow Excess Material Transfer" "label": "Allow Excess Material Transfer"
}, },
{
"default": "0",
"description": "In the case of 'Use Multi-Level BOM' in a work order, if the user wishes to add sub-assembly costs to Finished Goods items without using a job card as well the scrap items, then this option needs to be enable.",
"fieldname": "set_op_cost_and_scrape_from_sub_assemblies",
"fieldtype": "Check",
"label": "Set Operating Cost / Scrap Items From Sub-assemblies"
},
{ {
"default": "0", "default": "0",
"depends_on": "eval: doc.material_consumption", "depends_on": "eval: doc.material_consumption",
@@ -243,13 +236,20 @@
"fieldname": "validate_components_quantities_per_bom", "fieldname": "validate_components_quantities_per_bom",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Validate Components and Quantities Per BOM" "label": "Validate Components and Quantities Per BOM"
},
{
"default": "0",
"description": "To include sub-assembly costs and scrap items in Finished Goods on a work order without using a job card, when the 'Use Multi-Level BOM' option is enabled.",
"fieldname": "set_op_cost_and_scrap_from_sub_assemblies",
"fieldtype": "Check",
"label": "Set Operating Cost / Scrap Items From Sub-assemblies"
} }
], ],
"icon": "icon-wrench", "icon": "icon-wrench",
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2025-01-09 16:02:23.326763", "modified": "2025-01-13 12:07:03.089977",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Manufacturing", "module": "Manufacturing",
"name": "Manufacturing Settings", "name": "Manufacturing Settings",

View File

@@ -34,7 +34,7 @@ class ManufacturingSettings(Document):
mins_between_operations: DF.Int mins_between_operations: DF.Int
overproduction_percentage_for_sales_order: DF.Percent overproduction_percentage_for_sales_order: DF.Percent
overproduction_percentage_for_work_order: DF.Percent overproduction_percentage_for_work_order: DF.Percent
set_op_cost_and_scrape_from_sub_assemblies: DF.Check set_op_cost_and_scrap_from_sub_assemblies: DF.Check
update_bom_costs_automatically: DF.Check update_bom_costs_automatically: DF.Check
validate_components_quantities_per_bom: DF.Check validate_components_quantities_per_bom: DF.Check
# end: auto-generated types # end: auto-generated types

View File

@@ -2091,7 +2091,7 @@ class TestWorkOrder(FrappeTestCase):
def test_op_cost_and_scrap_based_on_sub_assemblies(self): def test_op_cost_and_scrap_based_on_sub_assemblies(self):
# Make Sub Assembly BOM 1 # Make Sub Assembly BOM 1
frappe.db.set_single_value("Manufacturing Settings", "set_op_cost_and_scrape_from_sub_assemblies", 1) frappe.db.set_single_value("Manufacturing Settings", "set_op_cost_and_scrap_from_sub_assemblies", 1)
items = { items = {
"Test Final FG Item": 0, "Test Final FG Item": 0,
@@ -2132,7 +2132,7 @@ class TestWorkOrder(FrappeTestCase):
for row in se_doc.additional_costs: for row in se_doc.additional_costs:
self.assertEqual(row.amount, 3000) self.assertEqual(row.amount, 3000)
frappe.db.set_single_value("Manufacturing Settings", "set_op_cost_and_scrape_from_sub_assemblies", 0) frappe.db.set_single_value("Manufacturing Settings", "set_op_cost_and_scrap_from_sub_assemblies", 0)
@change_settings( @change_settings(
"Manufacturing Settings", {"material_consumption": 1, "get_rm_cost_from_consumption_entry": 1} "Manufacturing Settings", {"material_consumption": 1, "get_rm_cost_from_consumption_entry": 1}
@@ -2501,6 +2501,53 @@ class TestWorkOrder(FrappeTestCase):
manufacture_entry = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 10)) manufacture_entry = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 10))
self.assertEqual(manufacture_entry.items[0].s_warehouse, "Stores - _TC") self.assertEqual(manufacture_entry.items[0].s_warehouse, "Stores - _TC")
def test_serial_no_status_for_stock_entry(self):
items = {
"Finished Good Test Item 1": {"is_stock_item": 1},
"_Test RM Item with Serial No": {
"is_stock_item": 1,
"has_serial_no": 1,
"serial_no_series": "SN-FCG-NO-.####",
},
}
for item, properties in items.items():
make_item(item, properties)
fg_item = "Finished Good Test Item 1"
rec_se = test_stock_entry.make_stock_entry(
item_code="_Test RM Item with Serial No", target="_Test Warehouse - _TC", qty=4, basic_rate=100
)
if not frappe.db.get_value("BOM", {"item": fg_item, "docstatus": 1}):
bom = make_bom(
item=fg_item,
rate=1000,
raw_materials=["_Test RM Item with Serial No"],
do_not_save=True,
)
bom.rm_cost_as_per = "Price List" # non stock item won't have valuation rate
bom.buying_price_list = "_Test Price List India"
bom.currency = "INR"
bom.save()
wo = make_wo_order_test_record(
production_item=fg_item, skip_transfer=1, source_warehouse="_Test Warehouse - _TC"
)
ste = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 4))
ste.items[0].use_serial_batch_fields = 1
ste.items[0].serial_no = "\n".join(
get_serial_nos_from_bundle(rec_se.items[0].serial_and_batch_bundle)
)
ste.insert()
ste.submit()
ste.reload()
serial_nos = get_serial_nos_from_bundle(ste.items[0].serial_and_batch_bundle)
for row in serial_nos:
status = frappe.db.get_value("Serial No", row, "status")
self.assertEqual(status, "Consumed")
def make_operation(**kwargs): def make_operation(**kwargs):
kwargs = frappe._dict(kwargs) kwargs = frappe._dict(kwargs)

View File

@@ -348,7 +348,7 @@ class WorkOrder(Document):
if flt(self.material_transferred_for_manufacturing) > 0: if flt(self.material_transferred_for_manufacturing) > 0:
status = "In Process" status = "In Process"
total_qty = flt(self.produced_qty) + flt(self.process_loss_qty) total_qty = flt(flt(self.produced_qty) + flt(self.process_loss_qty), self.precision("qty"))
if flt(total_qty) >= flt(self.qty): if flt(total_qty) >= flt(self.qty):
status = "Completed" status = "Completed"
else: else:

View File

@@ -315,7 +315,7 @@ erpnext.patches.v15_0.update_asset_value_for_manual_depr_entries
erpnext.patches.v15_0.update_gpa_and_ndb_for_assdeprsch erpnext.patches.v15_0.update_gpa_and_ndb_for_assdeprsch
erpnext.patches.v14_0.create_accounting_dimensions_for_closing_balance erpnext.patches.v14_0.create_accounting_dimensions_for_closing_balance
erpnext.patches.v14_0.set_period_start_end_date_in_pcv erpnext.patches.v14_0.set_period_start_end_date_in_pcv
erpnext.patches.v14_0.update_closing_balances #08-11-2024 erpnext.patches.v14_0.update_closing_balances #20-12-2024
execute:frappe.db.set_single_value("Accounts Settings", "merge_similar_account_heads", 0) execute:frappe.db.set_single_value("Accounts Settings", "merge_similar_account_heads", 0)
erpnext.patches.v14_0.update_reference_type_in_journal_entry_accounts erpnext.patches.v14_0.update_reference_type_in_journal_entry_accounts
erpnext.patches.v14_0.update_subscription_details erpnext.patches.v14_0.update_subscription_details
@@ -387,5 +387,6 @@ erpnext.patches.v15_0.set_is_exchange_gain_loss_in_payment_entry_deductions
erpnext.patches.v15_0.enable_allow_existing_serial_no 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_cc_in_process_statement_of_accounts
erpnext.patches.v15_0.update_asset_status_to_work_in_progress 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.migrate_checkbox_to_select_for_reconciliation_effect
erpnext.patches.v15_0.sync_auto_reconcile_config erpnext.patches.v15_0.sync_auto_reconcile_config

View File

@@ -1,6 +1,7 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors # Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE # License: MIT. See LICENSE
import itertools
import frappe import frappe
@@ -10,34 +11,91 @@ from erpnext.accounts.doctype.account_closing_balance.account_closing_balance im
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions, get_accounting_dimensions,
) )
from erpnext.accounts.utils import get_fiscal_year
def execute(): def execute():
# clear balances, they will be recalculated
frappe.db.truncate("Account Closing Balance") frappe.db.truncate("Account Closing Balance")
gle_fields = get_gle_fields() pcv_list = get_period_closing_vouchers()
gl_entries = get_gl_entries(pcv_list)
for company in frappe.get_all("Company", pluck="name"): for _, pcvs in itertools.groupby(pcv_list, key=lambda pcv: (pcv.company, pcv.period_start_date)):
i = 0 process_grouped_pcvs(list(pcvs), gl_entries)
company_wise_order = {}
for pcv in get_period_closing_vouchers(company):
company_wise_order.setdefault(pcv.company, [])
if pcv.period_end_date not in company_wise_order[pcv.company]:
pcv_doc = frappe.get_doc("Period Closing Voucher", pcv.name)
pcv_doc.pl_accounts_reverse_gle = get_pcv_gl_entries_for_pl_accounts(pcv, gle_fields)
pcv_doc.closing_account_gle = get_pcv_gl_entries_for_closing_accounts(pcv, gle_fields)
closing_entries = pcv_doc.get_account_closing_balances()
make_closing_entries(closing_entries, pcv.name, pcv.company, pcv.period_end_date)
company_wise_order[pcv.company].append(pcv.period_end_date)
i += 1 def process_grouped_pcvs(pcvs, gl_entries):
pl_account_entries = []
closing_account_entries = []
first_pcv = pcvs[0]
for pcv in pcvs:
pcv_entries = gl_entries.get(pcv.name) or []
for entry in pcv_entries:
entry["closing_date"] = first_pcv.period_end_date
entry["period_closing_voucher"] = first_pcv.name
entry["voucher_no"] = first_pcv.name
list_to_update = (
pl_account_entries if entry.account != pcv.closing_account_head else closing_account_entries
)
list_to_update.append(entry)
# hacky!!
if to_cancel := pcvs[1:]:
to_cancel = [pcv.name for pcv in to_cancel]
# update voucher number
gle_to_update = [entry.name for entry in closing_account_entries + pl_account_entries]
frappe.db.set_value(
"GL Entry",
{
"name": ("in", gle_to_update),
"voucher_no": ("in", to_cancel),
},
"voucher_no",
first_pcv.name,
update_modified=False,
)
# mark as cancelled
frappe.db.set_value(
"Period Closing Voucher",
{"name": ("in", to_cancel)},
"docstatus",
2,
update_modified=False,
)
pcv_doc = frappe.get_doc("Period Closing Voucher", first_pcv.name)
pcv_doc.pl_accounts_reverse_gle = pl_account_entries
pcv_doc.closing_account_gle = closing_account_entries
closing_entries = pcv_doc.get_account_closing_balances()
make_closing_entries(closing_entries, pcv_doc.name, pcv_doc.company, pcv_doc.period_end_date)
def get_period_closing_vouchers():
return frappe.db.get_all(
"Period Closing Voucher",
fields=["name", "closing_account_head", "period_start_date", "period_end_date", "company"],
filters={"docstatus": 1},
order_by="period_start_date asc, period_end_date desc",
)
def get_gl_entries(pcv_list):
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_no": ("in", [pcv.name for pcv in pcv_list]), "is_cancelled": 0},
fields=get_gle_fields(),
update={"is_period_closing_voucher_entry": 1},
)
return {k: list(v) for k, v in itertools.groupby(gl_entries, key=lambda gle: gle.voucher_no)}
def get_gle_fields(): def get_gle_fields():
default_diemnsion_fields = ["cost_center", "finance_book", "project"] return [
accounting_dimension_fields = get_accounting_dimensions()
gle_fields = [
"name", "name",
"company", "company",
"posting_date", "posting_date",
@@ -47,43 +105,11 @@ def get_gle_fields():
"credit", "credit",
"debit_in_account_currency", "debit_in_account_currency",
"credit_in_account_currency", "credit_in_account_currency",
*default_diemnsion_fields, "voucher_no",
*accounting_dimension_fields, # default dimension fields
"cost_center",
"finance_book",
"project",
# accounting dimensions
*get_accounting_dimensions(),
] ]
return gle_fields
def get_period_closing_vouchers(company):
return frappe.db.get_all(
"Period Closing Voucher",
fields=["name", "closing_account_head", "period_start_date", "period_end_date", "company"],
filters={"docstatus": 1, "company": company},
order_by="period_end_date",
)
def get_pcv_gl_entries_for_pl_accounts(pcv, gle_fields):
return get_gl_entries(pcv, gle_fields, {"account": ["!=", pcv.closing_account_head]})
def get_pcv_gl_entries_for_closing_accounts(pcv, gle_fields):
return get_gl_entries(pcv, gle_fields, {"account": pcv.closing_account_head})
def get_gl_entries(pcv, gle_fields, accounts_filter=None):
filters = {"voucher_no": pcv.name, "is_cancelled": 0}
if accounts_filter:
filters.update(accounts_filter)
gl_entries = frappe.db.get_all(
"GL Entry",
filters=filters,
fields=gle_fields,
)
for entry in gl_entries:
entry["is_period_closing_voucher_entry"] = 1
entry["closing_date"] = pcv.period_end_date
entry["period_closing_voucher"] = pcv.name
return gl_entries

View File

@@ -0,0 +1,9 @@
from frappe.model.utils.rename_field import rename_field
def execute():
rename_field(
"Manufacturing Settings",
"set_op_cost_and_scrape_from_sub_assemblies",
"set_op_cost_and_scrap_from_sub_assemblies",
)

View File

@@ -9,7 +9,7 @@ from frappe.desk.reportview import get_match_cond
from frappe.model.document import Document from frappe.model.document import Document
from frappe.query_builder import Interval from frappe.query_builder import Interval
from frappe.query_builder.functions import Count, CurDate, Date, Sum, UnixTimestamp from frappe.query_builder.functions import Count, CurDate, Date, Sum, UnixTimestamp
from frappe.utils import add_days, flt, get_datetime, get_time, get_url, nowtime, today from frappe.utils import add_days, flt, get_datetime, get_link_to_form, get_time, get_url, nowtime, today
from frappe.utils.user import is_website_user from frappe.utils.user import is_website_user
from erpnext import get_default_company from erpnext import get_default_company
@@ -341,24 +341,19 @@ class Project(Document):
frappe.db.set_value("Project", new_name, "copied_from", new_name) frappe.db.set_value("Project", new_name, "copied_from", new_name)
def send_welcome_email(self): def send_welcome_email(self):
url = get_url(f"/project/?name={self.name}") label = f"{self.project_name} ({self.name})"
messages = ( url = get_link_to_form(self.doctype, self.name, label)
_("You have been invited to collaborate on the project: {0}").format(self.name),
url,
_("Join"),
)
content = """ content = "<p>{}</p>".format(
<p>{0}.</p> _("You have been invited to collaborate on the project: {0}").format(url)
<p><a href="{1}">{2}</a></p> )
"""
for user in self.users: for user in self.users:
if user.welcome_email_sent == 0: if user.welcome_email_sent == 0:
frappe.sendmail( frappe.sendmail(
user.user, user.user,
subject=_("Project Collaboration Invitation"), subject=_("Project Collaboration Invitation"),
content=content.format(*messages), content=content,
) )
user.welcome_email_sent = 1 user.welcome_email_sent = 1

View File

@@ -10,6 +10,7 @@ from frappe import _
from frappe.query_builder import Interval from frappe.query_builder import Interval
from frappe.query_builder.functions import Count, CurDate, UnixTimestamp from frappe.query_builder.functions import Count, CurDate, UnixTimestamp
from frappe.utils import flt from frappe.utils import flt
from frappe.utils.data import get_url_to_list
from frappe.utils.nestedset import NestedSet, get_root_of from frappe.utils.nestedset import NestedSet, get_root_of
from erpnext import get_default_currency from erpnext import get_default_currency
@@ -42,6 +43,9 @@ class SalesPerson(NestedSet):
nsm_parent_field = "parent_sales_person" nsm_parent_field = "parent_sales_person"
def validate(self): def validate(self):
if not self.enabled:
self.validate_sales_person()
if not self.parent_sales_person: if not self.parent_sales_person:
self.parent_sales_person = get_root_of("Sales Person") self.parent_sales_person = get_root_of("Sales Person")
@@ -83,6 +87,25 @@ class SalesPerson(NestedSet):
super().on_update() super().on_update()
self.validate_one_root() self.validate_one_root()
def validate_sales_person(self):
sales_team = frappe.qb.DocType("Sales Team")
query = (
frappe.qb.from_(sales_team)
.select(sales_team.sales_person)
.where((sales_team.sales_person == self.name) & (sales_team.parenttype == "Customer"))
.groupby(sales_team.sales_person)
).run(as_dict=True)
if query:
frappe.throw(
_("The Sales Person is linked with {0}").format(
frappe.bold(
f"""<a href="{get_url_to_list("Customer")}?sales_person={self.name}">{"Customers"}</a>"""
)
)
)
def get_email_id(self): def get_email_id(self):
if self.employee: if self.employee:
user = frappe.db.get_value("Employee", self.employee, "user_id") user = frappe.db.get_value("Employee", self.employee, "user_id")

View File

@@ -4,11 +4,15 @@
import frappe import frappe
from frappe import _ from frappe import _
from frappe.utils import add_days, flt, get_datetime_str, nowdate from frappe.utils import add_days, flt, get_datetime_str, nowdate
from frappe.utils.data import now_datetime from frappe.utils.data import getdate, now_datetime
from frappe.utils.nestedset import get_root_of from frappe.utils.nestedset import get_root_of
from erpnext import get_default_company from erpnext import get_default_company
PEGGED_CURRENCIES = {
"USD": {"AED": 3.6725}, # AED is pegged to USD at a rate of 3.6725 since 1997
}
def before_tests(): def before_tests():
frappe.clear_cache() frappe.clear_cache()
@@ -45,6 +49,14 @@ def before_tests():
frappe.db.commit() frappe.db.commit()
def get_pegged_rate(from_currency: str, to_currency: str, transaction_date) -> float | None:
if rate := PEGGED_CURRENCIES.get(from_currency, {}).get(to_currency):
return rate
elif rate := PEGGED_CURRENCIES.get(to_currency, {}).get(from_currency):
return 1 / rate
return None
@frappe.whitelist() @frappe.whitelist()
def get_exchange_rate(from_currency, to_currency, transaction_date=None, args=None): def get_exchange_rate(from_currency, to_currency, transaction_date=None, args=None):
if not (from_currency and to_currency): if not (from_currency and to_currency):
@@ -55,6 +67,10 @@ def get_exchange_rate(from_currency, to_currency, transaction_date=None, args=No
if not transaction_date: if not transaction_date:
transaction_date = nowdate() transaction_date = nowdate()
if rate := get_pegged_rate(from_currency, to_currency, transaction_date):
return rate
currency_settings = frappe.get_doc("Accounts Settings").as_dict() currency_settings = frappe.get_doc("Accounts Settings").as_dict()
allow_stale_rates = currency_settings.get("allow_stale") allow_stale_rates = currency_settings.get("allow_stale")
@@ -95,8 +111,8 @@ def get_exchange_rate(from_currency, to_currency, transaction_date=None, args=No
settings = frappe.get_cached_doc("Currency Exchange Settings") settings = frappe.get_cached_doc("Currency Exchange Settings")
req_params = { req_params = {
"transaction_date": transaction_date, "transaction_date": transaction_date,
"from_currency": from_currency, "from_currency": from_currency if from_currency != "AED" else "USD",
"to_currency": to_currency, "to_currency": to_currency if to_currency != "AED" else "USD",
} }
params = {} params = {}
for row in settings.req_params: for row in settings.req_params:
@@ -108,6 +124,14 @@ def get_exchange_rate(from_currency, to_currency, transaction_date=None, args=No
for res_key in settings.result_key: for res_key in settings.result_key:
value = value[format_ces_api(str(res_key.key), req_params)] value = value[format_ces_api(str(res_key.key), req_params)]
cache.setex(name=key, time=21600, value=flt(value)) cache.setex(name=key, time=21600, value=flt(value))
# Support AED conversion through pegged USD
value = flt(value)
if to_currency == "AED":
value *= 3.6725
if from_currency == "AED":
value /= 3.6725
return flt(value) return flt(value)
except Exception: except Exception:
frappe.log_error("Unable to fetch exchange rate") frappe.log_error("Unable to fetch exchange rate")

View File

@@ -74,9 +74,8 @@ class PickList(Document):
def validate(self): def validate(self):
self.validate_for_qty() self.validate_for_qty()
if self.pick_manually and self.get("locations"): self.validate_stock_qty()
self.validate_stock_qty() self.check_serial_no_status()
self.check_serial_no_status()
def before_save(self): def before_save(self):
self.update_status() self.update_status()
@@ -90,14 +89,24 @@ class PickList(Document):
from erpnext.stock.doctype.batch.batch import get_batch_qty from erpnext.stock.doctype.batch.batch import get_batch_qty
for row in self.get("locations"): for row in self.get("locations"):
if row.batch_no and not row.qty: if not row.picked_qty:
continue
if row.batch_no and row.picked_qty:
batch_qty = get_batch_qty(row.batch_no, row.warehouse, row.item_code) batch_qty = get_batch_qty(row.batch_no, row.warehouse, row.item_code)
if row.qty > batch_qty: if row.picked_qty > batch_qty:
frappe.throw( frappe.throw(
_( _(
"At Row #{0}: The picked quantity {1} for the item {2} is greater than available stock {3} for the batch {4} in the warehouse {5}." "At Row #{0}: The picked quantity {1} for the item {2} is greater than available stock {3} for the batch {4} in the warehouse {5}. Please restock the item."
).format(row.idx, row.item_code, batch_qty, row.batch_no, bold(row.warehouse)), ).format(
row.idx,
row.picked_qty,
row.item_code,
batch_qty,
row.batch_no,
bold(row.warehouse),
),
title=_("Insufficient Stock"), title=_("Insufficient Stock"),
) )
@@ -109,11 +118,11 @@ class PickList(Document):
"actual_qty", "actual_qty",
) )
if row.qty > flt(bin_qty): if row.picked_qty > flt(bin_qty):
frappe.throw( frappe.throw(
_( _(
"At Row #{0}: The picked quantity {1} for the item {2} is greater than available stock {3} in the warehouse {4}." "At Row #{0}: The picked quantity {1} for the item {2} is greater than available stock {3} in the warehouse {4}."
).format(row.idx, row.qty, bold(row.item_code), bin_qty, bold(row.warehouse)), ).format(row.idx, row.picked_qty, bold(row.item_code), bin_qty, bold(row.warehouse)),
title=_("Insufficient Stock"), title=_("Insufficient Stock"),
) )
@@ -429,7 +438,14 @@ class PickList(Document):
locations_replica = self.get("locations") locations_replica = self.get("locations")
# reset # reset
self.delete_key("locations") reset_rows = []
for row in self.get("locations"):
if not row.picked_qty:
reset_rows.append(row)
for row in reset_rows:
self.remove(row)
updated_locations = frappe._dict() updated_locations = frappe._dict()
for item_doc in items: for item_doc in items:
item_code = item_doc.item_code item_code = item_doc.item_code
@@ -499,6 +515,9 @@ class PickList(Document):
# aggregate qty for same item # aggregate qty for same item
item_map = OrderedDict() item_map = OrderedDict()
for item in locations: for item in locations:
if item.picked_qty:
continue
if not item.item_code: if not item.item_code:
frappe.throw(f"Row #{item.idx}: Item Code is Mandatory") frappe.throw(f"Row #{item.idx}: Item Code is Mandatory")
if not cint( if not cint(

View File

@@ -870,7 +870,7 @@ class TestPickList(FrappeTestCase):
so = make_sales_order(item_code=item, qty=4, rate=100) so = make_sales_order(item_code=item, qty=4, rate=100)
pl = create_pick_list(so.name) pl = create_pick_list(so.name)
self.assertFalse(hasattr(pl, "locations")) self.assertFalse(pl.locations)
def test_pick_list_validation_for_serial_no(self): def test_pick_list_validation_for_serial_no(self):
warehouse = "_Test Warehouse - _TC" warehouse = "_Test Warehouse - _TC"
@@ -901,7 +901,7 @@ class TestPickList(FrappeTestCase):
so = make_sales_order(item_code=item, qty=4, rate=100) so = make_sales_order(item_code=item, qty=4, rate=100)
pl = create_pick_list(so.name) pl = create_pick_list(so.name)
self.assertFalse(hasattr(pl, "locations")) self.assertFalse(pl.locations)
def test_pick_list_validation_for_batch_no(self): def test_pick_list_validation_for_batch_no(self):
warehouse = "_Test Warehouse - _TC" warehouse = "_Test Warehouse - _TC"
@@ -937,7 +937,7 @@ class TestPickList(FrappeTestCase):
so = make_sales_order(item_code=item, qty=4, rate=100) so = make_sales_order(item_code=item, qty=4, rate=100)
pl = create_pick_list(so.name) pl = create_pick_list(so.name)
self.assertFalse(hasattr(pl, "locations")) self.assertFalse(pl.locations)
def test_pick_list_validation_for_batch_no_and_serial_item(self): def test_pick_list_validation_for_batch_no_and_serial_item(self):
warehouse = "_Test Warehouse - _TC" warehouse = "_Test Warehouse - _TC"
@@ -977,7 +977,7 @@ class TestPickList(FrappeTestCase):
so = make_sales_order(item_code=item, qty=4, rate=100) so = make_sales_order(item_code=item, qty=4, rate=100)
pl = create_pick_list(so.name) pl = create_pick_list(so.name)
self.assertFalse(hasattr(pl, "locations")) self.assertFalse(pl.locations)
def test_pick_list_validation_for_multiple_batches_and_sales_order(self): def test_pick_list_validation_for_multiple_batches_and_sales_order(self):
warehouse = "_Test Warehouse - _TC" warehouse = "_Test Warehouse - _TC"
@@ -1172,6 +1172,7 @@ class TestPickList(FrappeTestCase):
for row in pl.locations: for row in pl.locations:
row.qty = row.qty + 10 row.qty = row.qty + 10
row.picked_qty = row.qty
self.assertRaises(frappe.ValidationError, pl.save) self.assertRaises(frappe.ValidationError, pl.save)
@@ -1266,3 +1267,42 @@ class TestPickList(FrappeTestCase):
delivery_note = create_delivery_note(pl.name) delivery_note = create_delivery_note(pl.name)
self.assertEqual(len(delivery_note.items), 1) self.assertEqual(len(delivery_note.items), 1)
def test_pick_list_not_reset_batch(self):
warehouse = "_Test Warehouse - _TC"
item = make_item(
"Test Do Not Reset Picked Item",
properties={
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "BTH-PICKLT-.######",
},
).name
se = make_stock_entry(item=item, to_warehouse=warehouse, qty=10)
batch1 = get_batch_from_bundle(se.items[0].serial_and_batch_bundle)
se = make_stock_entry(item=item, to_warehouse=warehouse, qty=10)
batch2 = get_batch_from_bundle(se.items[0].serial_and_batch_bundle)
so = make_sales_order(item_code=item, qty=10, rate=100)
pl = create_pick_list(so.name)
pl.save()
for loc in pl.locations:
self.assertEqual(loc.batch_no, batch1)
loc.batch_no = batch2
loc.picked_qty = 0.0
pl.save()
for loc in pl.locations:
self.assertEqual(loc.batch_no, batch1)
loc.batch_no = batch2
loc.picked_qty = 10.0
pl.save()
for loc in pl.locations:
self.assertEqual(loc.batch_no, batch2)

View File

@@ -4,12 +4,26 @@
cur_frm.cscript.refresh = cur_frm.cscript.inspection_type; cur_frm.cscript.refresh = cur_frm.cscript.inspection_type;
frappe.ui.form.on("Quality Inspection", { frappe.ui.form.on("Quality Inspection", {
onload(frm) {
frm.trigger("set_default_company");
},
set_default_company(frm) {
if (!frm.doc.company) {
frm.set_value("company", frappe.defaults.get_default("company"));
}
},
setup: function (frm) { setup: function (frm) {
frm.set_query("reference_name", function () { frm.set_query("reference_name", function (doc) {
let filters = { docstatus: ["!=", 2] };
if (doc.company) {
filters["company"] = doc.company;
}
return { return {
filters: { filters: filters,
docstatus: ["!=", 2],
},
}; };
}); });

View File

@@ -8,14 +8,14 @@
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"naming_series", "naming_series",
"company",
"report_date", "report_date",
"status", "status",
"manual_inspection", "child_row_reference",
"column_break_4", "column_break_4",
"inspection_type", "inspection_type",
"reference_type", "reference_type",
"reference_name", "reference_name",
"child_row_reference",
"section_break_7", "section_break_7",
"item_code", "item_code",
"item_serial_no", "item_serial_no",
@@ -27,6 +27,7 @@
"bom_no", "bom_no",
"specification_details", "specification_details",
"quality_inspection_template", "quality_inspection_template",
"manual_inspection",
"readings", "readings",
"section_break_14", "section_break_14",
"inspected_by", "inspected_by",
@@ -248,6 +249,12 @@
"no_copy": 1, "no_copy": 1,
"print_hide": 1, "print_hide": 1,
"read_only": 1 "read_only": 1
},
{
"fieldname": "company",
"fieldtype": "Link",
"label": "Company",
"options": "Company"
} }
], ],
"icon": "fa fa-search", "icon": "fa fa-search",
@@ -255,7 +262,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2024-12-30 19:08:16.611192", "modified": "2025-01-16 17:00:48.774532",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Quality Inspection", "name": "Quality Inspection",

View File

@@ -30,6 +30,7 @@ class QualityInspection(Document):
batch_no: DF.Link | None batch_no: DF.Link | None
bom_no: DF.Link | None bom_no: DF.Link | None
child_row_reference: DF.Data | None child_row_reference: DF.Data | None
company: DF.Link | None
description: DF.SmallText | None description: DF.SmallText | None
inspected_by: DF.Link inspected_by: DF.Link
inspection_type: DF.Literal["", "Incoming", "Outgoing", "In Process"] inspection_type: DF.Literal["", "Incoming", "Outgoing", "In Process"]
@@ -76,6 +77,13 @@ class QualityInspection(Document):
self.validate_inspection_required() self.validate_inspection_required()
self.set_child_row_reference() self.set_child_row_reference()
self.set_company()
def set_company(self):
if self.reference_type and self.reference_name:
company = frappe.get_cached_value(self.reference_type, self.reference_name, "company")
if company != self.company:
self.company = company
def set_child_row_reference(self): def set_child_row_reference(self):
if self.child_row_reference: if self.child_row_reference:

View File

@@ -350,7 +350,7 @@ class SerialandBatchBundle(Document):
for row in bundle_data: for row in bundle_data:
if row.serial_no: if row.serial_no:
valuation_details["serial_nos"][row.serial_no] = row.incoming_rate valuation_details["serial_nos"][row.serial_no] = row.incoming_rate
else: if row.batch_no:
valuation_details["batches"][row.batch_no] = row.incoming_rate valuation_details["batches"][row.batch_no] = row.incoming_rate
return valuation_details return valuation_details

View File

@@ -254,7 +254,7 @@
"in_list_view": 1, "in_list_view": 1,
"in_standard_filter": 1, "in_standard_filter": 1,
"label": "Status", "label": "Status",
"options": "\nActive\nInactive\nDelivered\nExpired", "options": "\nActive\nInactive\nConsumed\nDelivered\nExpired",
"read_only": 1 "read_only": 1
}, },
{ {
@@ -272,7 +272,7 @@
"icon": "fa fa-barcode", "icon": "fa fa-barcode",
"idx": 1, "idx": 1,
"links": [], "links": [],
"modified": "2023-12-17 10:52:55.767839", "modified": "2025-01-15 16:22:49.873889",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Serial No", "name": "Serial No",
@@ -316,4 +316,4 @@
"sort_order": "DESC", "sort_order": "DESC",
"states": [], "states": [],
"track_changes": 1 "track_changes": 1
} }

View File

@@ -50,7 +50,7 @@ class SerialNo(StockController):
purchase_document_no: DF.Data | None purchase_document_no: DF.Data | None
purchase_rate: DF.Float purchase_rate: DF.Float
serial_no: DF.Data serial_no: DF.Data
status: DF.Literal["", "Active", "Inactive", "Delivered", "Expired"] status: DF.Literal["", "Active", "Inactive", "Consumed", "Delivered", "Expired"]
warehouse: DF.Link | None warehouse: DF.Link | None
warranty_expiry_date: DF.Date | None warranty_expiry_date: DF.Date | None
warranty_period: DF.Int warranty_period: DF.Int

View File

@@ -2040,7 +2040,7 @@ class StockEntry(StockController):
from erpnext.manufacturing.doctype.bom.bom import get_bom_items_as_dict from erpnext.manufacturing.doctype.bom.bom import get_bom_items_as_dict
if ( if (
frappe.db.get_single_value("Manufacturing Settings", "set_op_cost_and_scrape_from_sub_assemblies") frappe.db.get_single_value("Manufacturing Settings", "set_op_cost_and_scrap_from_sub_assemblies")
and self.work_order and self.work_order
and frappe.get_cached_value("Work Order", self.work_order, "use_multi_level_bom") and frappe.get_cached_value("Work Order", self.work_order, "use_multi_level_bom")
): ):
@@ -2847,7 +2847,7 @@ def get_operating_cost_per_unit(work_order=None, bom_no=None):
if ( if (
bom_no bom_no
and frappe.db.get_single_value( and frappe.db.get_single_value(
"Manufacturing Settings", "set_op_cost_and_scrape_from_sub_assemblies" "Manufacturing Settings", "set_op_cost_and_scrap_from_sub_assemblies"
) )
and frappe.get_cached_value("Work Order", work_order, "use_multi_level_bom") and frappe.get_cached_value("Work Order", work_order, "use_multi_level_bom")
): ):

View File

@@ -1802,7 +1802,7 @@ class TestStockEntry(FrappeTestCase):
for serial_no in serial_nos: for serial_no in serial_nos:
self.assertTrue(frappe.db.exists("Serial No", serial_no)) self.assertTrue(frappe.db.exists("Serial No", serial_no))
self.assertEqual(frappe.db.get_value("Serial No", serial_no, "status"), "Delivered") self.assertEqual(frappe.db.get_value("Serial No", serial_no, "status"), "Consumed")
def test_serial_batch_bundle_type_of_transaction(self): def test_serial_batch_bundle_type_of_transaction(self):
item = make_item( item = make_item(

View File

@@ -37,7 +37,7 @@
} }
], ],
"links": [], "links": [],
"modified": "2024-08-24 16:00:22.696958", "modified": "2025-01-15 16:00:22.696958",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Stock Entry Type", "name": "Stock Entry Type",

View File

@@ -89,18 +89,22 @@ def get_average_age(fifo_queue: list, to_date: str) -> float:
def get_range_age(filters: Filters, fifo_queue: list, to_date: str, item_dict: dict) -> list: def get_range_age(filters: Filters, fifo_queue: list, to_date: str, item_dict: dict) -> list:
precision = cint(frappe.db.get_single_value("System Settings", "float_precision", cache=True)) precision = cint(frappe.db.get_single_value("System Settings", "float_precision", cache=True))
range_values = [0.0] * (len(filters.ranges) + 1) range_values = [0.0] * ((len(filters.ranges) * 2) + 2)
for item in fifo_queue: for item in fifo_queue:
age = flt(date_diff(to_date, item[1])) age = flt(date_diff(to_date, item[1]))
qty = flt(item[0]) if not item_dict["has_serial_no"] else 1.0 qty = flt(item[0]) if not item_dict["has_serial_no"] else 1.0
stock_value = flt(item[2])
for i, age_limit in enumerate(filters.ranges): for i, age_limit in enumerate(filters.ranges):
if age <= flt(age_limit): if age <= flt(age_limit):
i *= 2
range_values[i] = flt(range_values[i] + qty, precision) range_values[i] = flt(range_values[i] + qty, precision)
range_values[i + 1] = flt(range_values[i + 1] + stock_value, precision)
break break
else: else:
range_values[-1] = flt(range_values[-1] + qty, precision) range_values[-2] = flt(range_values[-2] + qty, precision)
range_values[-1] = flt(range_values[-1] + stock_value, precision)
return range_values return range_values
@@ -199,6 +203,7 @@ def setup_ageing_columns(filters: Filters, range_columns: list):
for i, label in enumerate(ranges): for i, label in enumerate(ranges):
fieldname = "range" + str(i + 1) fieldname = "range" + str(i + 1)
add_column(range_columns, label=_("Age ({0})").format(label), fieldname=fieldname) add_column(range_columns, label=_("Age ({0})").format(label), fieldname=fieldname)
add_column(range_columns, label=_("Value ({0})").format(label), fieldname=fieldname + "value")
def add_column(range_columns: list, label: str, fieldname: str, fieldtype: str = "Float", width: int = 140): def add_column(range_columns: list, label: str, fieldname: str, fieldtype: str = "Float", width: int = 140):
@@ -298,16 +303,22 @@ class FIFOSlots:
# neutralize 0/negative stock by adding positive stock # neutralize 0/negative stock by adding positive stock
fifo_queue[0][0] += flt(row.actual_qty) fifo_queue[0][0] += flt(row.actual_qty)
fifo_queue[0][1] = row.posting_date fifo_queue[0][1] = row.posting_date
fifo_queue[0][2] += flt(row.stock_value_difference)
else: else:
fifo_queue.append([flt(row.actual_qty), row.posting_date]) fifo_queue.append(
[flt(row.actual_qty), row.posting_date, flt(row.stock_value_difference)]
)
return return
valuation = row.stock_value_difference / row.actual_qty
for serial_no in serial_nos: for serial_no in serial_nos:
if self.serial_no_batch_purchase_details.get(serial_no): if self.serial_no_batch_purchase_details.get(serial_no):
fifo_queue.append([serial_no, self.serial_no_batch_purchase_details.get(serial_no)]) fifo_queue.append(
[serial_no, self.serial_no_batch_purchase_details.get(serial_no), valuation]
)
else: else:
self.serial_no_batch_purchase_details.setdefault(serial_no, row.posting_date) self.serial_no_batch_purchase_details.setdefault(serial_no, row.posting_date)
fifo_queue.append([serial_no, row.posting_date]) fifo_queue.append([serial_no, row.posting_date, valuation])
def __compute_outgoing_stock(self, row: dict, fifo_queue: list, transfer_key: tuple, serial_nos: list): def __compute_outgoing_stock(self, row: dict, fifo_queue: list, transfer_key: tuple, serial_nos: list):
"Update FIFO Queue on outward stock." "Update FIFO Queue on outward stock."
@@ -316,34 +327,44 @@ class FIFOSlots:
return return
qty_to_pop = abs(row.actual_qty) qty_to_pop = abs(row.actual_qty)
stock_value = abs(row.stock_value_difference)
while qty_to_pop: while qty_to_pop:
slot = fifo_queue[0] if fifo_queue else [0, None] slot = fifo_queue[0] if fifo_queue else [0, None, 0]
if 0 < flt(slot[0]) <= qty_to_pop: if 0 < flt(slot[0]) <= qty_to_pop:
# qty to pop >= slot qty # qty to pop >= slot qty
# if +ve and not enough or exactly same balance in current slot, consume whole slot # if +ve and not enough or exactly same balance in current slot, consume whole slot
qty_to_pop -= flt(slot[0]) qty_to_pop -= flt(slot[0])
stock_value -= flt(slot[2])
self.transferred_item_details[transfer_key].append(fifo_queue.pop(0)) self.transferred_item_details[transfer_key].append(fifo_queue.pop(0))
elif not fifo_queue: elif not fifo_queue:
# negative stock, no balance but qty yet to consume # negative stock, no balance but qty yet to consume
fifo_queue.append([-(qty_to_pop), row.posting_date]) fifo_queue.append([-(qty_to_pop), row.posting_date, -(stock_value)])
self.transferred_item_details[transfer_key].append([qty_to_pop, row.posting_date]) self.transferred_item_details[transfer_key].append(
[qty_to_pop, row.posting_date, stock_value]
)
qty_to_pop = 0 qty_to_pop = 0
stock_value = 0
else: else:
# qty to pop < slot qty, ample balance # qty to pop < slot qty, ample balance
# consume actual_qty from first slot # consume actual_qty from first slot
slot[0] = flt(slot[0]) - qty_to_pop slot[0] = flt(slot[0]) - qty_to_pop
self.transferred_item_details[transfer_key].append([qty_to_pop, slot[1]]) slot[2] = flt(slot[2]) - stock_value
self.transferred_item_details[transfer_key].append([qty_to_pop, slot[1], stock_value])
qty_to_pop = 0 qty_to_pop = 0
stock_value = 0
def __adjust_incoming_transfer_qty(self, transfer_data: dict, fifo_queue: list, row: dict): def __adjust_incoming_transfer_qty(self, transfer_data: dict, fifo_queue: list, row: dict):
"Add previously removed stock back to FIFO Queue." "Add previously removed stock back to FIFO Queue."
transfer_qty_to_pop = flt(row.actual_qty) transfer_qty_to_pop = flt(row.actual_qty)
stock_value = flt(row.stock_value_difference)
def add_to_fifo_queue(slot): def add_to_fifo_queue(slot):
if fifo_queue and flt(fifo_queue[0][0]) <= 0: if fifo_queue and flt(fifo_queue[0][0]) <= 0:
# neutralize 0/negative stock by adding positive stock # neutralize 0/negative stock by adding positive stock
fifo_queue[0][0] += flt(slot[0]) fifo_queue[0][0] += flt(slot[0])
fifo_queue[0][1] = slot[1] fifo_queue[0][1] = slot[1]
fifo_queue[0][2] += flt(slot[2])
else: else:
fifo_queue.append(slot) fifo_queue.append(slot)
@@ -351,16 +372,20 @@ class FIFOSlots:
if transfer_data and 0 < transfer_data[0][0] <= transfer_qty_to_pop: if transfer_data and 0 < transfer_data[0][0] <= transfer_qty_to_pop:
# bucket qty is not enough, consume whole # bucket qty is not enough, consume whole
transfer_qty_to_pop -= transfer_data[0][0] transfer_qty_to_pop -= transfer_data[0][0]
stock_value -= transfer_data[0][2]
add_to_fifo_queue(transfer_data.pop(0)) add_to_fifo_queue(transfer_data.pop(0))
elif not transfer_data: elif not transfer_data:
# transfer bucket is empty, extra incoming qty # transfer bucket is empty, extra incoming qty
add_to_fifo_queue([transfer_qty_to_pop, row.posting_date]) add_to_fifo_queue([transfer_qty_to_pop, row.posting_date, stock_value])
transfer_qty_to_pop = 0 transfer_qty_to_pop = 0
stock_value = 0
else: else:
# ample bucket qty to consume # ample bucket qty to consume
transfer_data[0][0] -= transfer_qty_to_pop transfer_data[0][0] -= transfer_qty_to_pop
add_to_fifo_queue([transfer_qty_to_pop, transfer_data[0][1]]) transfer_data[0][2] -= stock_value
add_to_fifo_queue([transfer_qty_to_pop, transfer_data[0][1], stock_value])
transfer_qty_to_pop = 0 transfer_qty_to_pop = 0
stock_value = 0
def __update_balances(self, row: dict, key: tuple | str): def __update_balances(self, row: dict, key: tuple | str):
self.item_details[key]["qty_after_transaction"] = row.qty_after_transaction self.item_details[key]["qty_after_transaction"] = row.qty_after_transaction
@@ -412,6 +437,7 @@ class FIFOSlots:
item.stock_uom, item.stock_uom,
item.has_serial_no, item.has_serial_no,
sle.actual_qty, sle.actual_qty,
sle.stock_value_difference,
sle.posting_date, sle.posting_date,
sle.voucher_type, sle.voucher_type,
sle.voucher_no, sle.voucher_no,

View File

@@ -18,6 +18,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item", name="Flask Item",
actual_qty=30, actual_qty=30,
qty_after_transaction=30, qty_after_transaction=30,
stock_value_difference=30,
warehouse="WH 1", warehouse="WH 1",
posting_date="2021-12-01", posting_date="2021-12-01",
voucher_type="Stock Entry", voucher_type="Stock Entry",
@@ -29,6 +30,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item", name="Flask Item",
actual_qty=20, actual_qty=20,
qty_after_transaction=50, qty_after_transaction=50,
stock_value_difference=20,
warehouse="WH 1", warehouse="WH 1",
posting_date="2021-12-02", posting_date="2021-12-02",
voucher_type="Stock Entry", voucher_type="Stock Entry",
@@ -40,6 +42,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item", name="Flask Item",
actual_qty=(-10), actual_qty=(-10),
qty_after_transaction=40, qty_after_transaction=40,
stock_value_difference=(-10),
warehouse="WH 1", warehouse="WH 1",
posting_date="2021-12-03", posting_date="2021-12-03",
voucher_type="Stock Entry", voucher_type="Stock Entry",
@@ -57,6 +60,8 @@ class TestStockAgeing(FrappeTestCase):
self.assertEqual(result["qty_after_transaction"], result["total_qty"]) self.assertEqual(result["qty_after_transaction"], result["total_qty"])
self.assertEqual(queue[0][0], 20.0) self.assertEqual(queue[0][0], 20.0)
data = format_report_data(self.filters, slots, self.filters["to_date"])
self.assertEqual(data[0][8], 40.0) # valuating for stock value between age 0-30
def test_insufficient_balance(self): def test_insufficient_balance(self):
"Reference: Case 3 in stock_ageing_fifo_logic.md (same wh)" "Reference: Case 3 in stock_ageing_fifo_logic.md (same wh)"
@@ -65,6 +70,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item", name="Flask Item",
actual_qty=(-30), actual_qty=(-30),
qty_after_transaction=(-30), qty_after_transaction=(-30),
stock_value_difference=0,
warehouse="WH 1", warehouse="WH 1",
posting_date="2021-12-01", posting_date="2021-12-01",
voucher_type="Stock Entry", voucher_type="Stock Entry",
@@ -76,6 +82,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item", name="Flask Item",
actual_qty=20, actual_qty=20,
qty_after_transaction=(-10), qty_after_transaction=(-10),
stock_value_difference=0,
warehouse="WH 1", warehouse="WH 1",
posting_date="2021-12-02", posting_date="2021-12-02",
voucher_type="Stock Entry", voucher_type="Stock Entry",
@@ -87,6 +94,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item", name="Flask Item",
actual_qty=20, actual_qty=20,
qty_after_transaction=10, qty_after_transaction=10,
stock_value_difference=0,
warehouse="WH 1", warehouse="WH 1",
posting_date="2021-12-03", posting_date="2021-12-03",
voucher_type="Stock Entry", voucher_type="Stock Entry",
@@ -98,6 +106,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item", name="Flask Item",
actual_qty=10, actual_qty=10,
qty_after_transaction=20, qty_after_transaction=20,
stock_value_difference=0,
warehouse="WH 1", warehouse="WH 1",
posting_date="2021-12-03", posting_date="2021-12-03",
voucher_type="Stock Entry", voucher_type="Stock Entry",
@@ -126,6 +135,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item", name="Flask Item",
actual_qty=30, actual_qty=30,
qty_after_transaction=30, qty_after_transaction=30,
stock_value_difference=0,
warehouse="WH 1", warehouse="WH 1",
posting_date="2021-12-01", posting_date="2021-12-01",
voucher_type="Stock Entry", voucher_type="Stock Entry",
@@ -137,6 +147,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item", name="Flask Item",
actual_qty=0, actual_qty=0,
qty_after_transaction=50, qty_after_transaction=50,
stock_value_difference=0,
warehouse="WH 1", warehouse="WH 1",
posting_date="2021-12-02", posting_date="2021-12-02",
voucher_type="Stock Reconciliation", voucher_type="Stock Reconciliation",
@@ -148,6 +159,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item", name="Flask Item",
actual_qty=(-10), actual_qty=(-10),
qty_after_transaction=40, qty_after_transaction=40,
stock_value_difference=0,
warehouse="WH 1", warehouse="WH 1",
posting_date="2021-12-03", posting_date="2021-12-03",
voucher_type="Stock Entry", voucher_type="Stock Entry",
@@ -178,6 +190,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item", name="Flask Item",
actual_qty=0, actual_qty=0,
qty_after_transaction=1000, qty_after_transaction=1000,
stock_value_difference=0,
warehouse="WH 1", warehouse="WH 1",
posting_date="2021-12-01", posting_date="2021-12-01",
voucher_type="Stock Reconciliation", voucher_type="Stock Reconciliation",
@@ -189,6 +202,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item", name="Flask Item",
actual_qty=0, actual_qty=0,
qty_after_transaction=400, qty_after_transaction=400,
stock_value_difference=0,
warehouse="WH 1", warehouse="WH 1",
posting_date="2021-12-02", posting_date="2021-12-02",
voucher_type="Stock Reconciliation", voucher_type="Stock Reconciliation",
@@ -200,6 +214,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item", name="Flask Item",
actual_qty=(-10), actual_qty=(-10),
qty_after_transaction=390, qty_after_transaction=390,
stock_value_difference=0,
warehouse="WH 1", warehouse="WH 1",
posting_date="2021-12-03", posting_date="2021-12-03",
voucher_type="Stock Entry", voucher_type="Stock Entry",
@@ -233,6 +248,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item", name="Flask Item",
actual_qty=0, actual_qty=0,
qty_after_transaction=1000, qty_after_transaction=1000,
stock_value_difference=0,
warehouse="WH 1", warehouse="WH 1",
posting_date="2021-12-01", posting_date="2021-12-01",
voucher_type="Stock Reconciliation", voucher_type="Stock Reconciliation",
@@ -244,6 +260,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item", name="Flask Item",
actual_qty=0, actual_qty=0,
qty_after_transaction=400, qty_after_transaction=400,
stock_value_difference=0,
warehouse="WH 2", warehouse="WH 2",
posting_date="2021-12-02", posting_date="2021-12-02",
voucher_type="Stock Reconciliation", voucher_type="Stock Reconciliation",
@@ -255,6 +272,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item", name="Flask Item",
actual_qty=(-10), actual_qty=(-10),
qty_after_transaction=990, qty_after_transaction=990,
stock_value_difference=0,
warehouse="WH 1", warehouse="WH 1",
posting_date="2021-12-03", posting_date="2021-12-03",
voucher_type="Stock Entry", voucher_type="Stock Entry",
@@ -301,6 +319,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item", name="Flask Item",
actual_qty=500, actual_qty=500,
qty_after_transaction=500, qty_after_transaction=500,
stock_value_difference=0,
warehouse="WH 1", warehouse="WH 1",
posting_date="2021-12-03", posting_date="2021-12-03",
voucher_type="Stock Entry", voucher_type="Stock Entry",
@@ -312,6 +331,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item", name="Flask Item",
actual_qty=(-50), actual_qty=(-50),
qty_after_transaction=450, qty_after_transaction=450,
stock_value_difference=0,
warehouse="WH 1", warehouse="WH 1",
posting_date="2021-12-04", posting_date="2021-12-04",
voucher_type="Stock Entry", voucher_type="Stock Entry",
@@ -323,6 +343,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item", name="Flask Item",
actual_qty=(-50), actual_qty=(-50),
qty_after_transaction=400, qty_after_transaction=400,
stock_value_difference=0,
warehouse="WH 1", warehouse="WH 1",
posting_date="2021-12-04", posting_date="2021-12-04",
voucher_type="Stock Entry", voucher_type="Stock Entry",
@@ -334,6 +355,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item", name="Flask Item",
actual_qty=100, actual_qty=100,
qty_after_transaction=500, qty_after_transaction=500,
stock_value_difference=0,
warehouse="WH 1", warehouse="WH 1",
posting_date="2021-12-04", posting_date="2021-12-04",
voucher_type="Stock Entry", voucher_type="Stock Entry",
@@ -370,6 +392,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item", name="Flask Item",
actual_qty=500, actual_qty=500,
qty_after_transaction=500, qty_after_transaction=500,
stock_value_difference=0,
warehouse="WH 1", warehouse="WH 1",
posting_date="2021-12-03", posting_date="2021-12-03",
voucher_type="Stock Entry", voucher_type="Stock Entry",
@@ -381,6 +404,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item", name="Flask Item",
actual_qty=(-100), actual_qty=(-100),
qty_after_transaction=400, qty_after_transaction=400,
stock_value_difference=0,
warehouse="WH 1", warehouse="WH 1",
posting_date="2021-12-04", posting_date="2021-12-04",
voucher_type="Stock Entry", voucher_type="Stock Entry",
@@ -392,6 +416,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item", name="Flask Item",
actual_qty=50, actual_qty=50,
qty_after_transaction=450, qty_after_transaction=450,
stock_value_difference=0,
warehouse="WH 1", warehouse="WH 1",
posting_date="2021-12-04", posting_date="2021-12-04",
voucher_type="Stock Entry", voucher_type="Stock Entry",
@@ -426,6 +451,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item", name="Flask Item",
actual_qty=20, actual_qty=20,
qty_after_transaction=20, qty_after_transaction=20,
stock_value_difference=0,
warehouse="WH 1", warehouse="WH 1",
posting_date="2021-12-03", posting_date="2021-12-03",
voucher_type="Stock Entry", voucher_type="Stock Entry",
@@ -437,6 +463,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item", name="Flask Item",
actual_qty=(-50), actual_qty=(-50),
qty_after_transaction=(-30), qty_after_transaction=(-30),
stock_value_difference=0,
warehouse="WH 1", warehouse="WH 1",
posting_date="2021-12-04", posting_date="2021-12-04",
voucher_type="Stock Entry", voucher_type="Stock Entry",
@@ -448,6 +475,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item", name="Flask Item",
actual_qty=(-50), actual_qty=(-50),
qty_after_transaction=(-80), qty_after_transaction=(-80),
stock_value_difference=0,
warehouse="WH 1", warehouse="WH 1",
posting_date="2021-12-04", posting_date="2021-12-04",
voucher_type="Stock Entry", voucher_type="Stock Entry",
@@ -459,6 +487,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item", name="Flask Item",
actual_qty=50, actual_qty=50,
qty_after_transaction=(-30), qty_after_transaction=(-30),
stock_value_difference=0,
warehouse="WH 1", warehouse="WH 1",
posting_date="2021-12-04", posting_date="2021-12-04",
voucher_type="Stock Entry", voucher_type="Stock Entry",
@@ -496,6 +525,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item", name="Flask Item",
actual_qty=500, actual_qty=500,
qty_after_transaction=500, qty_after_transaction=500,
stock_value_difference=0,
warehouse="WH 1", warehouse="WH 1",
posting_date="2021-12-03", posting_date="2021-12-03",
voucher_type="Stock Entry", voucher_type="Stock Entry",
@@ -507,6 +537,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item", name="Flask Item",
actual_qty=(-50), actual_qty=(-50),
qty_after_transaction=450, qty_after_transaction=450,
stock_value_difference=0,
warehouse="WH 1", warehouse="WH 1",
posting_date="2021-12-04", posting_date="2021-12-04",
voucher_type="Stock Entry", voucher_type="Stock Entry",
@@ -518,6 +549,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item", name="Flask Item",
actual_qty=100, actual_qty=100,
qty_after_transaction=550, qty_after_transaction=550,
stock_value_difference=0,
warehouse="WH 1", warehouse="WH 1",
posting_date="2021-12-04", posting_date="2021-12-04",
voucher_type="Stock Entry", voucher_type="Stock Entry",
@@ -553,6 +585,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item", name="Flask Item",
actual_qty=20, actual_qty=20,
qty_after_transaction=20, qty_after_transaction=20,
stock_value_difference=0,
warehouse="WH 1", warehouse="WH 1",
posting_date="2021-12-03", posting_date="2021-12-03",
voucher_type="Stock Entry", voucher_type="Stock Entry",
@@ -564,6 +597,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item", name="Flask Item",
actual_qty=(-50), actual_qty=(-50),
qty_after_transaction=(-30), qty_after_transaction=(-30),
stock_value_difference=0,
warehouse="WH 1", warehouse="WH 1",
posting_date="2021-12-04", posting_date="2021-12-04",
voucher_type="Stock Entry", voucher_type="Stock Entry",
@@ -575,6 +609,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item", name="Flask Item",
actual_qty=50, actual_qty=50,
qty_after_transaction=20, qty_after_transaction=20,
stock_value_difference=0,
warehouse="WH 1", warehouse="WH 1",
posting_date="2021-12-04", posting_date="2021-12-04",
voucher_type="Stock Entry", voucher_type="Stock Entry",
@@ -586,6 +621,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item", name="Flask Item",
actual_qty=50, actual_qty=50,
qty_after_transaction=70, qty_after_transaction=70,
stock_value_difference=0,
warehouse="WH 1", warehouse="WH 1",
posting_date="2021-12-04", posting_date="2021-12-04",
voucher_type="Stock Entry", voucher_type="Stock Entry",
@@ -623,6 +659,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item", name="Flask Item",
actual_qty=(-50), actual_qty=(-50),
qty_after_transaction=(-50), qty_after_transaction=(-50),
stock_value_difference=0,
warehouse="WH 1", warehouse="WH 1",
posting_date="2021-12-01", posting_date="2021-12-01",
voucher_type="Stock Entry", voucher_type="Stock Entry",
@@ -634,6 +671,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item", name="Flask Item",
actual_qty=(-50), actual_qty=(-50),
qty_after_transaction=(-100), qty_after_transaction=(-100),
stock_value_difference=0,
warehouse="WH 1", warehouse="WH 1",
posting_date="2021-12-01", posting_date="2021-12-01",
voucher_type="Stock Entry", voucher_type="Stock Entry",
@@ -645,6 +683,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item", name="Flask Item",
actual_qty=30, actual_qty=30,
qty_after_transaction=(-70), qty_after_transaction=(-70),
stock_value_difference=0,
warehouse="WH 1", warehouse="WH 1",
posting_date="2021-12-01", posting_date="2021-12-01",
voucher_type="Stock Entry", voucher_type="Stock Entry",
@@ -722,6 +761,113 @@ class TestStockAgeing(FrappeTestCase):
self.assertEqual(bal_qty, 0.9) self.assertEqual(bal_qty, 0.9)
self.assertEqual(bal_qty, range_qty_sum) self.assertEqual(bal_qty, range_qty_sum)
def test_ageing_stock_valuation(self):
"Test stock valuation for each time bucket."
sle = [
frappe._dict(
name="Flask Item",
actual_qty=10,
qty_after_transaction=10,
stock_value_difference=10,
warehouse="WH 1",
posting_date="2021-12-01",
voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False,
serial_no=None,
),
frappe._dict(
name="Flask Item",
actual_qty=20,
qty_after_transaction=30,
stock_value_difference=20,
warehouse="WH 1",
posting_date="2021-12-02",
voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False,
serial_no=None,
),
frappe._dict(
name="Flask Item",
actual_qty=(-10),
qty_after_transaction=20,
stock_value_difference=(-10),
warehouse="WH 1",
posting_date="2021-12-03",
voucher_type="Stock Entry",
voucher_no="003",
has_serial_no=False,
serial_no=None,
),
frappe._dict(
name="Flask Item",
actual_qty=10,
qty_after_transaction=30,
stock_value_difference=20,
warehouse="WH 1",
posting_date="2022-01-01",
voucher_type="Stock Entry",
voucher_no="004",
has_serial_no=False,
serial_no=None,
),
frappe._dict(
name="Flask Item",
actual_qty=(-15),
qty_after_transaction=15,
stock_value_difference=(-15),
warehouse="WH 1",
posting_date="2022-01-02",
voucher_type="Stock Entry",
voucher_no="005",
has_serial_no=False,
serial_no=None,
),
frappe._dict(
name="Flask Item",
actual_qty=10,
qty_after_transaction=25,
stock_value_difference=5,
warehouse="WH 1",
posting_date="2022-02-01",
voucher_type="Stock Entry",
voucher_no="006",
has_serial_no=False,
serial_no=None,
),
frappe._dict(
name="Flask Item",
actual_qty=5,
qty_after_transaction=30,
stock_value_difference=2.5,
warehouse="WH 1",
posting_date="2022-02-02",
voucher_type="Stock Entry",
voucher_no="007",
has_serial_no=False,
serial_no=None,
),
frappe._dict(
name="Flask Item",
actual_qty=5,
qty_after_transaction=35,
stock_value_difference=15,
warehouse="WH 1",
posting_date="2022-03-01",
voucher_type="Stock Entry",
voucher_no="008",
has_serial_no=False,
serial_no=None,
),
]
slots = FIFOSlots(self.filters, sle).generate()
report_data = format_report_data(self.filters, slots, "2022-03-31")
range_values = report_data[0][7:15]
range_valuations = range_values[1::2]
self.assertEqual(range_valuations, [15, 7.5, 20, 5])
def generate_item_and_item_wh_wise_slots(filters, sle): def generate_item_and_item_wh_wise_slots(filters, sle):
"Return results with and without 'show_warehouse_wise_stock'" "Return results with and without 'show_warehouse_wise_stock'"

View File

@@ -351,6 +351,15 @@ class SerialBatchBundle:
status = "Inactive" status = "Inactive"
if self.sle.actual_qty < 0: if self.sle.actual_qty < 0:
status = "Delivered" status = "Delivered"
if self.sle.voucher_type == "Stock Entry":
purpose = frappe.get_cached_value("Stock Entry", self.sle.voucher_no, "purpose")
if purpose in [
"Manufacture",
"Material Issue",
"Repack",
"Material Consumption for Manufacture",
]:
status = "Consumed"
sn_table = frappe.qb.DocType("Serial No") sn_table = frappe.qb.DocType("Serial No")

View File

@@ -622,15 +622,12 @@ class update_entries_after:
if sle.dependant_sle_voucher_detail_no: if sle.dependant_sle_voucher_detail_no:
entries_to_fix = self.get_dependent_entries_to_fix(entries_to_fix, sle) entries_to_fix = self.get_dependent_entries_to_fix(entries_to_fix, sle)
if self.has_stock_reco_with_serial_batch(sle):
break
if self.exceptions: if self.exceptions:
self.raise_exceptions() self.raise_exceptions()
def has_stock_reco_with_serial_batch(self, sle): def has_stock_reco_with_serial_batch(self, sle):
if ( if (
sle.vocher_type == "Stock Reconciliation" sle.voucher_type == "Stock Reconciliation"
and frappe.db.get_value(sle.voucher_type, sle.voucher_no, "set_posting_time") == 1 and frappe.db.get_value(sle.voucher_type, sle.voucher_no, "set_posting_time") == 1
): ):
return not (sle.batch_no or sle.serial_no or sle.serial_and_batch_bundle) return not (sle.batch_no or sle.serial_no or sle.serial_and_batch_bundle)