mirror of
https://github.com/frappe/erpnext.git
synced 2026-04-17 05:45:11 +00:00
Merge pull request #52597 from frappe/version-16-hotfix
chore: release v16
This commit is contained in:
@@ -50,6 +50,7 @@
|
||||
"fieldname": "amount",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Amount",
|
||||
"options": "currency",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
|
||||
@@ -541,7 +541,7 @@ class FinancialQueryBuilder:
|
||||
.where(acb_table.period_closing_voucher == closing_voucher)
|
||||
)
|
||||
|
||||
query = self._apply_standard_filters(query, acb_table)
|
||||
query = self._apply_standard_filters(query, acb_table, "Account Closing Balance")
|
||||
results = self._execute_with_permissions(query, "Account Closing Balance")
|
||||
|
||||
for row in results:
|
||||
@@ -636,12 +636,15 @@ class FinancialQueryBuilder:
|
||||
return self._execute_with_permissions(query, "GL Entry")
|
||||
|
||||
def _calculate_running_balances(self, balances_data: dict, gl_data: list[dict]) -> dict:
|
||||
for row in gl_data:
|
||||
account = row["account"]
|
||||
gl_dict = {row["account"]: row for row in gl_data}
|
||||
accounts = set(balances_data.keys()) | set(gl_dict.keys())
|
||||
|
||||
for account in accounts:
|
||||
if account not in balances_data:
|
||||
balances_data[account] = AccountData(account=account, **self._get_account_meta(account))
|
||||
|
||||
account_data: AccountData = balances_data[account]
|
||||
gl_movement = gl_dict.get(account, {})
|
||||
|
||||
if account_data.has_periods():
|
||||
first_period = account_data.get_period(self.periods[0]["key"])
|
||||
@@ -651,20 +654,13 @@ class FinancialQueryBuilder:
|
||||
|
||||
for period in self.periods:
|
||||
period_key = period["key"]
|
||||
movement = row.get(period_key, 0.0)
|
||||
movement = gl_movement.get(period_key, 0.0)
|
||||
closing_balance = current_balance + movement
|
||||
|
||||
account_data.add_period(PeriodValue(period_key, current_balance, closing_balance, movement))
|
||||
|
||||
current_balance = closing_balance
|
||||
|
||||
# Accounts with no movements
|
||||
for account_data in balances_data.values():
|
||||
for period in self.periods:
|
||||
period_key = period["key"]
|
||||
if period_key not in account_data.period_values:
|
||||
account_data.add_period(PeriodValue(period_key, 0.0, 0.0, 0.0))
|
||||
|
||||
def _handle_balance_accumulation(self, balances_data):
|
||||
for account_data in balances_data.values():
|
||||
account_data: AccountData
|
||||
@@ -683,12 +679,12 @@ class FinancialQueryBuilder:
|
||||
else:
|
||||
account_data.unaccumulate_values()
|
||||
|
||||
def _apply_standard_filters(self, query, table):
|
||||
def _apply_standard_filters(self, query, table, doctype: str = "GL Entry"):
|
||||
if self.filters.get("ignore_closing_entries"):
|
||||
if hasattr(table, "is_period_closing_voucher_entry"):
|
||||
query = query.where(table.is_period_closing_voucher_entry == 0)
|
||||
else:
|
||||
if doctype == "GL Entry":
|
||||
query = query.where(table.voucher_type != "Period Closing Voucher")
|
||||
else:
|
||||
query = query.where(table.is_period_closing_voucher_entry == 0)
|
||||
|
||||
if self.filters.get("project"):
|
||||
projects = self.filters.get("project")
|
||||
|
||||
@@ -16,7 +16,8 @@ from erpnext.accounts.doctype.financial_report_template.financial_report_engine
|
||||
from erpnext.accounts.doctype.financial_report_template.test_financial_report_template import (
|
||||
FinancialReportTemplateTestCase,
|
||||
)
|
||||
from erpnext.accounts.utils import get_currency_precision
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
from erpnext.accounts.utils import get_currency_precision, get_fiscal_year
|
||||
|
||||
# On IntegrationTestCase, the doctype test records and all
|
||||
# link-field test record dependencies are recursively loaded
|
||||
@@ -1672,3 +1673,360 @@ class TestFilterExpressionParser(FinancialReportTemplateTestCase):
|
||||
mock_row_invalid = self._create_mock_report_row(invalid_formula)
|
||||
condition = parser.build_condition(mock_row_invalid, account_table)
|
||||
self.assertIsNone(condition)
|
||||
|
||||
|
||||
class TestFinancialQueryBuilder(FinancialReportTemplateTestCase):
|
||||
def test_fetch_balances_with_journal_entries(self):
|
||||
company = "_Test Company"
|
||||
cash_account = "_Test Cash - _TC"
|
||||
bank_account = "_Test Bank - _TC"
|
||||
|
||||
# Create journal entries in different periods
|
||||
# October: Transfer 1000 from Bank to Cash
|
||||
jv_oct = make_journal_entry(
|
||||
account1=cash_account,
|
||||
account2=bank_account,
|
||||
amount=1000,
|
||||
posting_date="2024-10-15",
|
||||
company=company,
|
||||
submit=True,
|
||||
)
|
||||
|
||||
# November: Transfer 500 from Bank to Cash
|
||||
jv_nov = make_journal_entry(
|
||||
account1=cash_account,
|
||||
account2=bank_account,
|
||||
amount=500,
|
||||
posting_date="2024-11-20",
|
||||
company=company,
|
||||
submit=True,
|
||||
)
|
||||
|
||||
# December: No transactions (test zero movement period)
|
||||
|
||||
try:
|
||||
# Set up filters and periods for Q4 2024
|
||||
filters = {
|
||||
"company": company,
|
||||
"from_fiscal_year": "2024",
|
||||
"to_fiscal_year": "2024",
|
||||
"period_start_date": "2024-10-01",
|
||||
"period_end_date": "2024-12-31",
|
||||
"filter_based_on": "Date Range",
|
||||
"periodicity": "Monthly",
|
||||
}
|
||||
|
||||
periods = [
|
||||
{"key": "2024_oct", "from_date": "2024-10-01", "to_date": "2024-10-31"},
|
||||
{"key": "2024_nov", "from_date": "2024-11-01", "to_date": "2024-11-30"},
|
||||
{"key": "2024_dec", "from_date": "2024-12-01", "to_date": "2024-12-31"},
|
||||
]
|
||||
|
||||
query_builder = FinancialQueryBuilder(filters, periods)
|
||||
|
||||
# Create account objects as expected by fetch_account_balances
|
||||
accounts = [
|
||||
frappe._dict({"name": cash_account, "account_name": "Cash", "account_number": "1001"}),
|
||||
frappe._dict({"name": bank_account, "account_name": "Bank", "account_number": "1002"}),
|
||||
]
|
||||
|
||||
# Fetch balances using the full workflow
|
||||
balances_data = query_builder.fetch_account_balances(accounts)
|
||||
|
||||
# Verify Cash account balances
|
||||
cash_data = balances_data.get(cash_account)
|
||||
self.assertIsNotNone(cash_data, "Cash account should exist in results")
|
||||
|
||||
# October: movement = +1000 (debit)
|
||||
oct_cash = cash_data.get_period("2024_oct")
|
||||
self.assertIsNotNone(oct_cash, "October period should exist for cash")
|
||||
self.assertEqual(oct_cash.movement, 1000.0, "October cash movement should be 1000")
|
||||
|
||||
# November: movement = +500
|
||||
nov_cash = cash_data.get_period("2024_nov")
|
||||
self.assertIsNotNone(nov_cash, "November period should exist for cash")
|
||||
self.assertEqual(nov_cash.movement, 500.0, "November cash movement should be 500")
|
||||
self.assertEqual(
|
||||
nov_cash.opening, oct_cash.closing, "November opening should equal October closing"
|
||||
)
|
||||
|
||||
# December: movement = 0 (no transactions)
|
||||
dec_cash = cash_data.get_period("2024_dec")
|
||||
self.assertIsNotNone(dec_cash, "December period should exist for cash")
|
||||
self.assertEqual(dec_cash.movement, 0.0, "December cash movement should be 0")
|
||||
self.assertEqual(
|
||||
dec_cash.closing,
|
||||
nov_cash.closing,
|
||||
"December closing should equal November closing when no movement",
|
||||
)
|
||||
|
||||
# Verify Bank account balances (opposite direction)
|
||||
bank_data = balances_data.get(bank_account)
|
||||
self.assertIsNotNone(bank_data, "Bank account should exist in results")
|
||||
|
||||
oct_bank = bank_data.get_period("2024_oct")
|
||||
self.assertEqual(oct_bank.movement, -1000.0, "October bank movement should be -1000")
|
||||
|
||||
nov_bank = bank_data.get_period("2024_nov")
|
||||
self.assertEqual(nov_bank.movement, -500.0, "November bank movement should be -500")
|
||||
|
||||
finally:
|
||||
# Clean up: cancel journal entries
|
||||
jv_nov.cancel()
|
||||
jv_oct.cancel()
|
||||
|
||||
def test_opening_balance_from_previous_period_closing(self):
|
||||
company = "_Test Company"
|
||||
cash_account = "_Test Cash - _TC"
|
||||
sales_account = "Sales - _TC"
|
||||
posting_date_2023 = "2023-06-15"
|
||||
|
||||
# Create journal entry in prior period (2023)
|
||||
# Cash Dr 5000, Sales Cr 5000
|
||||
jv_2023 = make_journal_entry(
|
||||
account1=cash_account,
|
||||
account2=sales_account,
|
||||
amount=5000,
|
||||
posting_date=posting_date_2023,
|
||||
company=company,
|
||||
submit=True,
|
||||
)
|
||||
|
||||
pcv = None
|
||||
jv_2024 = None
|
||||
original_pcv_setting = frappe.db.get_single_value(
|
||||
"Accounts Settings", "use_legacy_controller_for_pcv"
|
||||
)
|
||||
|
||||
try:
|
||||
# Create Period Closing Voucher for 2023
|
||||
# This will create Account Closing Balance entries
|
||||
closing_account = frappe.db.get_value(
|
||||
"Account",
|
||||
{
|
||||
"company": company,
|
||||
"root_type": "Liability",
|
||||
"is_group": 0,
|
||||
"account_type": ["not in", ["Payable", "Receivable"]],
|
||||
},
|
||||
"name",
|
||||
)
|
||||
|
||||
fy_2023 = get_fiscal_year(posting_date_2023, company=company)
|
||||
|
||||
frappe.db.set_single_value("Accounts Settings", "use_legacy_controller_for_pcv", 1)
|
||||
|
||||
pcv = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Period Closing Voucher",
|
||||
"transaction_date": "2023-12-31",
|
||||
"period_start_date": fy_2023[1],
|
||||
"period_end_date": fy_2023[2],
|
||||
"company": company,
|
||||
"fiscal_year": fy_2023[0],
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"closing_account_head": closing_account,
|
||||
"remarks": "Test Period Closing",
|
||||
}
|
||||
)
|
||||
pcv.insert()
|
||||
pcv.submit()
|
||||
pcv.reload()
|
||||
|
||||
# Now create a small transaction in 2024 to ensure the account appears
|
||||
jv_2024 = make_journal_entry(
|
||||
account1=cash_account,
|
||||
account2=sales_account,
|
||||
amount=100,
|
||||
posting_date="2024-01-15",
|
||||
company=company,
|
||||
submit=True,
|
||||
)
|
||||
|
||||
# Set up filters for Q1 2024 (after the period closing)
|
||||
filters = {
|
||||
"company": company,
|
||||
"from_fiscal_year": "2024",
|
||||
"to_fiscal_year": "2024",
|
||||
"period_start_date": "2024-01-01",
|
||||
"period_end_date": "2024-03-31",
|
||||
"filter_based_on": "Date Range",
|
||||
"periodicity": "Monthly",
|
||||
"ignore_closing_entries": True, # Don't include PCV entries in movements
|
||||
}
|
||||
|
||||
periods = [
|
||||
{"key": "2024_jan", "from_date": "2024-01-01", "to_date": "2024-01-31"},
|
||||
{"key": "2024_feb", "from_date": "2024-02-01", "to_date": "2024-02-29"},
|
||||
{"key": "2024_mar", "from_date": "2024-03-01", "to_date": "2024-03-31"},
|
||||
]
|
||||
|
||||
query_builder = FinancialQueryBuilder(filters, periods)
|
||||
|
||||
accounts = [
|
||||
frappe._dict({"name": cash_account, "account_name": "Cash", "account_number": "1001"}),
|
||||
]
|
||||
|
||||
balances_data = query_builder.fetch_account_balances(accounts)
|
||||
|
||||
# Verify Cash account has opening balance from 2023 transactions
|
||||
cash_data = balances_data.get(cash_account)
|
||||
self.assertIsNotNone(cash_data, "Cash account should exist in results")
|
||||
|
||||
jan_cash = cash_data.get_period("2024_jan")
|
||||
self.assertIsNotNone(jan_cash, "January period should exist")
|
||||
|
||||
# Opening balance should be from prior period
|
||||
# Cash had 5000 debit in 2023, so opening in 2024 should be >= 5000
|
||||
# (may be higher if there were other test transactions)
|
||||
self.assertEqual(
|
||||
jan_cash.opening,
|
||||
5000.0,
|
||||
"January opening should equal to balance from 2023 (5000)",
|
||||
)
|
||||
|
||||
# Verify running balance logic
|
||||
# Movement in January is 100 (from jv_2024)
|
||||
self.assertEqual(jan_cash.movement, 100.0, "January movement should be 100")
|
||||
self.assertEqual(
|
||||
jan_cash.closing, jan_cash.opening + jan_cash.movement, "Closing = Opening + Movement"
|
||||
)
|
||||
|
||||
# February and March should have no movement but carry the balance
|
||||
feb_cash = cash_data.get_period("2024_feb")
|
||||
self.assertEqual(feb_cash.opening, jan_cash.closing, "Feb opening = Jan closing")
|
||||
self.assertEqual(feb_cash.movement, 0.0, "February should have no movement")
|
||||
self.assertEqual(feb_cash.closing, feb_cash.opening, "Feb closing = opening when no movement")
|
||||
|
||||
mar_cash = cash_data.get_period("2024_mar")
|
||||
self.assertEqual(mar_cash.opening, feb_cash.closing, "Mar opening = Feb closing")
|
||||
self.assertEqual(mar_cash.movement, 0.0, "March should have no movement")
|
||||
self.assertEqual(mar_cash.closing, mar_cash.opening, "Mar closing = opening when no movement")
|
||||
|
||||
# Set up filters for Q2 2024
|
||||
filters_q2 = {
|
||||
"company": company,
|
||||
"from_fiscal_year": "2024",
|
||||
"to_fiscal_year": "2024",
|
||||
"period_start_date": "2024-04-01",
|
||||
"period_end_date": "2024-06-30",
|
||||
"filter_based_on": "Date Range",
|
||||
"periodicity": "Monthly",
|
||||
"ignore_closing_entries": True,
|
||||
}
|
||||
|
||||
periods_q2 = [
|
||||
{"key": "2024_apr", "from_date": "2024-04-01", "to_date": "2024-04-30"},
|
||||
{"key": "2024_may", "from_date": "2024-05-01", "to_date": "2024-05-31"},
|
||||
{"key": "2024_jun", "from_date": "2024-06-01", "to_date": "2024-06-30"},
|
||||
]
|
||||
|
||||
query_builder_q2 = FinancialQueryBuilder(filters_q2, periods_q2)
|
||||
|
||||
balances_data_q2 = query_builder_q2.fetch_account_balances(accounts)
|
||||
|
||||
# Verify Cash account in Q2
|
||||
cash_data_q2 = balances_data_q2.get(cash_account)
|
||||
self.assertIsNotNone(cash_data_q2, "Cash account should exist in Q2 results")
|
||||
|
||||
apr_cash = cash_data_q2.get_period("2024_apr")
|
||||
self.assertIsNotNone(apr_cash, "April period should exist")
|
||||
|
||||
# Opening balance in April should equal closing in March
|
||||
self.assertEqual(
|
||||
apr_cash.opening,
|
||||
mar_cash.closing,
|
||||
"April opening should equal March closing balance",
|
||||
)
|
||||
|
||||
self.assertEqual(apr_cash.closing, apr_cash.opening, "April closing = opening when no movement")
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
frappe.db.set_single_value(
|
||||
"Accounts Settings", "use_legacy_controller_for_pcv", original_pcv_setting or 0
|
||||
)
|
||||
|
||||
if jv_2024:
|
||||
jv_2024.cancel()
|
||||
|
||||
if pcv:
|
||||
pcv.reload()
|
||||
if pcv.docstatus == 1:
|
||||
pcv.cancel()
|
||||
|
||||
jv_2023.cancel()
|
||||
|
||||
def test_account_with_gl_entries_but_no_prior_closing_balance(self):
|
||||
company = "_Test Company"
|
||||
cash_account = "_Test Cash - _TC"
|
||||
bank_account = "_Test Bank - _TC"
|
||||
|
||||
# Create journal entries WITHOUT any prior Period Closing Voucher
|
||||
# This ensures the account exists in gl_dict but NOT in balances_data
|
||||
jv = make_journal_entry(
|
||||
account1=cash_account,
|
||||
account2=bank_account,
|
||||
amount=2500,
|
||||
posting_date="2024-07-15",
|
||||
company=company,
|
||||
submit=True,
|
||||
)
|
||||
|
||||
try:
|
||||
# Set up filters - use a period with no prior PCV
|
||||
filters = {
|
||||
"company": company,
|
||||
"from_fiscal_year": "2024",
|
||||
"to_fiscal_year": "2024",
|
||||
"period_start_date": "2024-07-01",
|
||||
"period_end_date": "2024-09-30",
|
||||
"filter_based_on": "Date Range",
|
||||
"periodicity": "Monthly",
|
||||
}
|
||||
|
||||
periods = [
|
||||
{"key": "2024_jul", "from_date": "2024-07-01", "to_date": "2024-07-31"},
|
||||
{"key": "2024_aug", "from_date": "2024-08-01", "to_date": "2024-08-31"},
|
||||
{"key": "2024_sep", "from_date": "2024-09-01", "to_date": "2024-09-30"},
|
||||
]
|
||||
|
||||
query_builder = FinancialQueryBuilder(filters, periods)
|
||||
|
||||
# Use accounts that have GL entries but may not have Account Closing Balance
|
||||
accounts = [
|
||||
frappe._dict({"name": cash_account, "account_name": "Cash", "account_number": "1001"}),
|
||||
frappe._dict({"name": bank_account, "account_name": "Bank", "account_number": "1002"}),
|
||||
]
|
||||
|
||||
balances_data = query_builder.fetch_account_balances(accounts)
|
||||
|
||||
# Verify accounts are present in results even without prior closing balance
|
||||
cash_data = balances_data.get(cash_account)
|
||||
self.assertIsNotNone(cash_data, "Cash account should exist in results")
|
||||
|
||||
bank_data = balances_data.get(bank_account)
|
||||
self.assertIsNotNone(bank_data, "Bank account should exist in results")
|
||||
|
||||
# Verify July has the movement from journal entry
|
||||
jul_cash = cash_data.get_period("2024_jul")
|
||||
self.assertIsNotNone(jul_cash, "July period should exist for cash")
|
||||
self.assertEqual(jul_cash.movement, 2500.0, "July cash movement should be 2500")
|
||||
|
||||
jul_bank = bank_data.get_period("2024_jul")
|
||||
self.assertIsNotNone(jul_bank, "July period should exist for bank")
|
||||
self.assertEqual(jul_bank.movement, -2500.0, "July bank movement should be -2500")
|
||||
|
||||
# Verify subsequent periods exist with zero movement
|
||||
aug_cash = cash_data.get_period("2024_aug")
|
||||
self.assertIsNotNone(aug_cash, "August period should exist for cash")
|
||||
self.assertEqual(aug_cash.movement, 0.0, "August cash movement should be 0")
|
||||
self.assertEqual(aug_cash.opening, jul_cash.closing, "August opening = July closing")
|
||||
|
||||
sep_cash = cash_data.get_period("2024_sep")
|
||||
self.assertIsNotNone(sep_cash, "September period should exist for cash")
|
||||
self.assertEqual(sep_cash.movement, 0.0, "September cash movement should be 0")
|
||||
self.assertEqual(sep_cash.opening, aug_cash.closing, "September opening = August closing")
|
||||
|
||||
finally:
|
||||
jv.cancel()
|
||||
|
||||
@@ -277,7 +277,21 @@ frappe.ui.form.on("Journal Entry", {
|
||||
var update_jv_details = function (doc, r) {
|
||||
$.each(r, function (i, d) {
|
||||
var row = frappe.model.add_child(doc, "Journal Entry Account", "accounts");
|
||||
frappe.model.set_value(row.doctype, row.name, "account", d.account);
|
||||
const {
|
||||
idx,
|
||||
name,
|
||||
owner,
|
||||
parent,
|
||||
parenttype,
|
||||
parentfield,
|
||||
creation,
|
||||
modified,
|
||||
modified_by,
|
||||
doctype,
|
||||
docstatus,
|
||||
...fields
|
||||
} = d;
|
||||
frappe.model.set_value(row.doctype, row.name, fields);
|
||||
});
|
||||
refresh_field("accounts");
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"entry_type_and_date",
|
||||
"company",
|
||||
"is_system_generated",
|
||||
"title",
|
||||
"voucher_type",
|
||||
@@ -17,7 +18,6 @@
|
||||
"reversal_of",
|
||||
"column_break1",
|
||||
"from_template",
|
||||
"company",
|
||||
"posting_date",
|
||||
"finance_book",
|
||||
"apply_tds",
|
||||
@@ -638,7 +638,7 @@
|
||||
"table_fieldname": "payment_entries"
|
||||
}
|
||||
],
|
||||
"modified": "2025-11-13 17:54:14.542903",
|
||||
"modified": "2026-02-03 14:40:39.944524",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Journal Entry",
|
||||
|
||||
@@ -74,8 +74,8 @@ class JournalEntry(AccountsController):
|
||||
mode_of_payment: DF.Link | None
|
||||
multi_currency: DF.Check
|
||||
naming_series: DF.Literal["ACC-JV-.YYYY.-"]
|
||||
party_not_required: DF.Check
|
||||
override_tax_withholding_entries: DF.Check
|
||||
party_not_required: DF.Check
|
||||
pay_to_recd_from: DF.Data | None
|
||||
payment_order: DF.Link | None
|
||||
periodic_entry_difference_account: DF.Link | None
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
frappe.ui.form.on("Journal Entry Template", {
|
||||
onload: function (frm) {
|
||||
erpnext.accounts.dimensions.setup_dimension_filters(frm, frm.doctype);
|
||||
if (frm.is_new()) {
|
||||
frappe.call({
|
||||
type: "GET",
|
||||
@@ -37,6 +38,31 @@ frappe.ui.form.on("Journal Entry Template", {
|
||||
|
||||
return { filters: filters };
|
||||
});
|
||||
|
||||
frm.set_query("project", "accounts", function (doc, cdt, cdn) {
|
||||
let row = frappe.get_doc(cdt, cdn);
|
||||
let filters = {
|
||||
company: doc.company,
|
||||
};
|
||||
if (row.party_type == "Customer") {
|
||||
filters.customer = row.party;
|
||||
}
|
||||
return {
|
||||
query: "erpnext.controllers.queries.get_project_name",
|
||||
filters,
|
||||
};
|
||||
});
|
||||
|
||||
frm.set_query("party_type", "accounts", function (doc, cdt, cdn) {
|
||||
const row = locals[cdt][cdn];
|
||||
|
||||
return {
|
||||
query: "erpnext.setup.doctype.party_type.party_type.get_party_type",
|
||||
filters: {
|
||||
account: row.account,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
voucher_type: function (frm) {
|
||||
var add_accounts = function (doc, r) {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
@@ -42,7 +43,29 @@ class JournalEntryTemplate(Document):
|
||||
]
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
def validate(self):
|
||||
self.validate_party()
|
||||
|
||||
def validate_party(self):
|
||||
"""
|
||||
Loop over all accounts and see if party and party type is set correctly
|
||||
"""
|
||||
for account in self.accounts:
|
||||
if account.party_type:
|
||||
account_type = frappe.get_cached_value("Account", account.account, "account_type")
|
||||
if account_type not in ["Receivable", "Payable"]:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Check row {0} for account {1}: Party Type is only allowed for Receivable or Payable accounts"
|
||||
).format(account.idx, account.account)
|
||||
)
|
||||
|
||||
if account.party and not account.party_type:
|
||||
frappe.throw(
|
||||
_("Check row {0} for account {1}: Party is only allowed if Party Type is set").format(
|
||||
account.idx, account.account
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
@@ -5,7 +5,13 @@
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"account"
|
||||
"account",
|
||||
"party_type",
|
||||
"party",
|
||||
"accounting_dimensions_section",
|
||||
"cost_center",
|
||||
"dimension_col_break",
|
||||
"project"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -15,18 +21,55 @@
|
||||
"label": "Account",
|
||||
"options": "Account",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "party_type",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Party Type",
|
||||
"options": "DocType"
|
||||
},
|
||||
{
|
||||
"fieldname": "party",
|
||||
"fieldtype": "Dynamic Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Party",
|
||||
"options": "party_type"
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"fieldname": "accounting_dimensions_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Accounting Dimensions"
|
||||
},
|
||||
{
|
||||
"fieldname": "cost_center",
|
||||
"fieldtype": "Link",
|
||||
"label": "Cost Center",
|
||||
"options": "Cost Center"
|
||||
},
|
||||
{
|
||||
"fieldname": "project",
|
||||
"fieldtype": "Link",
|
||||
"label": "Project",
|
||||
"options": "Project"
|
||||
},
|
||||
{
|
||||
"fieldname": "dimension_col_break",
|
||||
"fieldtype": "Column Break"
|
||||
}
|
||||
],
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:09:58.986448",
|
||||
"modified": "2026-01-09 13:16:27.615083",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Journal Entry Template Account",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,9 +16,13 @@ class JournalEntryTemplateAccount(Document):
|
||||
from frappe.types import DF
|
||||
|
||||
account: DF.Link
|
||||
cost_center: DF.Link | None
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
party: DF.DynamicLink | None
|
||||
party_type: DF.Link | None
|
||||
project: DF.Link | None
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
|
||||
@@ -99,8 +99,7 @@ def get_customers_list(pos_profile=None):
|
||||
|
||||
return (
|
||||
frappe.db.sql(
|
||||
f""" select name, customer_name, customer_group,
|
||||
territory, customer_pos_id from tabCustomer where disabled = 0
|
||||
f""" select name, customer_name, customer_group, territory from tabCustomer where disabled = 0
|
||||
and {cond}""",
|
||||
tuple(customer_groups),
|
||||
as_dict=1,
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
"email_append_to": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"company",
|
||||
"title",
|
||||
"naming_series",
|
||||
"supplier",
|
||||
"supplier_name",
|
||||
"tax_id",
|
||||
"company",
|
||||
"column_break_6",
|
||||
"posting_date",
|
||||
"posting_time",
|
||||
@@ -606,6 +606,7 @@
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "eval:doc.items.every((item) => !item.pr_detail)",
|
||||
"fieldname": "update_stock",
|
||||
"fieldtype": "Check",
|
||||
"label": "Update Stock",
|
||||
@@ -1668,7 +1669,7 @@
|
||||
"idx": 204,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-01-29 21:21:53.051193",
|
||||
"modified": "2026-02-05 20:45:16.964500",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Purchase Invoice",
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_import": 1,
|
||||
"allow_rename": 1,
|
||||
"autoname": "naming_series:",
|
||||
"creation": "2022-01-25 10:29:57.771398",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"customer_section",
|
||||
"company",
|
||||
"company_tax_id",
|
||||
"naming_series",
|
||||
"customer",
|
||||
"customer_name",
|
||||
"tax_id",
|
||||
"company",
|
||||
"company_tax_id",
|
||||
"column_break1",
|
||||
"posting_date",
|
||||
"posting_time",
|
||||
@@ -704,6 +703,7 @@
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "eval:doc.items.every((item) => !item.dn_detail)",
|
||||
"fieldname": "update_stock",
|
||||
"fieldtype": "Check",
|
||||
"hide_days": 1,
|
||||
@@ -2306,7 +2306,7 @@
|
||||
"link_fieldname": "consolidated_invoice"
|
||||
}
|
||||
],
|
||||
"modified": "2026-01-30 16:45:59.682473",
|
||||
"modified": "2026-02-06 20:43:44.732805",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Sales Invoice",
|
||||
|
||||
@@ -232,11 +232,11 @@ def get_report_summary(
|
||||
|
||||
|
||||
def get_chart_data(filters, columns, asset, liability, equity, currency):
|
||||
labels = [d.get("label") for d in columns[2:]]
|
||||
labels = [d.get("label") for d in columns[4:]]
|
||||
|
||||
asset_data, liability_data, equity_data = [], [], []
|
||||
|
||||
for p in columns[2:]:
|
||||
for p in columns[4:]:
|
||||
if asset:
|
||||
asset_data.append(asset[-2].get(p.get("fieldname")))
|
||||
if liability:
|
||||
|
||||
@@ -5,15 +5,16 @@ from collections import OrderedDict
|
||||
|
||||
import frappe
|
||||
from frappe import _, qb, scrub
|
||||
from frappe.query_builder import Order
|
||||
from frappe.query_builder import Case, Order
|
||||
from frappe.query_builder.functions import Coalesce
|
||||
from frappe.utils import cint, flt, formatdate
|
||||
from pypika.terms import ExistsCriterion
|
||||
|
||||
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
||||
get_accounting_dimensions,
|
||||
get_dimension_with_children,
|
||||
)
|
||||
from erpnext.accounts.report.financial_statements import get_cost_centers_with_children
|
||||
from erpnext.controllers.queries import get_match_cond
|
||||
from erpnext.stock.report.stock_ledger.stock_ledger import get_item_group_condition
|
||||
from erpnext.stock.utils import get_incoming_rate
|
||||
|
||||
@@ -176,7 +177,9 @@ def get_data_when_grouped_by_invoice(columns, gross_profit_data, filters, group_
|
||||
column_names = get_column_names()
|
||||
|
||||
# to display item as Item Code: Item Name
|
||||
columns[0] = "Sales Invoice:Link/Item:300"
|
||||
columns[0]["fieldname"] = "sales_invoice"
|
||||
columns[0]["options"] = "Item"
|
||||
columns[0]["width"] = 300
|
||||
# removing Item Code and Item Name columns
|
||||
supplier_master_name = frappe.db.get_single_value("Buying Settings", "supp_master_name")
|
||||
customer_master_name = frappe.db.get_single_value("Selling Settings", "cust_master_name")
|
||||
@@ -203,7 +206,11 @@ def get_data_when_grouped_by_invoice(columns, gross_profit_data, filters, group_
|
||||
|
||||
data.append(row)
|
||||
|
||||
total_gross_profit = total_base_amount - total_buying_amount
|
||||
total_gross_profit = flt(
|
||||
total_base_amount + abs(total_buying_amount)
|
||||
if total_buying_amount < 0
|
||||
else total_base_amount - total_buying_amount,
|
||||
)
|
||||
data.append(
|
||||
frappe._dict(
|
||||
{
|
||||
@@ -215,7 +222,7 @@ def get_data_when_grouped_by_invoice(columns, gross_profit_data, filters, group_
|
||||
"buying_amount": total_buying_amount,
|
||||
"gross_profit": total_gross_profit,
|
||||
"gross_profit_%": flt(
|
||||
(total_gross_profit / total_base_amount) * 100.0,
|
||||
(total_gross_profit / abs(total_base_amount)) * 100.0,
|
||||
cint(frappe.db.get_default("currency_precision")) or 3,
|
||||
)
|
||||
if total_base_amount
|
||||
@@ -248,9 +255,13 @@ def get_data_when_not_grouped_by_invoice(gross_profit_data, filters, group_wise_
|
||||
|
||||
data.append(row)
|
||||
|
||||
total_gross_profit = total_base_amount - total_buying_amount
|
||||
total_gross_profit = flt(
|
||||
total_base_amount + abs(total_buying_amount)
|
||||
if total_buying_amount < 0
|
||||
else total_base_amount - total_buying_amount,
|
||||
)
|
||||
currency_precision = cint(frappe.db.get_default("currency_precision")) or 3
|
||||
gross_profit_percent = (total_gross_profit / total_base_amount * 100.0) if total_base_amount else 0
|
||||
gross_profit_percent = (total_gross_profit / abs(total_base_amount) * 100.0) if total_base_amount else 0
|
||||
|
||||
total_row = {
|
||||
group_columns[0]: "Total",
|
||||
@@ -581,10 +592,15 @@ class GrossProfitGenerator:
|
||||
base_amount += row.base_amount
|
||||
|
||||
# calculate gross profit
|
||||
row.gross_profit = flt(row.base_amount - row.buying_amount, self.currency_precision)
|
||||
row.gross_profit = flt(
|
||||
row.base_amount + abs(row.buying_amount)
|
||||
if row.buying_amount < 0
|
||||
else row.base_amount - row.buying_amount,
|
||||
self.currency_precision,
|
||||
)
|
||||
if row.base_amount:
|
||||
row.gross_profit_percent = flt(
|
||||
(row.gross_profit / row.base_amount) * 100.0,
|
||||
(row.gross_profit / abs(row.base_amount)) * 100.0,
|
||||
self.currency_precision,
|
||||
)
|
||||
else:
|
||||
@@ -673,9 +689,14 @@ class GrossProfitGenerator:
|
||||
return new_row
|
||||
|
||||
def set_average_gross_profit(self, new_row):
|
||||
new_row.gross_profit = flt(new_row.base_amount - new_row.buying_amount, self.currency_precision)
|
||||
new_row.gross_profit = flt(
|
||||
new_row.base_amount + abs(new_row.buying_amount)
|
||||
if new_row.buying_amount < 0
|
||||
else new_row.base_amount - new_row.buying_amount,
|
||||
self.currency_precision,
|
||||
)
|
||||
new_row.gross_profit_percent = (
|
||||
flt(((new_row.gross_profit / new_row.base_amount) * 100.0), self.currency_precision)
|
||||
flt(((new_row.gross_profit / abs(new_row.base_amount)) * 100.0), self.currency_precision)
|
||||
if new_row.base_amount
|
||||
else 0
|
||||
)
|
||||
@@ -851,129 +872,173 @@ class GrossProfitGenerator:
|
||||
return flt(last_purchase_rate[0][0]) if last_purchase_rate else 0
|
||||
|
||||
def load_invoice_items(self):
|
||||
conditions = ""
|
||||
if self.filters.company:
|
||||
conditions += " and `tabSales Invoice`.company = %(company)s"
|
||||
if self.filters.from_date:
|
||||
conditions += " and posting_date >= %(from_date)s"
|
||||
if self.filters.to_date:
|
||||
conditions += " and posting_date <= %(to_date)s"
|
||||
self.si_list = []
|
||||
|
||||
SalesInvoice = frappe.qb.DocType("Sales Invoice")
|
||||
base_query = self.prepare_invoice_query()
|
||||
|
||||
if self.filters.include_returned_invoices:
|
||||
conditions += " and (is_return = 0 or (is_return=1 and return_against is null))"
|
||||
invoice_query = base_query.where(
|
||||
(SalesInvoice.is_return == 0)
|
||||
| ((SalesInvoice.is_return == 1) & SalesInvoice.return_against.isnull())
|
||||
)
|
||||
else:
|
||||
conditions += " and is_return = 0"
|
||||
invoice_query = base_query.where(SalesInvoice.is_return == 0)
|
||||
|
||||
if self.filters.item_group:
|
||||
conditions += f" and {get_item_group_condition(self.filters.item_group)}"
|
||||
self.si_list += invoice_query.run(as_dict=True)
|
||||
self.prepare_vouchers_to_ignore()
|
||||
|
||||
if self.filters.sales_person:
|
||||
conditions += """
|
||||
and exists(select 1
|
||||
from `tabSales Team` st
|
||||
where st.parent = `tabSales Invoice`.name
|
||||
and st.sales_person = %(sales_person)s)
|
||||
"""
|
||||
ret_invoice_query = base_query.where(
|
||||
(SalesInvoice.is_return == 1) & SalesInvoice.return_against.isnotnull()
|
||||
)
|
||||
if self.vouchers_to_ignore:
|
||||
ret_invoice_query = ret_invoice_query.where(
|
||||
SalesInvoice.return_against.notin(self.vouchers_to_ignore)
|
||||
)
|
||||
|
||||
self.si_list += ret_invoice_query.run(as_dict=True)
|
||||
|
||||
def prepare_invoice_query(self):
|
||||
SalesInvoice = frappe.qb.DocType("Sales Invoice")
|
||||
SalesInvoiceItem = frappe.qb.DocType("Sales Invoice Item")
|
||||
Item = frappe.qb.DocType("Item")
|
||||
SalesTeam = frappe.qb.DocType("Sales Team")
|
||||
PaymentSchedule = frappe.qb.DocType("Payment Schedule")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(SalesInvoice)
|
||||
.join(SalesInvoiceItem)
|
||||
.on(SalesInvoiceItem.parent == SalesInvoice.name)
|
||||
.join(Item)
|
||||
.on(Item.name == SalesInvoiceItem.item_code)
|
||||
.where((SalesInvoice.docstatus == 1) & (SalesInvoice.is_opening != "Yes"))
|
||||
)
|
||||
|
||||
query = self.apply_common_filters(query, SalesInvoice, SalesInvoiceItem, SalesTeam, Item)
|
||||
|
||||
query = query.select(
|
||||
SalesInvoiceItem.parenttype,
|
||||
SalesInvoiceItem.parent,
|
||||
SalesInvoice.posting_date,
|
||||
SalesInvoice.posting_time,
|
||||
SalesInvoice.project,
|
||||
SalesInvoice.update_stock,
|
||||
SalesInvoice.customer,
|
||||
SalesInvoice.customer_group,
|
||||
SalesInvoice.customer_name,
|
||||
SalesInvoice.territory,
|
||||
SalesInvoiceItem.item_code,
|
||||
SalesInvoice.base_net_total.as_("invoice_base_net_total"),
|
||||
SalesInvoiceItem.item_name,
|
||||
SalesInvoiceItem.description,
|
||||
SalesInvoiceItem.warehouse,
|
||||
SalesInvoiceItem.item_group,
|
||||
SalesInvoiceItem.brand,
|
||||
SalesInvoiceItem.so_detail,
|
||||
SalesInvoiceItem.sales_order,
|
||||
SalesInvoiceItem.dn_detail,
|
||||
SalesInvoiceItem.delivery_note,
|
||||
SalesInvoiceItem.stock_qty.as_("qty"),
|
||||
SalesInvoiceItem.base_net_rate,
|
||||
SalesInvoiceItem.base_net_amount,
|
||||
SalesInvoiceItem.name.as_("item_row"),
|
||||
SalesInvoice.is_return,
|
||||
SalesInvoiceItem.cost_center,
|
||||
SalesInvoiceItem.serial_and_batch_bundle,
|
||||
)
|
||||
|
||||
if self.filters.group_by == "Sales Person":
|
||||
sales_person_cols = """, sales.sales_person,
|
||||
sales.allocated_percentage * `tabSales Invoice Item`.base_net_amount / 100 as allocated_amount,
|
||||
sales.incentives
|
||||
"""
|
||||
sales_team_table = "left join `tabSales Team` sales on sales.parent = `tabSales Invoice`.name"
|
||||
else:
|
||||
sales_person_cols = ""
|
||||
sales_team_table = ""
|
||||
query = query.select(
|
||||
SalesTeam.sales_person,
|
||||
(SalesTeam.allocated_percentage * SalesInvoiceItem.base_net_amount / 100).as_(
|
||||
"allocated_amount"
|
||||
),
|
||||
SalesTeam.incentives,
|
||||
)
|
||||
|
||||
query = query.left_join(SalesTeam).on(SalesTeam.parent == SalesInvoice.name)
|
||||
|
||||
if self.filters.group_by == "Payment Term":
|
||||
payment_term_cols = """,if(`tabSales Invoice`.is_return = 1,
|
||||
'{}',
|
||||
coalesce(schedule.payment_term, '{}')) as payment_term,
|
||||
schedule.invoice_portion,
|
||||
schedule.payment_amount """.format(_("Sales Return"), _("No Terms"))
|
||||
payment_term_table = """ left join `tabPayment Schedule` schedule on schedule.parent = `tabSales Invoice`.name and
|
||||
`tabSales Invoice`.is_return = 0 """
|
||||
else:
|
||||
payment_term_cols = ""
|
||||
payment_term_table = ""
|
||||
query = query.select(
|
||||
Case()
|
||||
.when(SalesInvoice.is_return == 1, _("Sales Return"))
|
||||
.else_(Coalesce(PaymentSchedule.payment_term, _("No Terms")))
|
||||
.as_("payment_term"),
|
||||
PaymentSchedule.invoice_portion,
|
||||
PaymentSchedule.payment_amount,
|
||||
)
|
||||
|
||||
if self.filters.get("sales_invoice"):
|
||||
conditions += " and `tabSales Invoice`.name = %(sales_invoice)s"
|
||||
query = query.left_join(PaymentSchedule).on(
|
||||
(PaymentSchedule.parent == SalesInvoice.name) & (SalesInvoice.is_return == 0)
|
||||
)
|
||||
|
||||
if self.filters.get("item_code"):
|
||||
conditions += " and `tabSales Invoice Item`.item_code = %(item_code)s"
|
||||
query = query.orderby(SalesInvoice.posting_date, order=Order.desc).orderby(
|
||||
SalesInvoice.posting_time, order=Order.desc
|
||||
)
|
||||
|
||||
if self.filters.get("cost_center"):
|
||||
return query
|
||||
|
||||
def apply_common_filters(self, query, SalesInvoice, SalesInvoiceItem, SalesTeam, Item):
|
||||
if self.filters.company:
|
||||
query = query.where(SalesInvoice.company == self.filters.company)
|
||||
|
||||
if self.filters.from_date:
|
||||
query = query.where(SalesInvoice.posting_date >= self.filters.from_date)
|
||||
|
||||
if self.filters.to_date:
|
||||
query = query.where(SalesInvoice.posting_date <= self.filters.to_date)
|
||||
|
||||
if self.filters.item_group:
|
||||
query = query.where(get_item_group_condition(self.filters.item_group, Item))
|
||||
|
||||
if self.filters.sales_person:
|
||||
query = query.where(
|
||||
ExistsCriterion(
|
||||
frappe.qb.from_(SalesTeam)
|
||||
.select(1)
|
||||
.where(
|
||||
(SalesTeam.parent == SalesInvoice.name)
|
||||
& (SalesTeam.sales_person == self.filters.sales_person)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if self.filters.sales_invoice:
|
||||
query = query.where(SalesInvoice.name == self.filters.sales_invoice)
|
||||
|
||||
if self.filters.item_code:
|
||||
query = query.where(SalesInvoiceItem.item_code == self.filters.item_code)
|
||||
|
||||
if self.filters.cost_center:
|
||||
self.filters.cost_center = frappe.parse_json(self.filters.get("cost_center"))
|
||||
self.filters.cost_center = get_cost_centers_with_children(self.filters.cost_center)
|
||||
conditions += " and `tabSales Invoice Item`.cost_center in %(cost_center)s"
|
||||
query = query.where(SalesInvoiceItem.cost_center.isin(self.filters.cost_center))
|
||||
|
||||
if self.filters.get("project"):
|
||||
if self.filters.project:
|
||||
self.filters.project = frappe.parse_json(self.filters.get("project"))
|
||||
conditions += " and `tabSales Invoice Item`.project in %(project)s"
|
||||
query = query.where(SalesInvoiceItem.project.isin(self.filters.project))
|
||||
|
||||
accounting_dimensions = get_accounting_dimensions(as_list=False)
|
||||
if accounting_dimensions:
|
||||
for dimension in accounting_dimensions:
|
||||
if self.filters.get(dimension.fieldname):
|
||||
if frappe.get_cached_value("DocType", dimension.document_type, "is_tree"):
|
||||
self.filters[dimension.fieldname] = get_dimension_with_children(
|
||||
dimension.document_type, self.filters.get(dimension.fieldname)
|
||||
)
|
||||
conditions += (
|
||||
f" and `tabSales Invoice Item`.{dimension.fieldname} in %({dimension.fieldname})s"
|
||||
)
|
||||
else:
|
||||
conditions += (
|
||||
f" and `tabSales Invoice Item`.{dimension.fieldname} in %({dimension.fieldname})s"
|
||||
)
|
||||
for dim in get_accounting_dimensions(as_list=False) or []:
|
||||
if self.filters.get(dim.fieldname):
|
||||
if frappe.get_cached_value("DocType", dim.document_type, "is_tree"):
|
||||
self.filters[dim.fieldname] = get_dimension_with_children(
|
||||
dim.document_type, self.filters.get(dim.fieldname)
|
||||
)
|
||||
query = query.where(SalesInvoiceItem[dim.fieldname].isin(self.filters[dim.fieldname]))
|
||||
|
||||
if self.filters.get("warehouse"):
|
||||
warehouse_details = frappe.db.get_value(
|
||||
"Warehouse", self.filters.get("warehouse"), ["lft", "rgt"], as_dict=1
|
||||
if self.filters.warehouse:
|
||||
lft, rgt = frappe.db.get_value("Warehouse", self.filters.warehouse, ["lft", "rgt"])
|
||||
WH = frappe.qb.DocType("Warehouse")
|
||||
query = query.where(
|
||||
SalesInvoiceItem.warehouse.isin(
|
||||
frappe.qb.from_(WH).select(WH.name).where((WH.lft >= lft) & (WH.rgt <= rgt))
|
||||
)
|
||||
)
|
||||
if warehouse_details:
|
||||
conditions += f" and `tabSales Invoice Item`.warehouse in (select name from `tabWarehouse` wh where wh.lft >= {warehouse_details.lft} and wh.rgt <= {warehouse_details.rgt} and warehouse = wh.name)"
|
||||
|
||||
self.si_list = frappe.db.sql(
|
||||
"""
|
||||
select
|
||||
`tabSales Invoice Item`.parenttype, `tabSales Invoice Item`.parent,
|
||||
`tabSales Invoice`.posting_date, `tabSales Invoice`.posting_time,
|
||||
`tabSales Invoice`.project, `tabSales Invoice`.update_stock,
|
||||
`tabSales Invoice`.customer, `tabSales Invoice`.customer_group, `tabSales Invoice`.customer_name,
|
||||
`tabSales Invoice`.territory, `tabSales Invoice Item`.item_code,
|
||||
`tabSales Invoice`.base_net_total as "invoice_base_net_total",
|
||||
`tabSales Invoice Item`.item_name, `tabSales Invoice Item`.description,
|
||||
`tabSales Invoice Item`.warehouse, `tabSales Invoice Item`.item_group,
|
||||
`tabSales Invoice Item`.brand, `tabSales Invoice Item`.so_detail,
|
||||
`tabSales Invoice Item`.sales_order, `tabSales Invoice Item`.dn_detail,
|
||||
`tabSales Invoice Item`.delivery_note, `tabSales Invoice Item`.stock_qty as qty,
|
||||
`tabSales Invoice Item`.base_net_rate, `tabSales Invoice Item`.base_net_amount,
|
||||
`tabSales Invoice Item`.name as "item_row", `tabSales Invoice`.is_return,
|
||||
`tabSales Invoice Item`.cost_center, `tabSales Invoice Item`.serial_and_batch_bundle
|
||||
{sales_person_cols}
|
||||
{payment_term_cols}
|
||||
from
|
||||
`tabSales Invoice` inner join `tabSales Invoice Item`
|
||||
on `tabSales Invoice Item`.parent = `tabSales Invoice`.name
|
||||
join `tabItem` item on item.name = `tabSales Invoice Item`.item_code
|
||||
{sales_team_table}
|
||||
{payment_term_table}
|
||||
where
|
||||
`tabSales Invoice`.docstatus=1 and `tabSales Invoice`.is_opening!='Yes' {conditions} {match_cond}
|
||||
order by
|
||||
`tabSales Invoice`.posting_date desc, `tabSales Invoice`.posting_time desc""".format(
|
||||
conditions=conditions,
|
||||
sales_person_cols=sales_person_cols,
|
||||
sales_team_table=sales_team_table,
|
||||
payment_term_cols=payment_term_cols,
|
||||
payment_term_table=payment_term_table,
|
||||
match_cond=get_match_cond("Sales Invoice"),
|
||||
),
|
||||
self.filters,
|
||||
as_dict=1,
|
||||
)
|
||||
return query
|
||||
|
||||
def prepare_vouchers_to_ignore(self):
|
||||
self.vouchers_to_ignore = tuple(row["parent"] for row in self.si_list)
|
||||
|
||||
def get_delivery_notes(self):
|
||||
self.delivery_notes = frappe._dict({})
|
||||
|
||||
@@ -470,7 +470,7 @@ class TestGrossProfit(IntegrationTestCase):
|
||||
"selling_amount": -100.0,
|
||||
"buying_amount": 0.0,
|
||||
"gross_profit": -100.0,
|
||||
"gross_profit_%": 100.0,
|
||||
"gross_profit_%": -100.0,
|
||||
}
|
||||
gp_entry = [x for x in data if x.parent_invoice == sinv.name]
|
||||
report_output = {k: v for k, v in gp_entry[0].items() if k in expected_entry}
|
||||
@@ -649,21 +649,24 @@ class TestGrossProfit(IntegrationTestCase):
|
||||
def test_profit_for_later_period_return(self):
|
||||
month_start_date, month_end_date = get_first_day(nowdate()), get_last_day(nowdate())
|
||||
|
||||
sales_inv_date = month_start_date
|
||||
return_inv_date = add_days(month_end_date, 1)
|
||||
|
||||
# create sales invoice on month start date
|
||||
sinv = self.create_sales_invoice(qty=1, rate=100, do_not_save=True, do_not_submit=True)
|
||||
sinv.set_posting_time = 1
|
||||
sinv.posting_date = month_start_date
|
||||
sinv.posting_date = sales_inv_date
|
||||
sinv.save().submit()
|
||||
|
||||
# create credit note on next month start date
|
||||
cr_note = make_sales_return(sinv.name)
|
||||
cr_note.set_posting_time = 1
|
||||
cr_note.posting_date = add_days(month_end_date, 1)
|
||||
cr_note.posting_date = return_inv_date
|
||||
cr_note.save().submit()
|
||||
|
||||
# apply filters for invoiced period
|
||||
filters = frappe._dict(
|
||||
company=self.company, from_date=month_start_date, to_date=month_end_date, group_by="Invoice"
|
||||
company=self.company, from_date=month_start_date, to_date=month_start_date, group_by="Invoice"
|
||||
)
|
||||
|
||||
_, data = execute(filters=filters)
|
||||
@@ -675,7 +678,7 @@ class TestGrossProfit(IntegrationTestCase):
|
||||
self.assertEqual(total.get("gross_profit_%"), 100.0)
|
||||
|
||||
# extend filters upto returned period
|
||||
filters.update(to_date=add_days(month_end_date, 1))
|
||||
filters.update({"to_date": return_inv_date})
|
||||
|
||||
_, data = execute(filters=filters)
|
||||
total = data[-1]
|
||||
@@ -684,3 +687,63 @@ class TestGrossProfit(IntegrationTestCase):
|
||||
self.assertEqual(total.buying_amount, 0.0)
|
||||
self.assertEqual(total.gross_profit, 0.0)
|
||||
self.assertEqual(total.get("gross_profit_%"), 0.0)
|
||||
|
||||
# apply filters only on returned period
|
||||
filters.update({"from_date": return_inv_date, "to_date": return_inv_date})
|
||||
_, data = execute(filters=filters)
|
||||
total = data[-1]
|
||||
|
||||
self.assertEqual(total.selling_amount, -100.0)
|
||||
self.assertEqual(total.buying_amount, 0.0)
|
||||
self.assertEqual(total.gross_profit, -100.0)
|
||||
self.assertEqual(total.get("gross_profit_%"), -100.0)
|
||||
|
||||
def test_sales_person_wise_gross_profit(self):
|
||||
sales_person = make_sales_person("_Test Sales Person")
|
||||
|
||||
posting_date = get_first_day(nowdate())
|
||||
qty = 10
|
||||
rate = 100
|
||||
|
||||
sinv = self.create_sales_invoice(qty=qty, rate=rate, do_not_save=True, do_not_submit=True)
|
||||
sinv.set_posting_time = 1
|
||||
sinv.posting_date = posting_date
|
||||
sinv.append(
|
||||
"sales_team",
|
||||
{
|
||||
"sales_person": sales_person.name,
|
||||
"allocated_percentage": 100,
|
||||
"allocated_amount": 1000.0,
|
||||
"commission_rate": 5,
|
||||
"incentives": 5,
|
||||
},
|
||||
)
|
||||
sinv.save().submit()
|
||||
|
||||
filters = frappe._dict(
|
||||
company=self.company, from_date=posting_date, to_date=posting_date, group_by="Sales Person"
|
||||
)
|
||||
|
||||
_, data = execute(filters=filters)
|
||||
total = data[-1]
|
||||
|
||||
self.assertEqual(total[5], 1000.0)
|
||||
self.assertEqual(total[6], 0.0)
|
||||
self.assertEqual(total[7], 1000.0)
|
||||
self.assertEqual(total[8], 100.0)
|
||||
|
||||
|
||||
def make_sales_person(sales_person_name="_Test Sales Person"):
|
||||
if not frappe.db.exists("Sales Person", {"sales_person_name": sales_person_name}):
|
||||
sales_person_doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Sales Person",
|
||||
"is_group": 0,
|
||||
"parent_sales_person": "Sales Team",
|
||||
"sales_person_name": sales_person_name,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
else:
|
||||
sales_person_doc = frappe.get_doc("Sales Person", {"sales_person_name": sales_person_name})
|
||||
|
||||
return sales_person_doc
|
||||
|
||||
@@ -154,17 +154,11 @@ def get_columns(filters):
|
||||
"width": 60,
|
||||
},
|
||||
{
|
||||
"label": _("Total Amount"),
|
||||
"label": _("Taxable Amount"),
|
||||
"fieldname": "total_amount",
|
||||
"fieldtype": "Currency",
|
||||
"width": 120,
|
||||
},
|
||||
{
|
||||
"label": _("Base Total"),
|
||||
"fieldname": "base_total",
|
||||
"fieldtype": "Currency",
|
||||
"width": 120,
|
||||
},
|
||||
{
|
||||
"label": _("Tax Amount"),
|
||||
"fieldname": "tax_amount",
|
||||
@@ -172,10 +166,16 @@ def get_columns(filters):
|
||||
"width": 120,
|
||||
},
|
||||
{
|
||||
"label": _("Grand Total"),
|
||||
"label": _("Grand Total (Company Currency)"),
|
||||
"fieldname": "base_total",
|
||||
"fieldtype": "Currency",
|
||||
"width": 150,
|
||||
},
|
||||
{
|
||||
"label": _("Grand Total (Transaction Currency)"),
|
||||
"fieldname": "grand_total",
|
||||
"fieldtype": "Currency",
|
||||
"width": 120,
|
||||
"width": 170,
|
||||
},
|
||||
{
|
||||
"label": _("Reference Date"),
|
||||
|
||||
@@ -106,7 +106,7 @@ def get_columns(filters):
|
||||
"width": 120,
|
||||
},
|
||||
{
|
||||
"label": _("Total Amount"),
|
||||
"label": _("Total Taxable Amount"),
|
||||
"fieldname": "total_amount",
|
||||
"fieldtype": "Float",
|
||||
"width": 120,
|
||||
|
||||
@@ -513,12 +513,14 @@ frappe.ui.form.on("Asset", {
|
||||
},
|
||||
|
||||
is_composite_asset: function (frm) {
|
||||
if (frm.doc.is_composite_asset) {
|
||||
frm.set_value("net_purchase_amount", 0);
|
||||
} else {
|
||||
frm.set_df_property("net_purchase_amount", "read_only", 0);
|
||||
if (frm.doc.docstatus == 0) {
|
||||
if (frm.doc.is_composite_asset) {
|
||||
frm.set_value("net_purchase_amount", 0);
|
||||
} else {
|
||||
frm.set_df_property("net_purchase_amount", "read_only", 0);
|
||||
}
|
||||
frm.trigger("toggle_reference_doc");
|
||||
}
|
||||
frm.trigger("toggle_reference_doc");
|
||||
},
|
||||
|
||||
make_sales_invoice: function (frm) {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import cstr, get_link_to_form
|
||||
from frappe.utils import cstr, get_datetime, get_link_to_form
|
||||
|
||||
from erpnext.assets.doctype.asset_activity.asset_activity import add_asset_activity
|
||||
|
||||
@@ -34,6 +34,7 @@ class AssetMovement(Document):
|
||||
for d in self.assets:
|
||||
self.validate_asset(d)
|
||||
self.validate_movement(d)
|
||||
self.validate_transaction_date(d)
|
||||
|
||||
def validate_asset(self, d):
|
||||
status, company = frappe.db.get_value("Asset", d.asset, ["status", "company"])
|
||||
@@ -51,6 +52,18 @@ class AssetMovement(Document):
|
||||
else:
|
||||
self.validate_employee(d)
|
||||
|
||||
def validate_transaction_date(self, d):
|
||||
previous_movement_date = frappe.db.get_value(
|
||||
"Asset Movement",
|
||||
[["Asset Movement Item", "asset", "=", d.asset], ["docstatus", "=", 1]],
|
||||
"transaction_date",
|
||||
order_by="transaction_date desc",
|
||||
)
|
||||
if previous_movement_date and get_datetime(previous_movement_date) > get_datetime(
|
||||
self.transaction_date
|
||||
):
|
||||
frappe.throw(_("Transaction date can't be earlier than previous movement date"))
|
||||
|
||||
def validate_location_and_employee(self, d):
|
||||
self.validate_location(d)
|
||||
self.validate_employee(d)
|
||||
|
||||
@@ -4,9 +4,9 @@ import unittest
|
||||
|
||||
import frappe
|
||||
from frappe.tests import IntegrationTestCase
|
||||
from frappe.utils import now
|
||||
from frappe.utils import add_days, now
|
||||
|
||||
from erpnext.assets.doctype.asset.test_asset import create_asset_data
|
||||
from erpnext.assets.doctype.asset.test_asset import create_asset, create_asset_data
|
||||
from erpnext.setup.doctype.employee.test_employee import make_employee
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
|
||||
|
||||
@@ -147,6 +147,33 @@ class TestAssetMovement(IntegrationTestCase):
|
||||
movement1.cancel()
|
||||
self.assertEqual(frappe.db.get_value("Asset", asset.name, "location"), "Test Location")
|
||||
|
||||
def test_movement_transaction_date(self):
|
||||
asset = create_asset(item_code="Macbook Pro", do_not_save=1)
|
||||
asset.save().submit()
|
||||
|
||||
if not frappe.db.exists("Location", "Test Location 2"):
|
||||
frappe.get_doc({"doctype": "Location", "location_name": "Test Location 2"}).insert()
|
||||
|
||||
asset_creation_date = frappe.db.get_value(
|
||||
"Asset Movement",
|
||||
[["Asset Movement Item", "asset", "=", asset.name], ["docstatus", "=", 1]],
|
||||
"transaction_date",
|
||||
)
|
||||
asset_movement = create_asset_movement(
|
||||
purpose="Transfer",
|
||||
company=asset.company,
|
||||
assets=[
|
||||
{
|
||||
"asset": asset.name,
|
||||
"source_location": "Test Location",
|
||||
"target_location": "Test Location 2",
|
||||
}
|
||||
],
|
||||
transaction_date=add_days(asset_creation_date, -1),
|
||||
do_not_save=True,
|
||||
)
|
||||
self.assertRaises(frappe.ValidationError, asset_movement.save)
|
||||
|
||||
|
||||
def create_asset_movement(**args):
|
||||
args = frappe._dict(args)
|
||||
@@ -165,9 +192,10 @@ def create_asset_movement(**args):
|
||||
"reference_name": args.reference_name,
|
||||
}
|
||||
)
|
||||
|
||||
movement.insert()
|
||||
movement.submit()
|
||||
if not args.do_not_save:
|
||||
movement.insert(ignore_if_duplicate=True)
|
||||
if not args.do_not_submit:
|
||||
movement.submit()
|
||||
|
||||
return movement
|
||||
|
||||
|
||||
@@ -9,10 +9,9 @@
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"supplier_section",
|
||||
"company",
|
||||
"title",
|
||||
"naming_series",
|
||||
"supplier",
|
||||
"supplier_name",
|
||||
"order_confirmation_no",
|
||||
"order_confirmation_date",
|
||||
"get_items_from_open_material_requests",
|
||||
@@ -21,8 +20,9 @@
|
||||
"transaction_date",
|
||||
"schedule_date",
|
||||
"column_break1",
|
||||
"company",
|
||||
"supplier",
|
||||
"is_subcontracted",
|
||||
"supplier_name",
|
||||
"has_unit_price_items",
|
||||
"supplier_warehouse",
|
||||
"amended_from",
|
||||
@@ -1310,7 +1310,7 @@
|
||||
"idx": 105,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-01-29 21:22:54.323838",
|
||||
"modified": "2026-02-03 14:44:55.192192",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Purchase Order",
|
||||
|
||||
@@ -135,14 +135,6 @@ frappe.ui.form.on("Supplier", {
|
||||
// indicators
|
||||
erpnext.utils.set_party_dashboard_indicators(frm);
|
||||
}
|
||||
|
||||
frm.set_query("supplier_group", () => {
|
||||
return {
|
||||
filters: {
|
||||
is_group: 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
get_supplier_group_details: function (frm) {
|
||||
frappe.call({
|
||||
|
||||
@@ -167,6 +167,7 @@
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Supplier Group",
|
||||
"link_filters": "[[\"Supplier Group\",\"is_group\",\"=\",0]]",
|
||||
"oldfieldname": "supplier_type",
|
||||
"oldfieldtype": "Link",
|
||||
"options": "Supplier Group"
|
||||
@@ -500,7 +501,7 @@
|
||||
"link_fieldname": "party"
|
||||
}
|
||||
],
|
||||
"modified": "2026-01-16 15:56:31.139206",
|
||||
"modified": "2026-02-06 12:58:01.398824",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Supplier",
|
||||
|
||||
@@ -38,18 +38,18 @@ class EmailCampaign(Document):
|
||||
def set_date(self):
|
||||
if getdate(self.start_date) < getdate(today()):
|
||||
frappe.throw(_("Start Date cannot be before the current date"))
|
||||
|
||||
# set the end date as start date + max(send after days) in campaign schedule
|
||||
send_after_days = []
|
||||
campaign = frappe.get_doc("Campaign", self.campaign_name)
|
||||
for entry in campaign.get("campaign_schedules"):
|
||||
send_after_days.append(entry.send_after_days)
|
||||
try:
|
||||
self.end_date = add_days(getdate(self.start_date), max(send_after_days))
|
||||
except ValueError:
|
||||
campaign = frappe.get_cached_doc("Campaign", self.campaign_name)
|
||||
send_after_days = [entry.send_after_days for entry in campaign.get("campaign_schedules")]
|
||||
|
||||
if not send_after_days:
|
||||
frappe.throw(
|
||||
_("Please set up the Campaign Schedule in the Campaign {0}").format(self.campaign_name)
|
||||
)
|
||||
|
||||
self.end_date = add_days(getdate(self.start_date), max(send_after_days))
|
||||
|
||||
def validate_lead(self):
|
||||
lead_email_id = frappe.db.get_value("Lead", self.recipient, "email_id")
|
||||
if not lead_email_id:
|
||||
@@ -77,58 +77,128 @@ class EmailCampaign(Document):
|
||||
start_date = getdate(self.start_date)
|
||||
end_date = getdate(self.end_date)
|
||||
today_date = getdate(today())
|
||||
|
||||
if start_date > today_date:
|
||||
self.db_set("status", "Scheduled", update_modified=False)
|
||||
new_status = "Scheduled"
|
||||
elif end_date >= today_date:
|
||||
self.db_set("status", "In Progress", update_modified=False)
|
||||
elif end_date < today_date:
|
||||
self.db_set("status", "Completed", update_modified=False)
|
||||
new_status = "In Progress"
|
||||
else:
|
||||
new_status = "Completed"
|
||||
|
||||
if self.status != new_status:
|
||||
self.db_set("status", new_status, update_modified=False)
|
||||
|
||||
|
||||
# called through hooks to send campaign mails to leads
|
||||
def send_email_to_leads_or_contacts():
|
||||
today_date = getdate(today())
|
||||
|
||||
# Get all active email campaigns in a single query
|
||||
email_campaigns = frappe.get_all(
|
||||
"Email Campaign", filters={"status": ("not in", ["Unsubscribed", "Completed", "Scheduled"])}
|
||||
"Email Campaign",
|
||||
filters={"status": "In Progress"},
|
||||
fields=["name", "campaign_name", "email_campaign_for", "recipient", "start_date", "sender"],
|
||||
)
|
||||
for camp in email_campaigns:
|
||||
email_campaign = frappe.get_doc("Email Campaign", camp.name)
|
||||
campaign = frappe.get_cached_doc("Campaign", email_campaign.campaign_name)
|
||||
|
||||
if not email_campaigns:
|
||||
return
|
||||
|
||||
# Process each email campaign
|
||||
for email_campaign in email_campaigns:
|
||||
try:
|
||||
campaign = frappe.get_cached_doc("Campaign", email_campaign.campaign_name)
|
||||
except frappe.DoesNotExistError:
|
||||
frappe.log_error(
|
||||
title=_("Email Campaign Error"),
|
||||
message=_("Campaign {0} not found").format(email_campaign.campaign_name),
|
||||
)
|
||||
continue
|
||||
|
||||
# Find schedules that match today
|
||||
for entry in campaign.get("campaign_schedules"):
|
||||
scheduled_date = add_days(email_campaign.get("start_date"), entry.get("send_after_days"))
|
||||
if scheduled_date == getdate(today()):
|
||||
send_mail(entry, email_campaign)
|
||||
try:
|
||||
scheduled_date = add_days(getdate(email_campaign.start_date), entry.get("send_after_days"))
|
||||
if scheduled_date == today_date:
|
||||
send_mail(entry, email_campaign)
|
||||
except Exception:
|
||||
frappe.log_error(
|
||||
title=_("Email Campaign Send Error"),
|
||||
message=_("Failed to send email for campaign {0} to {1}").format(
|
||||
email_campaign.name, email_campaign.recipient
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def send_mail(entry, email_campaign):
|
||||
recipient_list = []
|
||||
if email_campaign.email_campaign_for == "Email Group":
|
||||
for member in frappe.db.get_list(
|
||||
"Email Group Member", filters={"email_group": email_campaign.get("recipient")}, fields=["email"]
|
||||
):
|
||||
recipient_list.append(member["email"])
|
||||
campaign_for = email_campaign.get("email_campaign_for")
|
||||
recipient = email_campaign.get("recipient")
|
||||
sender_user = email_campaign.get("sender")
|
||||
campaign_name = email_campaign.get("name")
|
||||
|
||||
# Get recipient emails
|
||||
if campaign_for == "Email Group":
|
||||
recipient_list = frappe.get_all(
|
||||
"Email Group Member",
|
||||
filters={"email_group": recipient, "unsubscribed": 0},
|
||||
pluck="email",
|
||||
)
|
||||
else:
|
||||
recipient_list.append(
|
||||
frappe.db.get_value(
|
||||
email_campaign.email_campaign_for, email_campaign.get("recipient"), "email_id"
|
||||
email_id = frappe.db.get_value(campaign_for, recipient, "email_id")
|
||||
if not email_id:
|
||||
frappe.log_error(
|
||||
title=_("Email Campaign Error"),
|
||||
message=_("No email found for {0} {1}").format(campaign_for, recipient),
|
||||
)
|
||||
return
|
||||
recipient_list = [email_id]
|
||||
|
||||
if not recipient_list:
|
||||
frappe.log_error(
|
||||
title=_("Email Campaign Error"),
|
||||
message=_("No recipients found for campaign {0}").format(campaign_name),
|
||||
)
|
||||
return
|
||||
|
||||
# Get email template and sender
|
||||
email_template = frappe.get_cached_doc("Email Template", entry.get("email_template"))
|
||||
sender = frappe.db.get_value("User", sender_user, "email") if sender_user else None
|
||||
|
||||
# Build context for template rendering
|
||||
if campaign_for != "Email Group":
|
||||
context = {"doc": frappe.get_doc(campaign_for, recipient)}
|
||||
else:
|
||||
# For email groups, use the email group document as context
|
||||
context = {"doc": frappe.get_doc("Email Group", recipient)}
|
||||
|
||||
# Render template
|
||||
subject = frappe.render_template(email_template.get("subject"), context)
|
||||
content = frappe.render_template(email_template.response_, context)
|
||||
|
||||
try:
|
||||
comm = make(
|
||||
doctype="Email Campaign",
|
||||
name=campaign_name,
|
||||
subject=subject,
|
||||
content=content,
|
||||
sender=sender,
|
||||
recipients=recipient_list,
|
||||
communication_medium="Email",
|
||||
sent_or_received="Sent",
|
||||
send_email=False,
|
||||
email_template=email_template.name,
|
||||
)
|
||||
|
||||
email_template = frappe.get_doc("Email Template", entry.get("email_template"))
|
||||
sender = frappe.db.get_value("User", email_campaign.get("sender"), "email")
|
||||
context = {"doc": frappe.get_doc(email_campaign.email_campaign_for, email_campaign.recipient)}
|
||||
# send mail and link communication to document
|
||||
comm = make(
|
||||
doctype="Email Campaign",
|
||||
name=email_campaign.name,
|
||||
subject=frappe.render_template(email_template.get("subject"), context),
|
||||
content=frappe.render_template(email_template.response_, context),
|
||||
sender=sender,
|
||||
bcc=recipient_list,
|
||||
communication_medium="Email",
|
||||
sent_or_received="Sent",
|
||||
send_email=True,
|
||||
email_template=email_template.name,
|
||||
)
|
||||
frappe.sendmail(
|
||||
recipients=recipient_list,
|
||||
subject=subject,
|
||||
content=content,
|
||||
sender=sender,
|
||||
communication=comm["name"],
|
||||
queue_separately=True,
|
||||
)
|
||||
except Exception:
|
||||
frappe.log_error(title="Email Campaign Failed.")
|
||||
|
||||
return comm
|
||||
|
||||
|
||||
@@ -140,7 +210,12 @@ def unsubscribe_recipient(unsubscribe, method):
|
||||
|
||||
# called through hooks to update email campaign status daily
|
||||
def set_email_campaign_status():
|
||||
email_campaigns = frappe.get_all("Email Campaign", filters={"status": ("!=", "Unsubscribed")})
|
||||
for entry in email_campaigns:
|
||||
email_campaign = frappe.get_doc("Email Campaign", entry.name)
|
||||
email_campaigns = frappe.get_all(
|
||||
"Email Campaign",
|
||||
filters={"status": ("!=", "Unsubscribed")},
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
for name in email_campaigns:
|
||||
email_campaign = frappe.get_doc("Email Campaign", name)
|
||||
email_campaign.update_status()
|
||||
|
||||
@@ -534,6 +534,7 @@ accounting_dimension_doctypes = [
|
||||
"Purchase Order Item",
|
||||
"Sales Order Item",
|
||||
"Journal Entry Account",
|
||||
"Journal Entry Template Account",
|
||||
"Material Request Item",
|
||||
"Delivery Note Item",
|
||||
"Purchase Receipt Item",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -580,9 +580,12 @@ frappe.ui.form.on("BOM", {
|
||||
frappe.ui.form.on("BOM Operation", {
|
||||
finished_good(frm, cdt, cdn) {
|
||||
let row = locals[cdt][cdn];
|
||||
if (row.finished_good === frm.doc.item) {
|
||||
frappe.model.set_value(row.doctype, row.name, "is_final_finished_good", 1);
|
||||
}
|
||||
frappe.model.set_value(
|
||||
row.doctype,
|
||||
row.name,
|
||||
"is_final_finished_good",
|
||||
row.finished_good === frm.doc.item
|
||||
);
|
||||
},
|
||||
|
||||
bom_no(frm, cdt, cdn) {
|
||||
|
||||
@@ -296,6 +296,57 @@ class BOM(WebsiteGenerator):
|
||||
self.set_process_loss_qty()
|
||||
self.validate_scrap_items()
|
||||
self.set_default_uom()
|
||||
self.validate_semi_finished_goods()
|
||||
|
||||
if self.docstatus == 1:
|
||||
self.validate_raw_materials_of_operation()
|
||||
|
||||
def validate_semi_finished_goods(self):
|
||||
if not self.track_semi_finished_goods or not self.operations:
|
||||
return
|
||||
|
||||
fg_items = []
|
||||
for row in self.operations:
|
||||
if not row.is_final_finished_good:
|
||||
continue
|
||||
|
||||
fg_items.append(row.finished_good)
|
||||
|
||||
if not fg_items:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Since you have enabled 'Track Semi Finished Goods', at least one operation must have 'Is Final Finished Good' checked. For that set the FG / Semi FG Item as {0} against an operation."
|
||||
).format(bold(self.item)),
|
||||
)
|
||||
|
||||
if fg_items and len(fg_items) > 1:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Only one operation can have 'Is Final Finished Good' checked when 'Track Semi Finished Goods' is enabled."
|
||||
),
|
||||
)
|
||||
|
||||
def validate_raw_materials_of_operation(self):
|
||||
if not self.track_semi_finished_goods or not self.operations:
|
||||
return
|
||||
|
||||
operation_idx_with_no_rm = {}
|
||||
for row in self.operations:
|
||||
if row.bom_no:
|
||||
continue
|
||||
|
||||
operation_idx_with_no_rm[row.idx] = row
|
||||
|
||||
for row in self.items:
|
||||
if row.operation_row_id and row.operation_row_id in operation_idx_with_no_rm:
|
||||
del operation_idx_with_no_rm[row.operation_row_id]
|
||||
|
||||
for idx, row in operation_idx_with_no_rm.items():
|
||||
frappe.throw(
|
||||
_("For operation {0} at row {1}, please add raw materials or set a BOM against it.").format(
|
||||
bold(row.operation), idx
|
||||
),
|
||||
)
|
||||
|
||||
def set_default_uom(self):
|
||||
if not self.get("items"):
|
||||
|
||||
@@ -23,6 +23,10 @@ from frappe.utils import (
|
||||
time_diff_in_hours,
|
||||
)
|
||||
|
||||
from erpnext.controllers.stock_controller import (
|
||||
QualityInspectionNotSubmittedError,
|
||||
QualityInspectionRejectedError,
|
||||
)
|
||||
from erpnext.manufacturing.doctype.bom.bom import add_additional_cost, get_bom_items_as_dict
|
||||
from erpnext.manufacturing.doctype.manufacturing_settings.manufacturing_settings import (
|
||||
get_mins_between_operations,
|
||||
@@ -167,6 +171,27 @@ class JobCard(Document):
|
||||
self.validate_work_order()
|
||||
self.set_employees()
|
||||
|
||||
if self.docstatus == 1:
|
||||
self.validate_semi_finished_goods()
|
||||
|
||||
def validate_semi_finished_goods(self):
|
||||
if not self.track_semi_finished_goods:
|
||||
return
|
||||
|
||||
if self.items and not self.transferred_qty and not self.skip_material_transfer:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Materials needs to be transferred to the work in progress warehouse for the job card {0}"
|
||||
).format(self.name)
|
||||
)
|
||||
|
||||
if self.docstatus == 1 and not self.total_completed_qty:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Total Completed Qty is required for Job Card {0}, please start and complete the job card before submission"
|
||||
).format(self.name)
|
||||
)
|
||||
|
||||
def on_update(self):
|
||||
self.validate_job_card_qty()
|
||||
|
||||
@@ -732,6 +757,7 @@ class JobCard(Document):
|
||||
self.set_process_loss()
|
||||
|
||||
def on_submit(self):
|
||||
self.validate_inspection()
|
||||
self.validate_transfer_qty()
|
||||
self.validate_job_card()
|
||||
self.update_work_order()
|
||||
@@ -741,6 +767,66 @@ class JobCard(Document):
|
||||
self.update_work_order()
|
||||
self.set_transferred_qty()
|
||||
|
||||
def validate_inspection(self):
|
||||
action_submit, action_reject = frappe.get_single_value(
|
||||
"Stock Settings",
|
||||
["action_if_quality_inspection_is_not_submitted", "action_if_quality_inspection_is_rejected"],
|
||||
)
|
||||
|
||||
item = self.finished_good or self.production_item
|
||||
bom_inspection_required = frappe.db.get_value(
|
||||
"BOM", self.semi_fg_bom or self.bom_no, "inspection_required"
|
||||
)
|
||||
if bom_inspection_required:
|
||||
if not self.quality_inspection:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Quality Inspection is required for the item {0} before completing the job card {1}"
|
||||
).format(get_link_to_form("Item", item), bold(self.name))
|
||||
)
|
||||
qa_status, docstatus = frappe.db.get_value(
|
||||
"Quality Inspection", self.quality_inspection, ["status", "docstatus"]
|
||||
)
|
||||
|
||||
if docstatus != 1:
|
||||
if action_submit == "Stop":
|
||||
frappe.throw(
|
||||
_("Quality Inspection {0} is not submitted for the item: {1}").format(
|
||||
get_link_to_form("Quality Inspection", self.quality_inspection),
|
||||
get_link_to_form("Item", item),
|
||||
),
|
||||
title=_("Inspection Submission"),
|
||||
exc=QualityInspectionNotSubmittedError,
|
||||
)
|
||||
else:
|
||||
frappe.msgprint(
|
||||
_("Quality Inspection {0} is not submitted for the item: {1}").format(
|
||||
get_link_to_form("Quality Inspection", self.quality_inspection),
|
||||
get_link_to_form("Item", item),
|
||||
),
|
||||
alert=True,
|
||||
indicator="orange",
|
||||
)
|
||||
elif qa_status == "Rejected":
|
||||
if action_reject == "Stop":
|
||||
frappe.throw(
|
||||
_("Quality Inspection {0} is rejected for the item: {1}").format(
|
||||
get_link_to_form("Quality Inspection", self.quality_inspection),
|
||||
get_link_to_form("Item", item),
|
||||
),
|
||||
title=_("Inspection Rejected"),
|
||||
exc=QualityInspectionRejectedError,
|
||||
)
|
||||
else:
|
||||
frappe.msgprint(
|
||||
_("Quality Inspection {0} is rejected for the item: {1}").format(
|
||||
get_link_to_form("Quality Inspection", self.quality_inspection),
|
||||
get_link_to_form("Item", item),
|
||||
),
|
||||
alert=True,
|
||||
indicator="orange",
|
||||
)
|
||||
|
||||
def validate_transfer_qty(self):
|
||||
if (
|
||||
not self.finished_good
|
||||
@@ -1251,10 +1337,26 @@ class JobCard(Document):
|
||||
frappe.db.set_value("Workstation", self.workstation, "status", status)
|
||||
|
||||
def add_time_logs(self, **kwargs):
|
||||
row = None
|
||||
kwargs = frappe._dict(kwargs)
|
||||
if not kwargs.employees and kwargs.to_time:
|
||||
for row in self.time_logs:
|
||||
if not row.to_time and row.from_time:
|
||||
row.to_time = kwargs.to_time
|
||||
row.time_in_mins = time_diff_in_minutes(row.to_time, row.from_time)
|
||||
|
||||
if kwargs.completed_qty:
|
||||
row.completed_qty = kwargs.completed_qty
|
||||
row.db_update()
|
||||
else:
|
||||
self.add_time_logs_for_employess(kwargs)
|
||||
|
||||
self.validate_time_logs(save=True)
|
||||
self.save()
|
||||
|
||||
def add_time_logs_for_employess(self, kwargs):
|
||||
row = None
|
||||
update_status = False
|
||||
|
||||
for employee in kwargs.employees:
|
||||
kwargs.employee = employee.get("employee")
|
||||
if kwargs.from_time and not kwargs.to_time:
|
||||
@@ -1290,9 +1392,6 @@ class JobCard(Document):
|
||||
|
||||
self.set_status(update_status=update_status)
|
||||
|
||||
self.validate_time_logs(save=True)
|
||||
self.save()
|
||||
|
||||
def update_workstation_status(self):
|
||||
status_map = {
|
||||
"Open": "Off",
|
||||
@@ -1341,6 +1440,9 @@ class JobCard(Document):
|
||||
employees=self.employee,
|
||||
sub_operation=kwargs.get("sub_operation"),
|
||||
)
|
||||
|
||||
if self.docstatus == 1:
|
||||
self.update_work_order()
|
||||
else:
|
||||
self.add_time_logs(completed_qty=kwargs.qty, employees=self.employee)
|
||||
self.save()
|
||||
|
||||
@@ -20,8 +20,9 @@ from erpnext.manufacturing.doctype.job_card.job_card import (
|
||||
make_stock_entry as make_stock_entry_from_jc,
|
||||
)
|
||||
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import WorkOrder
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import WorkOrder, make_work_order
|
||||
from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation
|
||||
from erpnext.stock.doctype.item.test_item import create_item
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
@@ -73,6 +74,68 @@ class TestJobCard(ERPNextTestSuite):
|
||||
def tearDown(self):
|
||||
frappe.db.rollback()
|
||||
|
||||
def test_quality_inspection_mandatory_check(self):
|
||||
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
||||
|
||||
raw = create_item("Fabric-Raw")
|
||||
cut_fg = create_item("Cut-Fabric-SFG")
|
||||
stitch_fg = create_item("Stitched-TShirt-SFG")
|
||||
final = create_item("Finished-TShirt")
|
||||
|
||||
row = {"operation": "Cutting", "workstation": "_Test Workstation 1"}
|
||||
|
||||
cutting = make_operation(row)
|
||||
stitching = make_operation({"operation": "Stitching", "workstation": "_Test Workstation 1"})
|
||||
ironing = make_operation({"operation": "Ironing", "workstation": "_Test Workstation 1"})
|
||||
|
||||
cut_bom = create_semi_fg_bom(cut_fg.name, raw.name, inspection_required=1)
|
||||
stitch_bom = create_semi_fg_bom(stitch_fg.name, cut_fg.name, inspection_required=0)
|
||||
final_bom = frappe.new_doc(
|
||||
"BOM", item=final.name, quantity=1, with_operations=1, track_semi_finished_goods=1
|
||||
)
|
||||
final_bom.append("items", {"item_code": raw.name, "qty": 1})
|
||||
final_bom.append(
|
||||
"operations",
|
||||
{
|
||||
"operation": cutting.name,
|
||||
"workstation": "_Test Workstation 1",
|
||||
"bom_no": cut_bom,
|
||||
"skip_material_transfer": 1,
|
||||
},
|
||||
)
|
||||
final_bom.append(
|
||||
"operations",
|
||||
{
|
||||
"operation": stitching.name,
|
||||
"workstation": "_Test Workstation 1",
|
||||
"bom_no": stitch_bom,
|
||||
"skip_material_transfer": 1,
|
||||
},
|
||||
)
|
||||
final_bom.append(
|
||||
"operations",
|
||||
{
|
||||
"operation": ironing.name,
|
||||
"workstation": "_Test Workstation 1",
|
||||
"is_final_finished_good": 1,
|
||||
"bom_no": final_bom.name,
|
||||
"skip_material_transfer": 1,
|
||||
},
|
||||
)
|
||||
final_bom.append("items", {"item_code": stitch_fg.name, "qty": 1, "operation_row_id": 3})
|
||||
final_bom.submit()
|
||||
work_order = make_work_order(final_bom.name, final.name, 1, variant_items=[], use_multi_level_bom=0)
|
||||
work_order.wip_warehouse = "Work In Progress - WP"
|
||||
work_order.fg_warehouse = "Finished Goods - WP"
|
||||
work_order.scrap_warehouse = "All Warehouses - WP"
|
||||
for operation in work_order.operations:
|
||||
operation.time_in_mins = 60
|
||||
|
||||
work_order.submit()
|
||||
job_card = frappe.get_all("Job Card", filters={"work_order": work_order.name, "operation": "Cutting"})
|
||||
job_card_doc = frappe.get_doc("Job Card", job_card[0].name)
|
||||
self.assertRaises(frappe.ValidationError, job_card_doc.submit)
|
||||
|
||||
def test_job_card_operations(self):
|
||||
job_cards = frappe.get_all(
|
||||
"Job Card", filters={"work_order": self.work_order.name}, fields=["operation_id", "name"]
|
||||
@@ -871,3 +934,13 @@ def make_wo_with_transfer_against_jc():
|
||||
work_order.submit()
|
||||
|
||||
return work_order
|
||||
|
||||
|
||||
def create_semi_fg_bom(semi_fg_item, raw_item, inspection_required):
|
||||
bom = frappe.new_doc("BOM")
|
||||
bom.item = semi_fg_item
|
||||
bom.quantity = 1
|
||||
bom.inspection_required = inspection_required
|
||||
bom.append("items", {"item_code": raw_item, "qty": 1})
|
||||
bom.submit()
|
||||
return bom.name
|
||||
|
||||
@@ -497,7 +497,7 @@ class ProductionPlan(Document):
|
||||
|
||||
item_details = get_item_details(data.item_code, throw=False)
|
||||
if self.combine_items:
|
||||
bom_no = item_details.bom_no
|
||||
bom_no = item_details.get("bom_no")
|
||||
if data.get("bom_no"):
|
||||
bom_no = data.get("bom_no")
|
||||
|
||||
|
||||
@@ -809,7 +809,7 @@ erpnext.work_order = {
|
||||
}
|
||||
}
|
||||
|
||||
if (frm.doc.status != "Stopped") {
|
||||
if (frm.doc.status != "Stopped" && !frm.doc.track_semi_finished_goods) {
|
||||
// If "Material Consumption is check in Manufacturing Settings, allow Material Consumption
|
||||
if (frm.doc.__onload && frm.doc.__onload.material_consumption == 1) {
|
||||
if (flt(doc.material_transferred_for_manufacturing) > 0 || frm.doc.skip_transfer) {
|
||||
|
||||
@@ -248,6 +248,16 @@ class WorkOrder(Document):
|
||||
if self.is_new() and frappe.db.get_single_value("Stock Settings", "auto_reserve_stock"):
|
||||
self.reserve_stock = 1
|
||||
|
||||
def before_save(self):
|
||||
self.set_skip_transfer_for_operations()
|
||||
|
||||
def set_skip_transfer_for_operations(self):
|
||||
if not self.track_semi_finished_goods:
|
||||
return
|
||||
|
||||
for op in self.operations:
|
||||
op.skip_material_transfer = self.skip_transfer
|
||||
|
||||
def validate_operations_sequence(self):
|
||||
if all([not op.sequence_id for op in self.operations]):
|
||||
for op in self.operations:
|
||||
@@ -1599,6 +1609,7 @@ class WorkOrder(Document):
|
||||
"item_code": row.item_code,
|
||||
"voucher_detail_no": row.name,
|
||||
"warehouse": row.source_warehouse,
|
||||
"status": ("not in", ["Closed", "Cancelled", "Completed"]),
|
||||
},
|
||||
pluck="name",
|
||||
):
|
||||
@@ -1807,24 +1818,10 @@ class WorkOrder(Document):
|
||||
elif stock_entry.job_card:
|
||||
# Reserve the final product for the job card.
|
||||
finished_good = frappe.db.get_value("Job Card", stock_entry.job_card, "finished_good")
|
||||
if finished_good == self.production_item:
|
||||
return
|
||||
|
||||
for row in stock_entry.items:
|
||||
if row.item_code == finished_good:
|
||||
item_details = [
|
||||
frappe._dict(
|
||||
{
|
||||
"item_code": row.item_code,
|
||||
"stock_qty": row.qty,
|
||||
"stock_reserved_qty": 0,
|
||||
"warehouse": row.t_warehouse,
|
||||
"voucher_no": stock_entry.work_order,
|
||||
"voucher_type": "Work Order",
|
||||
"name": row.name,
|
||||
"delivered_qty": 0,
|
||||
}
|
||||
)
|
||||
]
|
||||
break
|
||||
item_details = self.get_items_to_reserve_for_job_card(stock_entry, finished_good)
|
||||
else:
|
||||
# Reserve the final product for the sales order.
|
||||
item_details = self.get_so_details()
|
||||
@@ -1878,6 +1875,53 @@ class WorkOrder(Document):
|
||||
|
||||
return items
|
||||
|
||||
def get_items_to_reserve_for_job_card(self, stock_entry, finished_good):
|
||||
item_details = []
|
||||
for row in stock_entry.items:
|
||||
if row.item_code == finished_good:
|
||||
name = frappe.db.get_value(
|
||||
"Work Order Item",
|
||||
{"item_code": finished_good, "parent": self.name},
|
||||
"name",
|
||||
)
|
||||
|
||||
sres = frappe.get_all(
|
||||
"Stock Reservation Entry",
|
||||
fields=["reserved_qty"],
|
||||
filters={
|
||||
"voucher_no": self.name,
|
||||
"item_code": finished_good,
|
||||
"voucher_detail_no": name,
|
||||
"warehouse": row.t_warehouse,
|
||||
"docstatus": 1,
|
||||
"status": "Reserved",
|
||||
},
|
||||
)
|
||||
|
||||
pending_qty = row.qty
|
||||
for d in sres:
|
||||
pending_qty -= d.reserved_qty
|
||||
|
||||
if pending_qty > 0:
|
||||
item_details = [
|
||||
frappe._dict(
|
||||
{
|
||||
"item_code": row.item_code,
|
||||
"stock_qty": pending_qty,
|
||||
"stock_reserved_qty": 0,
|
||||
"warehouse": row.t_warehouse,
|
||||
"voucher_no": stock_entry.work_order,
|
||||
"voucher_type": "Work Order",
|
||||
"name": name,
|
||||
"delivered_qty": 0,
|
||||
}
|
||||
)
|
||||
]
|
||||
|
||||
break
|
||||
|
||||
return item_details
|
||||
|
||||
def get_wo_details(self):
|
||||
doctype = frappe.qb.DocType("Work Order")
|
||||
child_doctype = frappe.qb.DocType("Work Order Item")
|
||||
|
||||
@@ -8,6 +8,8 @@ from frappe.utils import getdate, today
|
||||
|
||||
from erpnext.stock.report.stock_analytics.stock_analytics import get_period, get_period_date_ranges
|
||||
|
||||
WORK_ORDER_STATUS_LIST = ["Not Started", "Overdue", "Pending", "Completed", "Closed", "Stopped"]
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
columns = get_columns(filters)
|
||||
@@ -16,119 +18,98 @@ def execute(filters=None):
|
||||
|
||||
|
||||
def get_columns(filters):
|
||||
columns = [{"label": _("Status"), "fieldname": "Status", "fieldtype": "Data", "width": 140}]
|
||||
|
||||
columns = [{"label": _("Status"), "fieldname": "status", "fieldtype": "Data", "width": 140}]
|
||||
ranges = get_period_date_ranges(filters)
|
||||
|
||||
for _dummy, end_date in ranges:
|
||||
period = get_period(end_date, filters)
|
||||
|
||||
columns.append({"label": _(period), "fieldname": scrub(period), "fieldtype": "Float", "width": 120})
|
||||
|
||||
return columns
|
||||
|
||||
|
||||
def get_periodic_data(filters, entry):
|
||||
periodic_data = {
|
||||
"Not Started": {},
|
||||
"Overdue": {},
|
||||
"Pending": {},
|
||||
"Completed": {},
|
||||
"Closed": {},
|
||||
"Stopped": {},
|
||||
}
|
||||
def get_work_orders(filters):
|
||||
from_date = filters.get("from_date")
|
||||
to_date = filters.get("to_date")
|
||||
|
||||
ranges = get_period_date_ranges(filters)
|
||||
WorkOrder = frappe.qb.DocType("Work Order")
|
||||
|
||||
for from_date, end_date in ranges:
|
||||
period = get_period(end_date, filters)
|
||||
for d in entry:
|
||||
if getdate(from_date) <= getdate(d.creation) <= getdate(end_date) and d.status not in [
|
||||
"Draft",
|
||||
"Submitted",
|
||||
"Completed",
|
||||
"Cancelled",
|
||||
]:
|
||||
if d.status in ["Not Started", "Closed", "Stopped"]:
|
||||
periodic_data = update_periodic_data(periodic_data, d.status, period)
|
||||
elif getdate(today()) > getdate(d.planned_end_date):
|
||||
periodic_data = update_periodic_data(periodic_data, "Overdue", period)
|
||||
elif getdate(today()) < getdate(d.planned_end_date):
|
||||
periodic_data = update_periodic_data(periodic_data, "Pending", period)
|
||||
|
||||
if (
|
||||
getdate(from_date) <= getdate(d.actual_end_date) <= getdate(end_date)
|
||||
and d.status == "Completed"
|
||||
):
|
||||
periodic_data = update_periodic_data(periodic_data, "Completed", period)
|
||||
|
||||
return periodic_data
|
||||
|
||||
|
||||
def update_periodic_data(periodic_data, status, period):
|
||||
if periodic_data.get(status).get(period):
|
||||
periodic_data[status][period] += 1
|
||||
else:
|
||||
periodic_data[status][period] = 1
|
||||
|
||||
return periodic_data
|
||||
return (
|
||||
frappe.qb.from_(WorkOrder)
|
||||
.select(WorkOrder.creation, WorkOrder.actual_end_date, WorkOrder.planned_end_date, WorkOrder.status)
|
||||
.where(
|
||||
(WorkOrder.docstatus == 1)
|
||||
& (WorkOrder.company == filters.get("company"))
|
||||
& (
|
||||
(WorkOrder.creation.between(from_date, to_date))
|
||||
| (WorkOrder.actual_end_date.between(from_date, to_date))
|
||||
)
|
||||
)
|
||||
.run(as_dict=True)
|
||||
)
|
||||
|
||||
|
||||
def get_data(filters, columns):
|
||||
data = []
|
||||
entry = frappe.get_all(
|
||||
"Work Order",
|
||||
fields=[
|
||||
"creation",
|
||||
"actual_end_date",
|
||||
"planned_end_date",
|
||||
"status",
|
||||
],
|
||||
filters={"docstatus": 1, "company": filters["company"]},
|
||||
)
|
||||
ranges = build_ranges(filters)
|
||||
period_labels = [scrub(pd) for _fd, _td, pd in ranges]
|
||||
periodic_data = {status: {pd: 0 for pd in period_labels} for status in WORK_ORDER_STATUS_LIST}
|
||||
entries = get_work_orders(filters)
|
||||
|
||||
periodic_data = get_periodic_data(filters, entry)
|
||||
for d in entries:
|
||||
if d.status == "Completed":
|
||||
if not d.actual_end_date:
|
||||
continue
|
||||
|
||||
labels = ["Not Started", "Overdue", "Pending", "Completed", "Closed", "Stopped"]
|
||||
chart_data = get_chart_data(periodic_data, columns)
|
||||
ranges = get_period_date_ranges(filters)
|
||||
if period := scrub(get_period_for_date(getdate(d.actual_end_date), ranges)):
|
||||
periodic_data["Completed"][period] += 1
|
||||
continue
|
||||
|
||||
for label in labels:
|
||||
work = {}
|
||||
work["Status"] = _(label)
|
||||
for _dummy, end_date in ranges:
|
||||
period = get_period(end_date, filters)
|
||||
if periodic_data.get(label).get(period):
|
||||
work[scrub(period)] = periodic_data.get(label).get(period)
|
||||
creation_date = getdate(d.creation)
|
||||
period = scrub(get_period_for_date(creation_date, ranges))
|
||||
if not period:
|
||||
continue
|
||||
|
||||
if d.status in ("Not Started", "Closed", "Stopped"):
|
||||
periodic_data[d.status][period] += 1
|
||||
else:
|
||||
if d.planned_end_date and getdate(today()) > getdate(d.planned_end_date):
|
||||
periodic_data["Overdue"][period] += 1
|
||||
else:
|
||||
work[scrub(period)] = 0.0
|
||||
data.append(work)
|
||||
periodic_data["Pending"][period] += 1
|
||||
|
||||
return data, chart_data
|
||||
data = []
|
||||
for status in WORK_ORDER_STATUS_LIST:
|
||||
row = {"status": _(status)}
|
||||
for _fd, _td, period in ranges:
|
||||
row[scrub(period)] = periodic_data[status].get(scrub(period), 0)
|
||||
data.append(row)
|
||||
|
||||
chart = get_chart_data(periodic_data, columns)
|
||||
return data, chart
|
||||
|
||||
|
||||
def get_period_for_date(date, ranges):
|
||||
for from_date, to_date, period in ranges:
|
||||
if from_date <= date <= to_date:
|
||||
return period
|
||||
return None
|
||||
|
||||
|
||||
def build_ranges(filters):
|
||||
ranges = []
|
||||
for from_date, end_date in get_period_date_ranges(filters):
|
||||
period = get_period(end_date, filters)
|
||||
ranges.append((getdate(from_date), getdate(end_date), period))
|
||||
return ranges
|
||||
|
||||
|
||||
def get_chart_data(periodic_data, columns):
|
||||
labels = [d.get("label") for d in columns[1:]]
|
||||
period_labels = [d.get("label") for d in columns[1:]]
|
||||
period_fieldnames = [d.get("fieldname") for d in columns[1:]]
|
||||
|
||||
not_start, overdue, pending, completed, closed, stopped = [], [], [], [], [], []
|
||||
datasets = []
|
||||
for status in WORK_ORDER_STATUS_LIST:
|
||||
values = [periodic_data.get(status, {}).get(fieldname, 0) for fieldname in period_fieldnames]
|
||||
datasets.append({"name": _(status), "values": values})
|
||||
|
||||
for d in labels:
|
||||
not_start.append(periodic_data.get("Not Started").get(d))
|
||||
overdue.append(periodic_data.get("Overdue").get(d))
|
||||
pending.append(periodic_data.get("Pending").get(d))
|
||||
completed.append(periodic_data.get("Completed").get(d))
|
||||
closed.append(periodic_data.get("Closed").get(d))
|
||||
stopped.append(periodic_data.get("Stopped").get(d))
|
||||
|
||||
datasets.append({"name": _("Not Started"), "values": not_start})
|
||||
datasets.append({"name": _("Overdue"), "values": overdue})
|
||||
datasets.append({"name": _("Pending"), "values": pending})
|
||||
datasets.append({"name": _("Completed"), "values": completed})
|
||||
datasets.append({"name": _("Closed"), "values": closed})
|
||||
datasets.append({"name": _("Stopped"), "values": stopped})
|
||||
|
||||
chart = {"data": {"labels": labels, "datasets": datasets}}
|
||||
chart["type"] = "line"
|
||||
|
||||
return chart
|
||||
return {"data": {"labels": period_labels, "datasets": datasets}, "type": "line"}
|
||||
|
||||
@@ -456,9 +456,11 @@ erpnext.patches.v16_0.update_tax_withholding_field_in_payment_entry
|
||||
erpnext.patches.v16_0.migrate_tax_withholding_data
|
||||
erpnext.patches.v16_0.update_corrected_cancelled_status
|
||||
erpnext.patches.v16_0.fix_barcode_typo
|
||||
erpnext.patches.v16_0.add_accounting_dimensions_to_journal_template_accounts
|
||||
erpnext.patches.v16_0.set_post_change_gl_entries_on_pos_settings
|
||||
execute:frappe.delete_doc_if_exists("Workspace Sidebar", "Opening & Closing")
|
||||
erpnext.patches.v15_0.create_accounting_dimensions_in_advance_taxes_and_charges
|
||||
erpnext.patches.v16_0.set_ordered_qty_in_quotation_item
|
||||
erpnext.patches.v16_0.migrate_transaction_deletion_task_flags_to_status # 2
|
||||
erpnext.patches.v16_0.update_company_custom_field_in_bin
|
||||
erpnext.patches.v15_0.replace_http_with_https_in_sales_partner
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import frappe
|
||||
from frappe import qb
|
||||
from pypika.functions import Replace
|
||||
|
||||
|
||||
def execute():
|
||||
sp = frappe.qb.DocType("Sales Partner")
|
||||
qb.update(sp).set(sp.partner_website, Replace(sp.partner_website, "http://", "https://")).where(
|
||||
sp.partner_website.rlike("^http://.*")
|
||||
).run()
|
||||
@@ -0,0 +1,11 @@
|
||||
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
||||
get_dimensions,
|
||||
make_dimension_in_accounting_doctypes,
|
||||
)
|
||||
|
||||
|
||||
def execute():
|
||||
dimensions_and_defaults = get_dimensions()
|
||||
if dimensions_and_defaults:
|
||||
for dimension in dimensions_and_defaults[0]:
|
||||
make_dimension_in_accounting_doctypes(dimension, ["Journal Entry Template Account"])
|
||||
@@ -989,7 +989,7 @@ erpnext.utils.map_current_doc = function (opts) {
|
||||
}
|
||||
|
||||
if (query_args.filters || query_args.query) {
|
||||
opts.get_query = () => query_args;
|
||||
opts.get_query = () => JSON.parse(JSON.stringify(query_args));
|
||||
}
|
||||
|
||||
if (opts.source_doctype) {
|
||||
|
||||
@@ -110,12 +110,6 @@ frappe.ui.form.ContactAddressQuickEntryForm = class ContactAddressQuickEntryForm
|
||||
options: "Country",
|
||||
mandatory_depends_on: "eval:doc.city || doc.address_line1",
|
||||
},
|
||||
{
|
||||
label: __("Customer POS Id"),
|
||||
fieldname: "customer_pos_id",
|
||||
fieldtype: "Data",
|
||||
hidden: 1,
|
||||
},
|
||||
];
|
||||
|
||||
return variant_fields;
|
||||
|
||||
@@ -28,4 +28,4 @@ erpnext.demo.clear_demo = function () {
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
@@ -621,6 +621,9 @@ def handle_mandatory_error(e, customer, lead_name):
|
||||
def get_ordered_items(quotation: str):
|
||||
return frappe._dict(
|
||||
frappe.get_all(
|
||||
"Quotation Item", {"docstatus": 1, "parent": quotation}, ["name", "ordered_qty"], as_list=True
|
||||
"Quotation Item",
|
||||
{"docstatus": 1, "parent": quotation, "ordered_qty": (">", 0)},
|
||||
["name", "ordered_qty"],
|
||||
as_list=True,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -11,18 +11,18 @@
|
||||
"field_order": [
|
||||
"customer_section",
|
||||
"column_break0",
|
||||
"company",
|
||||
"naming_series",
|
||||
"customer",
|
||||
"customer_name",
|
||||
"tax_id",
|
||||
"order_type",
|
||||
"column_break_7",
|
||||
"transaction_date",
|
||||
"delivery_date",
|
||||
"column_break1",
|
||||
"customer",
|
||||
"customer_name",
|
||||
"tax_id",
|
||||
"po_no",
|
||||
"po_date",
|
||||
"company",
|
||||
"skip_delivery_note",
|
||||
"has_unit_price_items",
|
||||
"is_subcontracted",
|
||||
@@ -1458,9 +1458,9 @@
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "eval:doc.order_type == 'Maintenance';",
|
||||
"fieldname": "skip_delivery_note",
|
||||
"fieldtype": "Check",
|
||||
"hidden": 1,
|
||||
"hide_days": 1,
|
||||
"hide_seconds": 1,
|
||||
"label": "Skip Delivery Note",
|
||||
@@ -1713,7 +1713,7 @@
|
||||
"idx": 105,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-01-29 21:23:48.362401",
|
||||
"modified": "2026-02-06 11:06:16.092658",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Sales Order",
|
||||
|
||||
@@ -57,6 +57,28 @@ class TestSalesOrder(AccountsTestMixin, IntegrationTestCase):
|
||||
frappe.db.rollback()
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
def test_sales_order_skip_delivery_note(self):
|
||||
so = make_sales_order(do_not_submit=True)
|
||||
so.order_type = "Maintenance"
|
||||
so.skip_delivery_note = 1
|
||||
so.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": "_Test Item 2",
|
||||
"qty": 2,
|
||||
"rate": 100,
|
||||
},
|
||||
)
|
||||
so.save()
|
||||
so.submit()
|
||||
|
||||
so.reload()
|
||||
si = make_sales_invoice(so.name)
|
||||
si.insert()
|
||||
si.submit()
|
||||
so.reload()
|
||||
self.assertEqual(so.status, "Completed")
|
||||
|
||||
@IntegrationTestCase.change_settings("Selling Settings", {"allow_negative_rates_for_items": 1})
|
||||
def test_sales_order_with_negative_rate(self):
|
||||
"""
|
||||
|
||||
@@ -162,8 +162,6 @@ class EmailDigest(Document):
|
||||
context.purchase_order_list,
|
||||
context.purchase_orders_items_overdue_list,
|
||||
) = self.get_purchase_orders_items_overdue_list()
|
||||
if not context.purchase_order_list:
|
||||
frappe.throw(_("No items to be received are overdue"))
|
||||
|
||||
if not context:
|
||||
return None
|
||||
|
||||
@@ -50,8 +50,17 @@ class SalesPartner(WebsiteGenerator):
|
||||
if not self.route:
|
||||
self.route = "partners/" + self.scrub(self.partner_name)
|
||||
super().validate()
|
||||
if self.partner_website and not self.partner_website.startswith("http"):
|
||||
self.partner_website = "http://" + self.partner_website
|
||||
if self.partner_website:
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
# scrub http
|
||||
parts = urlsplit(self.partner_website)
|
||||
if not parts.netloc and parts.path:
|
||||
parts = parts._replace(netloc=parts.path, path="")
|
||||
if not parts.scheme or parts.scheme == "http":
|
||||
parts = parts._replace(scheme="https")
|
||||
|
||||
self.partner_website = urlunsplit(parts)
|
||||
|
||||
def get_context(self, context):
|
||||
address_names = frappe.db.get_all(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import frappe
|
||||
from frappe.model.db_query import DatabaseQuery
|
||||
from frappe.desk.reportview import build_match_conditions
|
||||
from frappe.utils import cint, flt
|
||||
|
||||
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
|
||||
@@ -30,7 +30,7 @@ def get_data(
|
||||
filters.append(["item_code", "in", items])
|
||||
try:
|
||||
# check if user has any restrictions based on user permissions on warehouse
|
||||
if DatabaseQuery("Warehouse", user=frappe.session.user).build_match_conditions():
|
||||
if build_match_conditions("Warehouse", user=frappe.session.user):
|
||||
filters.append(["warehouse", "in", [w.name for w in frappe.get_list("Warehouse")]])
|
||||
except frappe.PermissionError:
|
||||
# user does not have access on warehouse
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import frappe
|
||||
from frappe.model.db_query import DatabaseQuery
|
||||
from frappe.desk.reportview import build_match_conditions
|
||||
from frappe.utils import flt, nowdate
|
||||
|
||||
from erpnext.stock.utils import get_stock_balance
|
||||
@@ -54,7 +54,7 @@ def get_filters(item_code=None, warehouse=None, parent_warehouse=None, company=N
|
||||
def get_warehouse_filter_based_on_permissions(filters):
|
||||
try:
|
||||
# check if user has any restrictions based on user permissions on warehouse
|
||||
if DatabaseQuery("Warehouse", user=frappe.session.user).build_match_conditions():
|
||||
if build_match_conditions("Warehouse", user=frappe.session.user):
|
||||
filters.append(["warehouse", "in", [w.name for w in frappe.get_list("Warehouse")]])
|
||||
return False, filters
|
||||
except frappe.PermissionError:
|
||||
|
||||
@@ -123,6 +123,80 @@ class TestBatch(IntegrationTestCase):
|
||||
for d in batches:
|
||||
self.assertEqual(d.qty, batchwise_qty[(d.batch_no, d.warehouse)])
|
||||
|
||||
def test_batch_qty_on_pos_creation(self):
|
||||
from erpnext.accounts.doctype.pos_closing_entry.test_pos_closing_entry import (
|
||||
init_user_and_profile,
|
||||
)
|
||||
from erpnext.accounts.doctype.pos_invoice.test_pos_invoice import create_pos_invoice
|
||||
from erpnext.accounts.doctype.pos_opening_entry.test_pos_opening_entry import create_opening_entry
|
||||
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
|
||||
get_auto_batch_nos,
|
||||
)
|
||||
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
|
||||
create_batch_item_with_batch,
|
||||
)
|
||||
|
||||
invoice_type = frappe.db.get_single_value("POS Settings", "invoice_type")
|
||||
session_user = frappe.session.user
|
||||
|
||||
try:
|
||||
# Set invoice type to POS Invoice
|
||||
frappe.db.set_single_value("POS Settings", "invoice_type", "POS Invoice")
|
||||
|
||||
# Create batch item
|
||||
create_batch_item_with_batch("_Test BATCH ITEM", "TestBatch-RS 02")
|
||||
|
||||
# Create stock entry
|
||||
se = make_stock_entry(
|
||||
target="_Test Warehouse - _TC",
|
||||
item_code="_Test BATCH ITEM",
|
||||
qty=30,
|
||||
basic_rate=100,
|
||||
)
|
||||
|
||||
se.reload()
|
||||
|
||||
batch_no = get_batch_from_bundle(se.items[0].serial_and_batch_bundle)
|
||||
|
||||
# Create opening entry
|
||||
session_user = frappe.session.user
|
||||
test_user, pos_profile = init_user_and_profile()
|
||||
create_opening_entry(pos_profile, test_user.name)
|
||||
|
||||
# POS Invoice 1, for the batch without bundle
|
||||
pos_inv1 = create_pos_invoice(item="_Test BATCH ITEM", rate=300, qty=15, do_not_save=1)
|
||||
pos_inv1.append(
|
||||
"payments",
|
||||
{"mode_of_payment": "Cash", "amount": 4500},
|
||||
)
|
||||
pos_inv1.items[0].batch_no = batch_no
|
||||
pos_inv1.save()
|
||||
pos_inv1.submit()
|
||||
pos_inv1.reload()
|
||||
|
||||
# Get auto batch nos after pos invoice
|
||||
batches = get_auto_batch_nos(
|
||||
frappe._dict(
|
||||
{
|
||||
"item_code": "_Test BATCH ITEM",
|
||||
"warehouse": "_Test Warehouse - _TC",
|
||||
"for_stock_levels": True,
|
||||
"ignore_reserved_stock": True,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# Check batch qty after pos invoice
|
||||
row = _find_batch_row(batches, batch_no, "_Test Warehouse - _TC")
|
||||
self.assertIsNotNone(row)
|
||||
self.assertEqual(row.qty, 30)
|
||||
|
||||
finally:
|
||||
# Set invoice type to Sales Invoice
|
||||
frappe.db.set_single_value("POS Settings", "invoice_type", invoice_type)
|
||||
# Set user to session user
|
||||
frappe.set_user(session_user)
|
||||
|
||||
def test_stock_entry_incoming(self):
|
||||
"""Test batch creation via Stock Entry (Work Order)"""
|
||||
|
||||
@@ -610,6 +684,10 @@ def create_price_list_for_batch(item_code, batch, rate):
|
||||
).insert()
|
||||
|
||||
|
||||
def _find_batch_row(batches, batch_no, warehouse):
|
||||
return next((b for b in batches if b.batch_no == batch_no and b.warehouse == warehouse), None)
|
||||
|
||||
|
||||
def make_new_batch(**args):
|
||||
args = frappe._dict(args)
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ class InventoryDimension(Document):
|
||||
self.source_fieldname = scrub(self.dimension_name)
|
||||
|
||||
if not self.target_fieldname:
|
||||
self.target_fieldname = scrub(self.reference_document)
|
||||
self.target_fieldname = scrub(self.dimension_name)
|
||||
|
||||
def on_update(self):
|
||||
self.add_custom_fields()
|
||||
|
||||
@@ -117,12 +117,12 @@ class TestInventoryDimension(IntegrationTestCase):
|
||||
inward.load_from_db()
|
||||
|
||||
sle_data = frappe.db.get_value(
|
||||
"Stock Ledger Entry", {"voucher_no": inward.name}, ["shelf", "warehouse"], as_dict=1
|
||||
"Stock Ledger Entry", {"voucher_no": inward.name}, ["to_shelf", "warehouse"], as_dict=1
|
||||
)
|
||||
|
||||
self.assertEqual(inward.items[0].to_shelf, "Shelf 1")
|
||||
self.assertEqual(sle_data.warehouse, warehouse)
|
||||
self.assertEqual(sle_data.shelf, "Shelf 1")
|
||||
self.assertEqual(sle_data.to_shelf, "Shelf 1")
|
||||
|
||||
outward = make_stock_entry(
|
||||
item_code=item_code,
|
||||
|
||||
@@ -30,7 +30,10 @@ frappe.ui.form.on("Material Request", {
|
||||
|
||||
frm.set_query("from_warehouse", "items", function (doc) {
|
||||
return {
|
||||
filters: { company: doc.company },
|
||||
filters: {
|
||||
company: doc.company,
|
||||
is_group: 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -70,19 +73,28 @@ frappe.ui.form.on("Material Request", {
|
||||
|
||||
frm.set_query("warehouse", "items", function (doc) {
|
||||
return {
|
||||
filters: { company: doc.company },
|
||||
filters: {
|
||||
company: doc.company,
|
||||
is_group: 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
frm.set_query("set_warehouse", function (doc) {
|
||||
return {
|
||||
filters: { company: doc.company },
|
||||
filters: {
|
||||
company: doc.company,
|
||||
is_group: 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
frm.set_query("set_from_warehouse", function (doc) {
|
||||
return {
|
||||
filters: { company: doc.company },
|
||||
filters: {
|
||||
company: doc.company,
|
||||
is_group: 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -777,6 +777,9 @@ def make_stock_entry(source_name, target_doc=None):
|
||||
target.purpose = source.material_request_type
|
||||
target.from_warehouse = source.set_from_warehouse
|
||||
target.to_warehouse = source.set_warehouse
|
||||
if source.material_request_type == "Material Issue":
|
||||
target.from_warehouse = source.set_warehouse
|
||||
target.to_warehouse = None
|
||||
|
||||
if source.job_card:
|
||||
target.purpose = "Material Transfer for Manufacture"
|
||||
|
||||
@@ -1017,15 +1017,27 @@ class TestMaterialRequest(IntegrationTestCase):
|
||||
import json
|
||||
|
||||
from erpnext.stock.doctype.pick_list.pick_list import create_stock_entry
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
mr = make_material_request(material_request_type="Material Transfer")
|
||||
new_item = create_item("_Test Pick List Item", is_stock_item=1)
|
||||
item_code = new_item.name
|
||||
|
||||
make_stock_entry(
|
||||
item_code=item_code,
|
||||
target="_Test Warehouse - _TC",
|
||||
qty=10,
|
||||
do_not_save=False,
|
||||
do_not_submit=False,
|
||||
)
|
||||
|
||||
mr = make_material_request(item_code=item_code, material_request_type="Material Transfer")
|
||||
pl = create_pick_list(mr.name)
|
||||
pl.save()
|
||||
pl.locations[0].qty = 5
|
||||
pl.locations[0].stock_qty = 5
|
||||
pl.submit()
|
||||
|
||||
to_warehouse = create_warehouse("Test To Warehouse")
|
||||
to_warehouse = create_warehouse("_Test Warehouse - _TC")
|
||||
|
||||
se_data = create_stock_entry(json.dumps(pl.as_dict()))
|
||||
se = frappe.get_doc(se_data)
|
||||
@@ -1044,6 +1056,15 @@ class TestMaterialRequest(IntegrationTestCase):
|
||||
|
||||
def test_mr_pick_list_qty_validation(self):
|
||||
"""Test for checking pick list qty validation from Material Request"""
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
make_stock_entry(
|
||||
item_code="_Test Item",
|
||||
target="_Test Warehouse - _TC",
|
||||
qty=10,
|
||||
do_not_save=False,
|
||||
do_not_submit=False,
|
||||
)
|
||||
|
||||
mr = make_material_request(material_request_type="Material Transfer")
|
||||
pl = create_pick_list(mr.name)
|
||||
@@ -1104,6 +1125,19 @@ class TestMaterialRequest(IntegrationTestCase):
|
||||
|
||||
self.assertRaises(frappe.ValidationError, end_transit_2.submit)
|
||||
|
||||
def test_make_stock_entry_material_issue_warehouse_mapping(self):
|
||||
"""Test to ensure while making stock entry from material request of type Material Issue, warehouse is mapped correctly"""
|
||||
mr = make_material_request(material_request_type="Material Issue", do_not_submit=True)
|
||||
mr.set_warehouse = "_Test Warehouse - _TC"
|
||||
mr.save()
|
||||
mr.submit()
|
||||
|
||||
se = make_stock_entry(mr.name)
|
||||
self.assertEqual(se.from_warehouse, "_Test Warehouse - _TC")
|
||||
self.assertIsNone(se.to_warehouse)
|
||||
se.save()
|
||||
se.submit()
|
||||
|
||||
|
||||
def get_in_transit_warehouse(company):
|
||||
if not frappe.db.exists("Warehouse Type", "Transit"):
|
||||
|
||||
@@ -1462,6 +1462,9 @@ class SerialandBatchBundle(Document):
|
||||
def throw_negative_batch(self, batch_no, available_qty, precision):
|
||||
from erpnext.stock.stock_ledger import NegativeStockError
|
||||
|
||||
if frappe.db.get_single_value("Stock Settings", "allow_negative_stock_for_batch"):
|
||||
return
|
||||
|
||||
frappe.throw(
|
||||
_(
|
||||
"""
|
||||
@@ -2690,7 +2693,10 @@ def get_auto_batch_nos(kwargs):
|
||||
|
||||
available_batches = get_available_batches(kwargs)
|
||||
stock_ledgers_batches = get_stock_ledgers_batches(kwargs)
|
||||
pos_invoice_batches = get_reserved_batches_for_pos(kwargs)
|
||||
|
||||
pos_invoice_batches = frappe._dict()
|
||||
if not kwargs.for_stock_levels:
|
||||
pos_invoice_batches = get_reserved_batches_for_pos(kwargs)
|
||||
|
||||
sre_reserved_batches = frappe._dict()
|
||||
if not kwargs.ignore_reserved_stock:
|
||||
|
||||
@@ -449,12 +449,12 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
def set_job_card_data(self):
|
||||
if self.job_card and not self.work_order:
|
||||
data = frappe.db.get_value(
|
||||
"Job Card", self.job_card, ["for_quantity", "work_order", "bom_no"], as_dict=1
|
||||
"Job Card", self.job_card, ["for_quantity", "work_order", "bom_no", "semi_fg_bom"], as_dict=1
|
||||
)
|
||||
self.fg_completed_qty = data.for_quantity
|
||||
self.work_order = data.work_order
|
||||
self.from_bom = 1
|
||||
self.bom_no = data.bom_no
|
||||
self.bom_no = data.semi_fg_bom or data.bom_no
|
||||
|
||||
def validate_job_card_fg_item(self):
|
||||
if not self.job_card:
|
||||
@@ -971,6 +971,7 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
|
||||
precision = frappe.get_precision("Stock Entry Detail", "qty")
|
||||
for item_code, details in raw_materials.items():
|
||||
item_code = item_code[0] if type(item_code) == tuple else item_code
|
||||
if matched_item := self.get_matched_items(item_code):
|
||||
if flt(details.get("qty"), precision) != flt(matched_item.qty, precision):
|
||||
frappe.throw(
|
||||
@@ -2005,6 +2006,7 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
else:
|
||||
job_doc.set_consumed_qty_in_job_card_item(self)
|
||||
job_doc.set_manufactured_qty()
|
||||
job_doc.update_work_order()
|
||||
|
||||
if self.work_order:
|
||||
pro_doc = frappe.get_doc("Work Order", self.work_order)
|
||||
|
||||
@@ -2,12 +2,16 @@
|
||||
# For license information, please see license.txt
|
||||
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import flt
|
||||
from frappe.utils import cint, flt
|
||||
|
||||
from erpnext.manufacturing.doctype.bom.bom import get_bom_items_as_dict
|
||||
from erpnext.stock.serial_batch_bundle import SerialBatchCreation
|
||||
from erpnext.stock.utils import get_combine_datetime
|
||||
|
||||
|
||||
class StockEntryType(Document):
|
||||
@@ -77,7 +81,6 @@ class ManufactureEntry:
|
||||
self.stock_entry.fg_completed_qty = self.for_quantity
|
||||
self.stock_entry.project = self.project
|
||||
self.stock_entry.job_card = self.job_card
|
||||
self.stock_entry.work_order = self.work_order
|
||||
self.stock_entry.set_stock_entry_type()
|
||||
|
||||
self.prepare_source_warehouse()
|
||||
@@ -107,11 +110,17 @@ class ManufactureEntry:
|
||||
"Manufacturing Settings", "backflush_raw_materials_based_on"
|
||||
)
|
||||
|
||||
available_serial_batches = frappe._dict({})
|
||||
if backflush_based_on != "BOM":
|
||||
available_serial_batches = self.get_transferred_serial_batches()
|
||||
|
||||
for item_code, _dict in item_dict.items():
|
||||
_dict.from_warehouse = self.source_wh.get(item_code) or self.wip_warehouse
|
||||
_dict.to_warehouse = ""
|
||||
|
||||
if backflush_based_on != "BOM":
|
||||
if backflush_based_on != "BOM" and not frappe.db.get_value(
|
||||
"Job Card", self.job_card, "skip_material_transfer"
|
||||
):
|
||||
calculated_qty = flt(_dict.transferred_qty) - flt(_dict.consumed_qty)
|
||||
if calculated_qty < 0:
|
||||
frappe.throw(
|
||||
@@ -119,9 +128,131 @@ class ManufactureEntry:
|
||||
)
|
||||
|
||||
_dict.qty = calculated_qty
|
||||
self.update_available_serial_batches(_dict, available_serial_batches)
|
||||
|
||||
self.stock_entry.add_to_stock_entry_detail(item_dict)
|
||||
|
||||
def parse_available_serial_batches(self, item_dict, available_serial_batches):
|
||||
key = (item_dict.item_code, item_dict.from_warehouse)
|
||||
if key not in available_serial_batches:
|
||||
return [], {}
|
||||
|
||||
_avl_dict = available_serial_batches[key]
|
||||
|
||||
qty = item_dict.qty
|
||||
serial_nos = []
|
||||
batches = frappe._dict()
|
||||
|
||||
if _avl_dict.serial_nos:
|
||||
serial_nos = _avl_dict.serial_nos[: cint(qty)]
|
||||
qty -= len(serial_nos)
|
||||
for sn in serial_nos:
|
||||
_avl_dict.serial_nos.remove(sn)
|
||||
|
||||
elif _avl_dict.batches:
|
||||
batches = frappe._dict()
|
||||
for batch_no, batch_qty in _avl_dict.batches.items():
|
||||
if qty <= 0:
|
||||
break
|
||||
if batch_qty <= qty:
|
||||
batches[batch_no] = batch_qty
|
||||
qty -= batch_qty
|
||||
else:
|
||||
batches[batch_no] = qty
|
||||
qty = 0
|
||||
|
||||
for _used_batch_no in batches:
|
||||
_avl_dict.batches[_used_batch_no] -= batches[_used_batch_no]
|
||||
if _avl_dict.batches[_used_batch_no] <= 0:
|
||||
del _avl_dict.batches[_used_batch_no]
|
||||
|
||||
return serial_nos, batches
|
||||
|
||||
def update_available_serial_batches(self, item_dict, available_serial_batches):
|
||||
serial_nos, batches = self.parse_available_serial_batches(item_dict, available_serial_batches)
|
||||
if serial_nos or batches:
|
||||
sabb = SerialBatchCreation(
|
||||
{
|
||||
"item_code": item_dict.item_code,
|
||||
"warehouse": item_dict.from_warehouse,
|
||||
"posting_datetime": get_combine_datetime(
|
||||
self.stock_entry.posting_date, self.stock_entry.posting_time
|
||||
),
|
||||
"voucher_type": self.stock_entry.doctype,
|
||||
"company": self.stock_entry.company,
|
||||
"type_of_transaction": "Outward",
|
||||
"qty": item_dict.qty,
|
||||
"serial_nos": serial_nos,
|
||||
"batches": batches,
|
||||
"do_not_submit": True,
|
||||
}
|
||||
).make_serial_and_batch_bundle()
|
||||
|
||||
item_dict.serial_and_batch_bundle = sabb.name
|
||||
|
||||
def get_stock_entry_data(self):
|
||||
stock_entry = frappe.qb.DocType("Stock Entry")
|
||||
stock_entry_detail = frappe.qb.DocType("Stock Entry Detail")
|
||||
|
||||
return (
|
||||
frappe.qb.from_(stock_entry)
|
||||
.inner_join(stock_entry_detail)
|
||||
.on(stock_entry.name == stock_entry_detail.parent)
|
||||
.select(
|
||||
stock_entry_detail.item_code,
|
||||
stock_entry_detail.qty,
|
||||
stock_entry_detail.serial_and_batch_bundle,
|
||||
stock_entry_detail.s_warehouse,
|
||||
stock_entry_detail.t_warehouse,
|
||||
stock_entry.purpose,
|
||||
)
|
||||
.where(
|
||||
(stock_entry.job_card == self.job_card)
|
||||
& (stock_entry_detail.serial_and_batch_bundle.isnotnull())
|
||||
& (stock_entry.docstatus == 1)
|
||||
& (stock_entry.purpose.isin(["Material Transfer for Manufacture", "Manufacture"]))
|
||||
)
|
||||
.orderby(stock_entry.posting_date, stock_entry.posting_time)
|
||||
).run(as_dict=True)
|
||||
|
||||
def get_transferred_serial_batches(self):
|
||||
available_serial_batches = frappe._dict({})
|
||||
|
||||
stock_entry_data = self.get_stock_entry_data()
|
||||
|
||||
for row in stock_entry_data:
|
||||
warehouse = (
|
||||
row.t_warehouse if row.purpose == "Material Transfer for Manufacture" else row.s_warehouse
|
||||
)
|
||||
key = (row.item_code, warehouse)
|
||||
if key not in available_serial_batches:
|
||||
available_serial_batches[key] = frappe._dict(
|
||||
{
|
||||
"batches": defaultdict(float),
|
||||
"serial_nos": [],
|
||||
}
|
||||
)
|
||||
|
||||
_avl_dict = available_serial_batches[key]
|
||||
|
||||
sabb_data = frappe.get_all(
|
||||
"Serial and Batch Entry",
|
||||
filters={"parent": row.serial_and_batch_bundle},
|
||||
fields=["serial_no", "batch_no", "qty"],
|
||||
)
|
||||
for entry in sabb_data:
|
||||
if entry.serial_no:
|
||||
if entry.qty > 0:
|
||||
_avl_dict.serial_nos.append(entry.serial_no)
|
||||
else:
|
||||
_avl_dict.serial_nos.remove(entry.serial_no)
|
||||
if entry.batch_no:
|
||||
_avl_dict.batches[entry.batch_no] += flt(entry.qty) * (
|
||||
-1 if row.purpose == "Material Transfer for Manufacture" else 1
|
||||
)
|
||||
|
||||
return available_serial_batches
|
||||
|
||||
def get_items_from_job_card(self):
|
||||
item_dict = {}
|
||||
items = frappe.get_all(
|
||||
|
||||
@@ -523,6 +523,9 @@ class StockReconciliation(StockController):
|
||||
if abs(difference_amount) > 0:
|
||||
return True
|
||||
|
||||
float_precision = frappe.db.get_default("float_precision") or 3
|
||||
item_dict["rate"] = flt(item_dict.get("rate"), float_precision)
|
||||
item.valuation_rate = flt(item.valuation_rate, float_precision) if item.valuation_rate else None
|
||||
if (
|
||||
(item.qty is None or item.qty == item_dict.get("qty"))
|
||||
and (item.valuation_rate is None or item.valuation_rate == item_dict.get("rate"))
|
||||
|
||||
@@ -1713,6 +1713,101 @@ class TestStockReconciliation(IntegrationTestCase, StockTestMixin):
|
||||
|
||||
self.assertEqual(docstatus, 2)
|
||||
|
||||
def test_stock_reco_with_opening_stock_with_diff_inventory(self):
|
||||
from erpnext.stock.doctype.inventory_dimension.test_inventory_dimension import (
|
||||
create_inventory_dimension,
|
||||
)
|
||||
|
||||
if frappe.db.exists("DocType", "Plant"):
|
||||
return
|
||||
|
||||
doctype = frappe.get_doc(
|
||||
{
|
||||
"doctype": "DocType",
|
||||
"name": "Plant",
|
||||
"module": "Stock",
|
||||
"custom": 1,
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "plant_name",
|
||||
"fieldtype": "Data",
|
||||
"label": "Plant Name",
|
||||
"reqd": 1,
|
||||
}
|
||||
],
|
||||
"autoname": "field:plant_name",
|
||||
}
|
||||
)
|
||||
doctype.insert(ignore_permissions=True)
|
||||
create_inventory_dimension(dimension_name="ID-Plant", reference_document="Plant")
|
||||
|
||||
plant_a = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Plant",
|
||||
"plant_name": "Plant A",
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
plant_b = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Plant",
|
||||
"plant_name": "Plant B",
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
|
||||
item_code = "Item-Test"
|
||||
item = self.make_item(item_code, {"is_stock_item": 1})
|
||||
|
||||
sr = frappe.new_doc("Stock Reconciliation")
|
||||
sr.purpose = "Opening Stock"
|
||||
sr.posting_date = nowdate()
|
||||
sr.posting_time = nowtime()
|
||||
sr.company = "_Test Company"
|
||||
|
||||
sr.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": item.name,
|
||||
"warehouse": warehouse,
|
||||
"qty": 5,
|
||||
"valuation_rate": 100,
|
||||
"id_plant": plant_a.name,
|
||||
},
|
||||
)
|
||||
|
||||
sr.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": item.name,
|
||||
"warehouse": warehouse,
|
||||
"qty": 3,
|
||||
"valuation_rate": 110,
|
||||
"id_plant": plant_b.name,
|
||||
},
|
||||
)
|
||||
|
||||
sr.insert()
|
||||
sr.submit()
|
||||
|
||||
self.assertEqual(len(sr.items), 2)
|
||||
sle_count = frappe.db.count(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_type": "Stock Reconciliation", "voucher_no": sr.name, "is_cancelled": 0},
|
||||
)
|
||||
self.assertEqual(sle_count, 2)
|
||||
sle = frappe.get_all(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_type": "Stock Reconciliation", "voucher_no": sr.name, "is_cancelled": 0},
|
||||
["item_code", "id_plant", "actual_qty", "valuation_rate"],
|
||||
)
|
||||
for s in sle:
|
||||
if s.id_plant == plant_a.name:
|
||||
self.assertEqual(s.actual_qty, 5)
|
||||
elif s.id_plant == plant_b.name:
|
||||
self.assertEqual(s.actual_qty, 3)
|
||||
|
||||
|
||||
def create_batch_item_with_batch(item_name, batch_id):
|
||||
batch_item_doc = create_item(item_name, is_stock_item=1)
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
"disable_serial_no_and_batch_selector",
|
||||
"use_serial_batch_fields",
|
||||
"do_not_update_serial_batch_on_creation_of_auto_bundle",
|
||||
"allow_negative_stock_for_batch",
|
||||
"serial_and_batch_bundle_section",
|
||||
"set_serial_and_batch_bundle_naming_based_on_naming_series",
|
||||
"section_break_gnhq",
|
||||
@@ -546,6 +547,13 @@
|
||||
"fieldname": "validate_material_transfer_warehouses",
|
||||
"fieldtype": "Check",
|
||||
"label": "Validate Material Transfer Warehouses"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "If enabled, the system will allow negative stock entries for the batch, but this could calculate the valuation rate incorrectly, so avoid using this option.",
|
||||
"fieldname": "allow_negative_stock_for_batch",
|
||||
"fieldtype": "Check",
|
||||
"label": "Allow Negative Stock for Batch"
|
||||
}
|
||||
],
|
||||
"hide_toolbar": 1,
|
||||
@@ -554,7 +562,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2026-01-02 18:19:59.034785",
|
||||
"modified": "2026-02-09 15:01:12.466175",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Stock Settings",
|
||||
|
||||
@@ -30,6 +30,7 @@ class StockSettings(Document):
|
||||
allow_from_pr: DF.Check
|
||||
allow_internal_transfer_at_arms_length_price: DF.Check
|
||||
allow_negative_stock: DF.Check
|
||||
allow_negative_stock_for_batch: DF.Check
|
||||
allow_partial_reservation: DF.Check
|
||||
allow_to_edit_stock_uom_qty_for_purchase: DF.Check
|
||||
allow_to_edit_stock_uom_qty_for_sales: DF.Check
|
||||
|
||||
@@ -314,7 +314,7 @@
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-01-26 21:23:15.665712",
|
||||
"modified": "2026-01-27 21:23:15.665712",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Invoicing",
|
||||
|
||||
Reference in New Issue
Block a user