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 += __("Please enable only if the understand the effects of enabling this.");
msg += "<br>";
msg += "Do you still want to enable immutable ledger?";
msg += __("Do you still want to enable immutable ledger?");
frappe.confirm(
msg,

View File

@@ -76,6 +76,7 @@
"reports_tab",
"remarks_section",
"general_ledger_remarks_length",
"ignore_is_opening_check_for_reporting",
"column_break_lvjk",
"receivable_payable_remarks_length",
"payment_request_settings",
@@ -515,6 +516,13 @@
"fieldname": "reconciliation_queue_size",
"fieldtype": "Int",
"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",
@@ -522,7 +530,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2025-01-13 17:38:39.661320",
"modified": "2025-01-18 21:24:19.840745",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Settings",

View File

@@ -48,6 +48,7 @@ class AccountsSettings(Document):
frozen_accounts_modifier: DF.Link | None
general_ledger_remarks_length: DF.Int
ignore_account_closing_balance: DF.Check
ignore_is_opening_check_for_reporting: DF.Check
make_payment_via_journal_entry: DF.Check
merge_similar_account_heads: DF.Check
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):
_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:
_current_invoice_start = date
elif self.trial_period_start and self.is_trialling():
_current_invoice_start = self.trial_period_start
else:
_current_invoice_start = nowdate()
@@ -414,8 +414,8 @@ class Subscription(Document):
if frappe.db.get_value("Supplier", self.party, "tax_withholding_category"):
invoice.apply_tds = 1
# Add party currency to invoice
invoice.currency = get_party_account_currency(self.party_type, self.party, self.company)
# Add currency to invoice
invoice.currency = frappe.db.get_value("Subscription Plan", {"name": self.plans[0].plan}, "currency")
# Add dimensions in invoice for subscription:
accounting_dimensions = get_accounting_dimensions()

View File

@@ -3,7 +3,7 @@
import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.tests.utils import FrappeTestCase, change_settings
from frappe.utils.data import (
add_days,
add_months,
@@ -470,6 +470,28 @@ class TestSubscription(FrappeTestCase):
currency = frappe.db.get_value("Sales Invoice", subscription.invoices[0].name, "currency")
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):
"""Test if Subscription recovers when start/end date run out of sync with created invoices."""
subscription = create_subscription(
@@ -581,6 +603,12 @@ def create_parties():
customer.append("accounts", {"company": "_Test Company", "account": "_Test Receivable USD - _TC"})
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"):
customer = frappe.new_doc("Customer")
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":
return tax_row, tax_deducted_on_advances, voucher_wise_amount
else:
@@ -302,6 +305,10 @@ def get_tax_amount(party_type, parties, inv, tax_details, posting_date, pan_no=N
tax_amount = 0
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)
if tax_deducted:
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
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"):
doctype = "Purchase Invoice" if party_type == "Supplier" else "Sales Invoice"
field = (

View File

@@ -61,6 +61,49 @@ class TestTaxWithholdingCategory(FrappeTestCase):
for d in reversed(invoices):
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):
invoices = []
frappe.db.set_value(
@@ -1061,6 +1104,16 @@ def create_tax_withholding_category_records():
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(
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
args = {party_type.lower(): party, "company": company}
args = {frappe.scrub(party_type): party, "company": company}
if tax_category:
args["tax_category"] = tax_category
@@ -696,10 +696,10 @@ def set_taxes(
else:
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"})
if party_type in ["Lead", "Prospect"]:
if party_type in ["Lead", "Prospect", "CRM Deal"]:
args["customer"] = None
del args[frappe.scrub(party_type)]
else:

View File

@@ -510,12 +510,16 @@ def get_accounting_entries(
.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":
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.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")
else:
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):
conditions = []
ignore_is_opening = frappe.db.get_single_value(
"Accounts Settings", "ignore_is_opening_check_for_reporting"
)
if filters.get("account"):
filters.account = get_accounts_with_children(filters.account)
if filters.account:
@@ -270,9 +274,15 @@ def get_conditions(filters):
or filters.get("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"):
conditions.append("project in %(project)s")

View File

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

View File

@@ -89,6 +89,10 @@ def get_data(filters):
)
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:
return None
@@ -96,7 +100,7 @@ def get_data(filters):
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
if filters.project:
@@ -114,7 +118,13 @@ def get_data(filters):
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)
data = prepare_data(accounts, filters, parent_children_map, company_currency)
@@ -125,15 +135,15 @@ def get_data(filters):
return data
def get_opening_balances(filters):
balance_sheet_opening = get_rootwise_opening_balances(filters, "Balance Sheet")
pl_opening = get_rootwise_opening_balances(filters, "Profit and Loss")
def get_opening_balances(filters, ignore_is_opening):
balance_sheet_opening = get_rootwise_opening_balances(filters, "Balance Sheet", ignore_is_opening)
pl_opening = get_rootwise_opening_balances(filters, "Profit and Loss", ignore_is_opening)
balance_sheet_opening.update(pl_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 = []
last_period_closing_voucher = ""
@@ -159,16 +169,24 @@ def get_rootwise_opening_balances(filters, report_type):
report_type,
accounting_dimensions,
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
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)
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:
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()
for d in gle:
@@ -187,7 +205,13 @@ def get_rootwise_opening_balances(filters, report_type):
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)
account = frappe.qb.DocType("Account")
@@ -223,11 +247,16 @@ def get_opening_balance(
(closing_balance.posting_date >= start_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:
opening_balance = opening_balance.where(
(closing_balance.posting_date < filters.from_date) | (closing_balance.is_opening == "Yes")
)
if not ignore_is_opening:
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":
opening_balance = opening_balance.where(closing_balance.is_cancelled == 0)
@@ -298,7 +327,7 @@ def get_opening_balance(
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 = {
"opening_debit": 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)
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["credit"] += flt(entry.credit)

View File

@@ -110,7 +110,7 @@ def validate_returned_items(doc):
for d in doc.get("items"):
key = d.item_code
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"
if d.get(field):
key = (d.item_code, d.get(field))
@@ -259,7 +259,7 @@ def get_already_returned_items(doc):
)
data = frappe.db.sql(
f"""
select {column}, {field}
select {column}, child.{field}
from
`tab{doc.doctype} Item` child, `tab{doc.doctype}` par
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):
if not qty_field:
qty_field = "qty"
qty_field = "stock_qty"
if not warehouse_field:
warehouse_field = "warehouse"
@@ -1109,7 +1109,7 @@ def make_serial_batch_bundle_for_return(data, child_doc, parent_doc, warehouse_f
warehouse_field = "warehouse"
if not qty_field:
qty_field = "qty"
qty_field = "stock_qty"
warehouse = child_doc.get(warehouse_field)
if parent_doc.get("is_internal_customer"):

View File

@@ -30,6 +30,11 @@ class calculate_taxes_and_totals:
"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")
get_round_off_applicable_accounts(self.doc.company, frappe.flags.round_off_applicable_accounts)

View File

@@ -17,7 +17,9 @@
"quotation_section",
"default_valid_till",
"section_break_13",
"carry_forward_communication_and_comments"
"carry_forward_communication_and_comments",
"column_break_junk",
"update_timestamp_on_new_communication"
],
"fields": [
{
@@ -77,7 +79,7 @@
{
"fieldname": "section_break_13",
"fieldtype": "Section Break",
"label": "Other Settings"
"label": "Activity"
},
{
"default": "0",
@@ -85,13 +87,24 @@
"fieldname": "carry_forward_communication_and_comments",
"fieldtype": "Check",
"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",
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2022-06-06 11:22:08.464253",
"modified": "2025-01-16 16:12:14.889455",
"modified_by": "Administrator",
"module": "CRM",
"name": "CRM Settings",

View File

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

View File

@@ -84,6 +84,20 @@ def link_communications_with_prospect(communication, method):
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):
prospect = None
if reference_doctype == "Lead":

View File

@@ -29,7 +29,7 @@ frappe.ui.form.on("Plaid Settings", {
"Bank Transaction",
"",
true,
"Bank Transaction"
__("Bank Transaction")
);
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.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": {
"after_insert": "erpnext.crm.utils.link_events_with_prospect",

View File

@@ -43,6 +43,7 @@ class BlanketOrder(Document):
def validate(self):
self.validate_dates()
self.validate_duplicate_items()
self.validate_item_qty()
self.set_party_item_code()
def validate_dates(self):
@@ -117,6 +118,11 @@ class BlanketOrder(Document):
for d in self.items:
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()
def make_order(source_name):
@@ -148,7 +154,7 @@ def make_order(source_name):
"doctype": doctype + " Item",
"field_map": {"rate": "blanket_order_rate", "parent": "blanket_order"},
"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:
remaining_qty = item.qty - item.ordered_qty
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(
_(
"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"],
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:
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 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):
rm_item = make_item(
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():
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):
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")
def get_parent_row_no(doc, name):
for row in doc.items:
if row.name == name:
return row.idx
@frappe.whitelist()
def add_item(**kwargs):
if isinstance(kwargs, str):
@@ -418,6 +412,8 @@ def add_sub_assembly(**kwargs):
parent_row_no = ""
if not kwargs.convert_to_sub_assembly:
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(
"items",
{
@@ -426,6 +422,7 @@ def add_sub_assembly(**kwargs):
"uom": item_info.stock_uom,
"fg_item": kwargs.fg_item,
"conversion_factor": 1,
"parent_row_no": parent_row_no,
"fg_reference_id": name,
"stock_qty": bom_item.qty,
"do_not_explode": 1,
@@ -437,9 +434,7 @@ def add_sub_assembly(**kwargs):
parent_row_no = item_row.idx
name = ""
else:
parent_row_no = [row.idx for row in doc.items if row.name == kwargs.fg_reference_id]
if parent_row_no:
parent_row_no = parent_row_no[0]
parent_row_no = get_parent_row_no(doc, kwargs.fg_reference_id)
for row in bom_item.get("items"):
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()
def delete_node(**kwargs):
if isinstance(kwargs, str):

View File

@@ -37,7 +37,7 @@
"capacity_planning_for_days",
"mins_between_operations",
"other_settings_section",
"set_op_cost_and_scrape_from_sub_assemblies",
"set_op_cost_and_scrap_from_sub_assemblies",
"column_break_23",
"make_serial_no_batch_from_work_order"
],
@@ -202,13 +202,6 @@
"fieldtype": "Check",
"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",
"depends_on": "eval: doc.material_consumption",
@@ -243,13 +236,20 @@
"fieldname": "validate_components_quantities_per_bom",
"fieldtype": "Check",
"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",
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2025-01-09 16:02:23.326763",
"modified": "2025-01-13 12:07:03.089977",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Manufacturing Settings",

View File

@@ -34,7 +34,7 @@ class ManufacturingSettings(Document):
mins_between_operations: DF.Int
overproduction_percentage_for_sales_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
validate_components_quantities_per_bom: DF.Check
# end: auto-generated types

View File

@@ -2091,7 +2091,7 @@ class TestWorkOrder(FrappeTestCase):
def test_op_cost_and_scrap_based_on_sub_assemblies(self):
# 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 = {
"Test Final FG Item": 0,
@@ -2132,7 +2132,7 @@ class TestWorkOrder(FrappeTestCase):
for row in se_doc.additional_costs:
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(
"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))
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):
kwargs = frappe._dict(kwargs)

View File

@@ -348,7 +348,7 @@ class WorkOrder(Document):
if flt(self.material_transferred_for_manufacturing) > 0:
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):
status = "Completed"
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.v14_0.create_accounting_dimensions_for_closing_balance
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)
erpnext.patches.v14_0.update_reference_type_in_journal_entry_accounts
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.update_cc_in_process_statement_of_accounts
erpnext.patches.v15_0.update_asset_status_to_work_in_progress
erpnext.patches.v15_0.rename_manufacturing_settings_field
erpnext.patches.v15_0.migrate_checkbox_to_select_for_reconciliation_effect
erpnext.patches.v15_0.sync_auto_reconcile_config

View File

@@ -1,6 +1,7 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
import itertools
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 (
get_accounting_dimensions,
)
from erpnext.accounts.utils import get_fiscal_year
def execute():
# clear balances, they will be recalculated
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"):
i = 0
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)
for _, pcvs in itertools.groupby(pcv_list, key=lambda pcv: (pcv.company, pcv.period_start_date)):
process_grouped_pcvs(list(pcvs), gl_entries)
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():
default_diemnsion_fields = ["cost_center", "finance_book", "project"]
accounting_dimension_fields = get_accounting_dimensions()
gle_fields = [
return [
"name",
"company",
"posting_date",
@@ -47,43 +105,11 @@ def get_gle_fields():
"credit",
"debit_in_account_currency",
"credit_in_account_currency",
*default_diemnsion_fields,
*accounting_dimension_fields,
"voucher_no",
# 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.query_builder import Interval
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 erpnext import get_default_company
@@ -341,24 +341,19 @@ class Project(Document):
frappe.db.set_value("Project", new_name, "copied_from", new_name)
def send_welcome_email(self):
url = get_url(f"/project/?name={self.name}")
messages = (
_("You have been invited to collaborate on the project: {0}").format(self.name),
url,
_("Join"),
)
label = f"{self.project_name} ({self.name})"
url = get_link_to_form(self.doctype, self.name, label)
content = """
<p>{0}.</p>
<p><a href="{1}">{2}</a></p>
"""
content = "<p>{}</p>".format(
_("You have been invited to collaborate on the project: {0}").format(url)
)
for user in self.users:
if user.welcome_email_sent == 0:
frappe.sendmail(
user.user,
subject=_("Project Collaboration Invitation"),
content=content.format(*messages),
content=content,
)
user.welcome_email_sent = 1

View File

@@ -10,6 +10,7 @@ from frappe import _
from frappe.query_builder import Interval
from frappe.query_builder.functions import Count, CurDate, UnixTimestamp
from frappe.utils import flt
from frappe.utils.data import get_url_to_list
from frappe.utils.nestedset import NestedSet, get_root_of
from erpnext import get_default_currency
@@ -42,6 +43,9 @@ class SalesPerson(NestedSet):
nsm_parent_field = "parent_sales_person"
def validate(self):
if not self.enabled:
self.validate_sales_person()
if not self.parent_sales_person:
self.parent_sales_person = get_root_of("Sales Person")
@@ -83,6 +87,25 @@ class SalesPerson(NestedSet):
super().on_update()
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):
if self.employee:
user = frappe.db.get_value("Employee", self.employee, "user_id")

View File

@@ -4,11 +4,15 @@
import frappe
from frappe import _
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 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():
frappe.clear_cache()
@@ -45,6 +49,14 @@ def before_tests():
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()
def get_exchange_rate(from_currency, to_currency, transaction_date=None, args=None):
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:
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()
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")
req_params = {
"transaction_date": transaction_date,
"from_currency": from_currency,
"to_currency": to_currency,
"from_currency": from_currency if from_currency != "AED" else "USD",
"to_currency": to_currency if to_currency != "AED" else "USD",
}
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:
value = value[format_ces_api(str(res_key.key), req_params)]
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)
except Exception:
frappe.log_error("Unable to fetch exchange rate")

View File

@@ -74,9 +74,8 @@ class PickList(Document):
def validate(self):
self.validate_for_qty()
if self.pick_manually and self.get("locations"):
self.validate_stock_qty()
self.check_serial_no_status()
self.validate_stock_qty()
self.check_serial_no_status()
def before_save(self):
self.update_status()
@@ -90,14 +89,24 @@ class PickList(Document):
from erpnext.stock.doctype.batch.batch import get_batch_qty
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)
if row.qty > batch_qty:
if row.picked_qty > batch_qty:
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}."
).format(row.idx, row.item_code, batch_qty, row.batch_no, bold(row.warehouse)),
"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.picked_qty,
row.item_code,
batch_qty,
row.batch_no,
bold(row.warehouse),
),
title=_("Insufficient Stock"),
)
@@ -109,11 +118,11 @@ class PickList(Document):
"actual_qty",
)
if row.qty > flt(bin_qty):
if row.picked_qty > flt(bin_qty):
frappe.throw(
_(
"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"),
)
@@ -429,7 +438,14 @@ class PickList(Document):
locations_replica = self.get("locations")
# 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()
for item_doc in items:
item_code = item_doc.item_code
@@ -499,6 +515,9 @@ class PickList(Document):
# aggregate qty for same item
item_map = OrderedDict()
for item in locations:
if item.picked_qty:
continue
if not item.item_code:
frappe.throw(f"Row #{item.idx}: Item Code is Mandatory")
if not cint(

View File

@@ -870,7 +870,7 @@ class TestPickList(FrappeTestCase):
so = make_sales_order(item_code=item, qty=4, rate=100)
pl = create_pick_list(so.name)
self.assertFalse(hasattr(pl, "locations"))
self.assertFalse(pl.locations)
def test_pick_list_validation_for_serial_no(self):
warehouse = "_Test Warehouse - _TC"
@@ -901,7 +901,7 @@ class TestPickList(FrappeTestCase):
so = make_sales_order(item_code=item, qty=4, rate=100)
pl = create_pick_list(so.name)
self.assertFalse(hasattr(pl, "locations"))
self.assertFalse(pl.locations)
def test_pick_list_validation_for_batch_no(self):
warehouse = "_Test Warehouse - _TC"
@@ -937,7 +937,7 @@ class TestPickList(FrappeTestCase):
so = make_sales_order(item_code=item, qty=4, rate=100)
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):
warehouse = "_Test Warehouse - _TC"
@@ -977,7 +977,7 @@ class TestPickList(FrappeTestCase):
so = make_sales_order(item_code=item, qty=4, rate=100)
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):
warehouse = "_Test Warehouse - _TC"
@@ -1172,6 +1172,7 @@ class TestPickList(FrappeTestCase):
for row in pl.locations:
row.qty = row.qty + 10
row.picked_qty = row.qty
self.assertRaises(frappe.ValidationError, pl.save)
@@ -1266,3 +1267,42 @@ class TestPickList(FrappeTestCase):
delivery_note = create_delivery_note(pl.name)
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;
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) {
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 {
filters: {
docstatus: ["!=", 2],
},
filters: filters,
};
});

View File

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

View File

@@ -30,6 +30,7 @@ class QualityInspection(Document):
batch_no: DF.Link | None
bom_no: DF.Link | None
child_row_reference: DF.Data | None
company: DF.Link | None
description: DF.SmallText | None
inspected_by: DF.Link
inspection_type: DF.Literal["", "Incoming", "Outgoing", "In Process"]
@@ -76,6 +77,13 @@ class QualityInspection(Document):
self.validate_inspection_required()
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):
if self.child_row_reference:

View File

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

View File

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

View File

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

View File

@@ -1802,7 +1802,7 @@ class TestStockEntry(FrappeTestCase):
for serial_no in serial_nos:
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):
item = make_item(

View File

@@ -37,7 +37,7 @@
}
],
"links": [],
"modified": "2024-08-24 16:00:22.696958",
"modified": "2025-01-15 16:00:22.696958",
"modified_by": "Administrator",
"module": "Stock",
"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:
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:
age = flt(date_diff(to_date, item[1]))
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):
if age <= flt(age_limit):
i *= 2
range_values[i] = flt(range_values[i] + qty, precision)
range_values[i + 1] = flt(range_values[i + 1] + stock_value, precision)
break
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
@@ -199,6 +203,7 @@ def setup_ageing_columns(filters: Filters, range_columns: list):
for i, label in enumerate(ranges):
fieldname = "range" + str(i + 1)
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):
@@ -298,16 +303,22 @@ class FIFOSlots:
# neutralize 0/negative stock by adding positive stock
fifo_queue[0][0] += flt(row.actual_qty)
fifo_queue[0][1] = row.posting_date
fifo_queue[0][2] += flt(row.stock_value_difference)
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
valuation = row.stock_value_difference / row.actual_qty
for serial_no in serial_nos:
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:
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):
"Update FIFO Queue on outward stock."
@@ -316,34 +327,44 @@ class FIFOSlots:
return
qty_to_pop = abs(row.actual_qty)
stock_value = abs(row.stock_value_difference)
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:
# qty to pop >= slot qty
# if +ve and not enough or exactly same balance in current slot, consume whole slot
qty_to_pop -= flt(slot[0])
stock_value -= flt(slot[2])
self.transferred_item_details[transfer_key].append(fifo_queue.pop(0))
elif not fifo_queue:
# negative stock, no balance but qty yet to consume
fifo_queue.append([-(qty_to_pop), row.posting_date])
self.transferred_item_details[transfer_key].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, stock_value]
)
qty_to_pop = 0
stock_value = 0
else:
# qty to pop < slot qty, ample balance
# consume actual_qty from first slot
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
stock_value = 0
def __adjust_incoming_transfer_qty(self, transfer_data: dict, fifo_queue: list, row: dict):
"Add previously removed stock back to FIFO Queue."
transfer_qty_to_pop = flt(row.actual_qty)
stock_value = flt(row.stock_value_difference)
def add_to_fifo_queue(slot):
if fifo_queue and flt(fifo_queue[0][0]) <= 0:
# neutralize 0/negative stock by adding positive stock
fifo_queue[0][0] += flt(slot[0])
fifo_queue[0][1] = slot[1]
fifo_queue[0][2] += flt(slot[2])
else:
fifo_queue.append(slot)
@@ -351,16 +372,20 @@ class FIFOSlots:
if transfer_data and 0 < transfer_data[0][0] <= transfer_qty_to_pop:
# bucket qty is not enough, consume whole
transfer_qty_to_pop -= transfer_data[0][0]
stock_value -= transfer_data[0][2]
add_to_fifo_queue(transfer_data.pop(0))
elif not transfer_data:
# 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
stock_value = 0
else:
# ample bucket qty to consume
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
stock_value = 0
def __update_balances(self, row: dict, key: tuple | str):
self.item_details[key]["qty_after_transaction"] = row.qty_after_transaction
@@ -412,6 +437,7 @@ class FIFOSlots:
item.stock_uom,
item.has_serial_no,
sle.actual_qty,
sle.stock_value_difference,
sle.posting_date,
sle.voucher_type,
sle.voucher_no,

View File

@@ -18,6 +18,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item",
actual_qty=30,
qty_after_transaction=30,
stock_value_difference=30,
warehouse="WH 1",
posting_date="2021-12-01",
voucher_type="Stock Entry",
@@ -29,6 +30,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item",
actual_qty=20,
qty_after_transaction=50,
stock_value_difference=20,
warehouse="WH 1",
posting_date="2021-12-02",
voucher_type="Stock Entry",
@@ -40,6 +42,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item",
actual_qty=(-10),
qty_after_transaction=40,
stock_value_difference=(-10),
warehouse="WH 1",
posting_date="2021-12-03",
voucher_type="Stock Entry",
@@ -57,6 +60,8 @@ class TestStockAgeing(FrappeTestCase):
self.assertEqual(result["qty_after_transaction"], result["total_qty"])
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):
"Reference: Case 3 in stock_ageing_fifo_logic.md (same wh)"
@@ -65,6 +70,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item",
actual_qty=(-30),
qty_after_transaction=(-30),
stock_value_difference=0,
warehouse="WH 1",
posting_date="2021-12-01",
voucher_type="Stock Entry",
@@ -76,6 +82,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item",
actual_qty=20,
qty_after_transaction=(-10),
stock_value_difference=0,
warehouse="WH 1",
posting_date="2021-12-02",
voucher_type="Stock Entry",
@@ -87,6 +94,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item",
actual_qty=20,
qty_after_transaction=10,
stock_value_difference=0,
warehouse="WH 1",
posting_date="2021-12-03",
voucher_type="Stock Entry",
@@ -98,6 +106,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item",
actual_qty=10,
qty_after_transaction=20,
stock_value_difference=0,
warehouse="WH 1",
posting_date="2021-12-03",
voucher_type="Stock Entry",
@@ -126,6 +135,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item",
actual_qty=30,
qty_after_transaction=30,
stock_value_difference=0,
warehouse="WH 1",
posting_date="2021-12-01",
voucher_type="Stock Entry",
@@ -137,6 +147,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item",
actual_qty=0,
qty_after_transaction=50,
stock_value_difference=0,
warehouse="WH 1",
posting_date="2021-12-02",
voucher_type="Stock Reconciliation",
@@ -148,6 +159,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item",
actual_qty=(-10),
qty_after_transaction=40,
stock_value_difference=0,
warehouse="WH 1",
posting_date="2021-12-03",
voucher_type="Stock Entry",
@@ -178,6 +190,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item",
actual_qty=0,
qty_after_transaction=1000,
stock_value_difference=0,
warehouse="WH 1",
posting_date="2021-12-01",
voucher_type="Stock Reconciliation",
@@ -189,6 +202,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item",
actual_qty=0,
qty_after_transaction=400,
stock_value_difference=0,
warehouse="WH 1",
posting_date="2021-12-02",
voucher_type="Stock Reconciliation",
@@ -200,6 +214,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item",
actual_qty=(-10),
qty_after_transaction=390,
stock_value_difference=0,
warehouse="WH 1",
posting_date="2021-12-03",
voucher_type="Stock Entry",
@@ -233,6 +248,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item",
actual_qty=0,
qty_after_transaction=1000,
stock_value_difference=0,
warehouse="WH 1",
posting_date="2021-12-01",
voucher_type="Stock Reconciliation",
@@ -244,6 +260,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item",
actual_qty=0,
qty_after_transaction=400,
stock_value_difference=0,
warehouse="WH 2",
posting_date="2021-12-02",
voucher_type="Stock Reconciliation",
@@ -255,6 +272,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item",
actual_qty=(-10),
qty_after_transaction=990,
stock_value_difference=0,
warehouse="WH 1",
posting_date="2021-12-03",
voucher_type="Stock Entry",
@@ -301,6 +319,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item",
actual_qty=500,
qty_after_transaction=500,
stock_value_difference=0,
warehouse="WH 1",
posting_date="2021-12-03",
voucher_type="Stock Entry",
@@ -312,6 +331,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item",
actual_qty=(-50),
qty_after_transaction=450,
stock_value_difference=0,
warehouse="WH 1",
posting_date="2021-12-04",
voucher_type="Stock Entry",
@@ -323,6 +343,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item",
actual_qty=(-50),
qty_after_transaction=400,
stock_value_difference=0,
warehouse="WH 1",
posting_date="2021-12-04",
voucher_type="Stock Entry",
@@ -334,6 +355,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item",
actual_qty=100,
qty_after_transaction=500,
stock_value_difference=0,
warehouse="WH 1",
posting_date="2021-12-04",
voucher_type="Stock Entry",
@@ -370,6 +392,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item",
actual_qty=500,
qty_after_transaction=500,
stock_value_difference=0,
warehouse="WH 1",
posting_date="2021-12-03",
voucher_type="Stock Entry",
@@ -381,6 +404,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item",
actual_qty=(-100),
qty_after_transaction=400,
stock_value_difference=0,
warehouse="WH 1",
posting_date="2021-12-04",
voucher_type="Stock Entry",
@@ -392,6 +416,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item",
actual_qty=50,
qty_after_transaction=450,
stock_value_difference=0,
warehouse="WH 1",
posting_date="2021-12-04",
voucher_type="Stock Entry",
@@ -426,6 +451,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item",
actual_qty=20,
qty_after_transaction=20,
stock_value_difference=0,
warehouse="WH 1",
posting_date="2021-12-03",
voucher_type="Stock Entry",
@@ -437,6 +463,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item",
actual_qty=(-50),
qty_after_transaction=(-30),
stock_value_difference=0,
warehouse="WH 1",
posting_date="2021-12-04",
voucher_type="Stock Entry",
@@ -448,6 +475,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item",
actual_qty=(-50),
qty_after_transaction=(-80),
stock_value_difference=0,
warehouse="WH 1",
posting_date="2021-12-04",
voucher_type="Stock Entry",
@@ -459,6 +487,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item",
actual_qty=50,
qty_after_transaction=(-30),
stock_value_difference=0,
warehouse="WH 1",
posting_date="2021-12-04",
voucher_type="Stock Entry",
@@ -496,6 +525,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item",
actual_qty=500,
qty_after_transaction=500,
stock_value_difference=0,
warehouse="WH 1",
posting_date="2021-12-03",
voucher_type="Stock Entry",
@@ -507,6 +537,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item",
actual_qty=(-50),
qty_after_transaction=450,
stock_value_difference=0,
warehouse="WH 1",
posting_date="2021-12-04",
voucher_type="Stock Entry",
@@ -518,6 +549,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item",
actual_qty=100,
qty_after_transaction=550,
stock_value_difference=0,
warehouse="WH 1",
posting_date="2021-12-04",
voucher_type="Stock Entry",
@@ -553,6 +585,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item",
actual_qty=20,
qty_after_transaction=20,
stock_value_difference=0,
warehouse="WH 1",
posting_date="2021-12-03",
voucher_type="Stock Entry",
@@ -564,6 +597,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item",
actual_qty=(-50),
qty_after_transaction=(-30),
stock_value_difference=0,
warehouse="WH 1",
posting_date="2021-12-04",
voucher_type="Stock Entry",
@@ -575,6 +609,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item",
actual_qty=50,
qty_after_transaction=20,
stock_value_difference=0,
warehouse="WH 1",
posting_date="2021-12-04",
voucher_type="Stock Entry",
@@ -586,6 +621,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item",
actual_qty=50,
qty_after_transaction=70,
stock_value_difference=0,
warehouse="WH 1",
posting_date="2021-12-04",
voucher_type="Stock Entry",
@@ -623,6 +659,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item",
actual_qty=(-50),
qty_after_transaction=(-50),
stock_value_difference=0,
warehouse="WH 1",
posting_date="2021-12-01",
voucher_type="Stock Entry",
@@ -634,6 +671,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item",
actual_qty=(-50),
qty_after_transaction=(-100),
stock_value_difference=0,
warehouse="WH 1",
posting_date="2021-12-01",
voucher_type="Stock Entry",
@@ -645,6 +683,7 @@ class TestStockAgeing(FrappeTestCase):
name="Flask Item",
actual_qty=30,
qty_after_transaction=(-70),
stock_value_difference=0,
warehouse="WH 1",
posting_date="2021-12-01",
voucher_type="Stock Entry",
@@ -722,6 +761,113 @@ class TestStockAgeing(FrappeTestCase):
self.assertEqual(bal_qty, 0.9)
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):
"Return results with and without 'show_warehouse_wise_stock'"

View File

@@ -351,6 +351,15 @@ class SerialBatchBundle:
status = "Inactive"
if self.sle.actual_qty < 0:
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")

View File

@@ -622,15 +622,12 @@ class update_entries_after:
if sle.dependant_sle_voucher_detail_no:
entries_to_fix = self.get_dependent_entries_to_fix(entries_to_fix, sle)
if self.has_stock_reco_with_serial_batch(sle):
break
if self.exceptions:
self.raise_exceptions()
def has_stock_reco_with_serial_batch(self, sle):
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
):
return not (sle.batch_no or sle.serial_no or sle.serial_and_batch_bundle)