mirror of
https://github.com/frappe/erpnext.git
synced 2026-06-08 23:52:57 +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",
|
"fieldname": "amount",
|
||||||
"fieldtype": "Currency",
|
"fieldtype": "Currency",
|
||||||
"label": "Amount",
|
"label": "Amount",
|
||||||
|
"options": "currency",
|
||||||
"read_only": 1
|
"read_only": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -541,7 +541,7 @@ class FinancialQueryBuilder:
|
|||||||
.where(acb_table.period_closing_voucher == closing_voucher)
|
.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")
|
results = self._execute_with_permissions(query, "Account Closing Balance")
|
||||||
|
|
||||||
for row in results:
|
for row in results:
|
||||||
@@ -636,12 +636,15 @@ class FinancialQueryBuilder:
|
|||||||
return self._execute_with_permissions(query, "GL Entry")
|
return self._execute_with_permissions(query, "GL Entry")
|
||||||
|
|
||||||
def _calculate_running_balances(self, balances_data: dict, gl_data: list[dict]) -> dict:
|
def _calculate_running_balances(self, balances_data: dict, gl_data: list[dict]) -> dict:
|
||||||
for row in gl_data:
|
gl_dict = {row["account"]: row for row in gl_data}
|
||||||
account = row["account"]
|
accounts = set(balances_data.keys()) | set(gl_dict.keys())
|
||||||
|
|
||||||
|
for account in accounts:
|
||||||
if account not in balances_data:
|
if account not in balances_data:
|
||||||
balances_data[account] = AccountData(account=account, **self._get_account_meta(account))
|
balances_data[account] = AccountData(account=account, **self._get_account_meta(account))
|
||||||
|
|
||||||
account_data: AccountData = balances_data[account]
|
account_data: AccountData = balances_data[account]
|
||||||
|
gl_movement = gl_dict.get(account, {})
|
||||||
|
|
||||||
if account_data.has_periods():
|
if account_data.has_periods():
|
||||||
first_period = account_data.get_period(self.periods[0]["key"])
|
first_period = account_data.get_period(self.periods[0]["key"])
|
||||||
@@ -651,20 +654,13 @@ class FinancialQueryBuilder:
|
|||||||
|
|
||||||
for period in self.periods:
|
for period in self.periods:
|
||||||
period_key = period["key"]
|
period_key = period["key"]
|
||||||
movement = row.get(period_key, 0.0)
|
movement = gl_movement.get(period_key, 0.0)
|
||||||
closing_balance = current_balance + movement
|
closing_balance = current_balance + movement
|
||||||
|
|
||||||
account_data.add_period(PeriodValue(period_key, current_balance, closing_balance, movement))
|
account_data.add_period(PeriodValue(period_key, current_balance, closing_balance, movement))
|
||||||
|
|
||||||
current_balance = closing_balance
|
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):
|
def _handle_balance_accumulation(self, balances_data):
|
||||||
for account_data in balances_data.values():
|
for account_data in balances_data.values():
|
||||||
account_data: AccountData
|
account_data: AccountData
|
||||||
@@ -683,12 +679,12 @@ class FinancialQueryBuilder:
|
|||||||
else:
|
else:
|
||||||
account_data.unaccumulate_values()
|
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 self.filters.get("ignore_closing_entries"):
|
||||||
if hasattr(table, "is_period_closing_voucher_entry"):
|
if doctype == "GL Entry":
|
||||||
query = query.where(table.is_period_closing_voucher_entry == 0)
|
|
||||||
else:
|
|
||||||
query = query.where(table.voucher_type != "Period Closing Voucher")
|
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"):
|
if self.filters.get("project"):
|
||||||
projects = 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 (
|
from erpnext.accounts.doctype.financial_report_template.test_financial_report_template import (
|
||||||
FinancialReportTemplateTestCase,
|
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
|
# On IntegrationTestCase, the doctype test records and all
|
||||||
# link-field test record dependencies are recursively loaded
|
# 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)
|
mock_row_invalid = self._create_mock_report_row(invalid_formula)
|
||||||
condition = parser.build_condition(mock_row_invalid, account_table)
|
condition = parser.build_condition(mock_row_invalid, account_table)
|
||||||
self.assertIsNone(condition)
|
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) {
|
var update_jv_details = function (doc, r) {
|
||||||
$.each(r, function (i, d) {
|
$.each(r, function (i, d) {
|
||||||
var row = frappe.model.add_child(doc, "Journal Entry Account", "accounts");
|
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");
|
refresh_field("accounts");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"engine": "InnoDB",
|
"engine": "InnoDB",
|
||||||
"field_order": [
|
"field_order": [
|
||||||
"entry_type_and_date",
|
"entry_type_and_date",
|
||||||
|
"company",
|
||||||
"is_system_generated",
|
"is_system_generated",
|
||||||
"title",
|
"title",
|
||||||
"voucher_type",
|
"voucher_type",
|
||||||
@@ -17,7 +18,6 @@
|
|||||||
"reversal_of",
|
"reversal_of",
|
||||||
"column_break1",
|
"column_break1",
|
||||||
"from_template",
|
"from_template",
|
||||||
"company",
|
|
||||||
"posting_date",
|
"posting_date",
|
||||||
"finance_book",
|
"finance_book",
|
||||||
"apply_tds",
|
"apply_tds",
|
||||||
@@ -638,7 +638,7 @@
|
|||||||
"table_fieldname": "payment_entries"
|
"table_fieldname": "payment_entries"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"modified": "2025-11-13 17:54:14.542903",
|
"modified": "2026-02-03 14:40:39.944524",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Accounts",
|
"module": "Accounts",
|
||||||
"name": "Journal Entry",
|
"name": "Journal Entry",
|
||||||
|
|||||||
@@ -74,8 +74,8 @@ class JournalEntry(AccountsController):
|
|||||||
mode_of_payment: DF.Link | None
|
mode_of_payment: DF.Link | None
|
||||||
multi_currency: DF.Check
|
multi_currency: DF.Check
|
||||||
naming_series: DF.Literal["ACC-JV-.YYYY.-"]
|
naming_series: DF.Literal["ACC-JV-.YYYY.-"]
|
||||||
party_not_required: DF.Check
|
|
||||||
override_tax_withholding_entries: DF.Check
|
override_tax_withholding_entries: DF.Check
|
||||||
|
party_not_required: DF.Check
|
||||||
pay_to_recd_from: DF.Data | None
|
pay_to_recd_from: DF.Data | None
|
||||||
payment_order: DF.Link | None
|
payment_order: DF.Link | None
|
||||||
periodic_entry_difference_account: DF.Link | None
|
periodic_entry_difference_account: DF.Link | None
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
frappe.ui.form.on("Journal Entry Template", {
|
frappe.ui.form.on("Journal Entry Template", {
|
||||||
onload: function (frm) {
|
onload: function (frm) {
|
||||||
|
erpnext.accounts.dimensions.setup_dimension_filters(frm, frm.doctype);
|
||||||
if (frm.is_new()) {
|
if (frm.is_new()) {
|
||||||
frappe.call({
|
frappe.call({
|
||||||
type: "GET",
|
type: "GET",
|
||||||
@@ -37,6 +38,31 @@ frappe.ui.form.on("Journal Entry Template", {
|
|||||||
|
|
||||||
return { filters: filters };
|
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) {
|
voucher_type: function (frm) {
|
||||||
var add_accounts = function (doc, r) {
|
var add_accounts = function (doc, r) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
|
from frappe import _
|
||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
|
|
||||||
|
|
||||||
@@ -42,7 +43,29 @@ class JournalEntryTemplate(Document):
|
|||||||
]
|
]
|
||||||
# end: auto-generated types
|
# 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()
|
@frappe.whitelist()
|
||||||
|
|||||||
@@ -5,7 +5,13 @@
|
|||||||
"editable_grid": 1,
|
"editable_grid": 1,
|
||||||
"engine": "InnoDB",
|
"engine": "InnoDB",
|
||||||
"field_order": [
|
"field_order": [
|
||||||
"account"
|
"account",
|
||||||
|
"party_type",
|
||||||
|
"party",
|
||||||
|
"accounting_dimensions_section",
|
||||||
|
"cost_center",
|
||||||
|
"dimension_col_break",
|
||||||
|
"project"
|
||||||
],
|
],
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
@@ -15,16 +21,53 @@
|
|||||||
"label": "Account",
|
"label": "Account",
|
||||||
"options": "Account",
|
"options": "Account",
|
||||||
"reqd": 1
|
"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,
|
"istable": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2024-03-27 13:09:58.986448",
|
"modified": "2026-01-09 13:16:27.615083",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Accounts",
|
"module": "Accounts",
|
||||||
"name": "Journal Entry Template Account",
|
"name": "Journal Entry Template Account",
|
||||||
"owner": "Administrator",
|
"owner": "Administrator",
|
||||||
"permissions": [],
|
"permissions": [],
|
||||||
|
"row_format": "Dynamic",
|
||||||
"sort_field": "creation",
|
"sort_field": "creation",
|
||||||
"sort_order": "DESC",
|
"sort_order": "DESC",
|
||||||
"states": [],
|
"states": [],
|
||||||
|
|||||||
@@ -16,9 +16,13 @@ class JournalEntryTemplateAccount(Document):
|
|||||||
from frappe.types import DF
|
from frappe.types import DF
|
||||||
|
|
||||||
account: DF.Link
|
account: DF.Link
|
||||||
|
cost_center: DF.Link | None
|
||||||
parent: DF.Data
|
parent: DF.Data
|
||||||
parentfield: DF.Data
|
parentfield: DF.Data
|
||||||
parenttype: DF.Data
|
parenttype: DF.Data
|
||||||
|
party: DF.DynamicLink | None
|
||||||
|
party_type: DF.Link | None
|
||||||
|
project: DF.Link | None
|
||||||
# end: auto-generated types
|
# end: auto-generated types
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -99,8 +99,7 @@ def get_customers_list(pos_profile=None):
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
frappe.db.sql(
|
frappe.db.sql(
|
||||||
f""" select name, customer_name, customer_group,
|
f""" select name, customer_name, customer_group, territory from tabCustomer where disabled = 0
|
||||||
territory, customer_pos_id from tabCustomer where disabled = 0
|
|
||||||
and {cond}""",
|
and {cond}""",
|
||||||
tuple(customer_groups),
|
tuple(customer_groups),
|
||||||
as_dict=1,
|
as_dict=1,
|
||||||
|
|||||||
@@ -8,12 +8,12 @@
|
|||||||
"email_append_to": 1,
|
"email_append_to": 1,
|
||||||
"engine": "InnoDB",
|
"engine": "InnoDB",
|
||||||
"field_order": [
|
"field_order": [
|
||||||
|
"company",
|
||||||
"title",
|
"title",
|
||||||
"naming_series",
|
"naming_series",
|
||||||
"supplier",
|
"supplier",
|
||||||
"supplier_name",
|
"supplier_name",
|
||||||
"tax_id",
|
"tax_id",
|
||||||
"company",
|
|
||||||
"column_break_6",
|
"column_break_6",
|
||||||
"posting_date",
|
"posting_date",
|
||||||
"posting_time",
|
"posting_time",
|
||||||
@@ -606,6 +606,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": "0",
|
"default": "0",
|
||||||
|
"depends_on": "eval:doc.items.every((item) => !item.pr_detail)",
|
||||||
"fieldname": "update_stock",
|
"fieldname": "update_stock",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Update Stock",
|
"label": "Update Stock",
|
||||||
@@ -1668,7 +1669,7 @@
|
|||||||
"idx": 204,
|
"idx": 204,
|
||||||
"is_submittable": 1,
|
"is_submittable": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2026-01-29 21:21:53.051193",
|
"modified": "2026-02-05 20:45:16.964500",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Accounts",
|
"module": "Accounts",
|
||||||
"name": "Purchase Invoice",
|
"name": "Purchase Invoice",
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
{
|
{
|
||||||
"actions": [],
|
"actions": [],
|
||||||
"allow_import": 1,
|
"allow_import": 1,
|
||||||
"allow_rename": 1,
|
|
||||||
"autoname": "naming_series:",
|
"autoname": "naming_series:",
|
||||||
"creation": "2022-01-25 10:29:57.771398",
|
"creation": "2022-01-25 10:29:57.771398",
|
||||||
"doctype": "DocType",
|
"doctype": "DocType",
|
||||||
"engine": "InnoDB",
|
"engine": "InnoDB",
|
||||||
"field_order": [
|
"field_order": [
|
||||||
"customer_section",
|
"customer_section",
|
||||||
|
"company",
|
||||||
|
"company_tax_id",
|
||||||
"naming_series",
|
"naming_series",
|
||||||
"customer",
|
"customer",
|
||||||
"customer_name",
|
"customer_name",
|
||||||
"tax_id",
|
"tax_id",
|
||||||
"company",
|
|
||||||
"company_tax_id",
|
|
||||||
"column_break1",
|
"column_break1",
|
||||||
"posting_date",
|
"posting_date",
|
||||||
"posting_time",
|
"posting_time",
|
||||||
@@ -704,6 +703,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": "0",
|
"default": "0",
|
||||||
|
"depends_on": "eval:doc.items.every((item) => !item.dn_detail)",
|
||||||
"fieldname": "update_stock",
|
"fieldname": "update_stock",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"hide_days": 1,
|
"hide_days": 1,
|
||||||
@@ -2306,7 +2306,7 @@
|
|||||||
"link_fieldname": "consolidated_invoice"
|
"link_fieldname": "consolidated_invoice"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"modified": "2026-01-30 16:45:59.682473",
|
"modified": "2026-02-06 20:43:44.732805",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Accounts",
|
"module": "Accounts",
|
||||||
"name": "Sales Invoice",
|
"name": "Sales Invoice",
|
||||||
|
|||||||
@@ -232,11 +232,11 @@ def get_report_summary(
|
|||||||
|
|
||||||
|
|
||||||
def get_chart_data(filters, columns, asset, liability, equity, currency):
|
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 = [], [], []
|
asset_data, liability_data, equity_data = [], [], []
|
||||||
|
|
||||||
for p in columns[2:]:
|
for p in columns[4:]:
|
||||||
if asset:
|
if asset:
|
||||||
asset_data.append(asset[-2].get(p.get("fieldname")))
|
asset_data.append(asset[-2].get(p.get("fieldname")))
|
||||||
if liability:
|
if liability:
|
||||||
|
|||||||
@@ -5,15 +5,16 @@ from collections import OrderedDict
|
|||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
from frappe import _, qb, scrub
|
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 frappe.utils import cint, flt, formatdate
|
||||||
|
from pypika.terms import ExistsCriterion
|
||||||
|
|
||||||
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
||||||
get_accounting_dimensions,
|
get_accounting_dimensions,
|
||||||
get_dimension_with_children,
|
get_dimension_with_children,
|
||||||
)
|
)
|
||||||
from erpnext.accounts.report.financial_statements import get_cost_centers_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.report.stock_ledger.stock_ledger import get_item_group_condition
|
||||||
from erpnext.stock.utils import get_incoming_rate
|
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()
|
column_names = get_column_names()
|
||||||
|
|
||||||
# to display item as Item Code: Item Name
|
# 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
|
# removing Item Code and Item Name columns
|
||||||
supplier_master_name = frappe.db.get_single_value("Buying Settings", "supp_master_name")
|
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")
|
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)
|
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(
|
data.append(
|
||||||
frappe._dict(
|
frappe._dict(
|
||||||
{
|
{
|
||||||
@@ -215,7 +222,7 @@ def get_data_when_grouped_by_invoice(columns, gross_profit_data, filters, group_
|
|||||||
"buying_amount": total_buying_amount,
|
"buying_amount": total_buying_amount,
|
||||||
"gross_profit": total_gross_profit,
|
"gross_profit": total_gross_profit,
|
||||||
"gross_profit_%": flt(
|
"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,
|
cint(frappe.db.get_default("currency_precision")) or 3,
|
||||||
)
|
)
|
||||||
if total_base_amount
|
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)
|
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
|
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 = {
|
total_row = {
|
||||||
group_columns[0]: "Total",
|
group_columns[0]: "Total",
|
||||||
@@ -581,10 +592,15 @@ class GrossProfitGenerator:
|
|||||||
base_amount += row.base_amount
|
base_amount += row.base_amount
|
||||||
|
|
||||||
# calculate gross profit
|
# 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:
|
if row.base_amount:
|
||||||
row.gross_profit_percent = flt(
|
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,
|
self.currency_precision,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -673,9 +689,14 @@ class GrossProfitGenerator:
|
|||||||
return new_row
|
return new_row
|
||||||
|
|
||||||
def set_average_gross_profit(self, 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 = (
|
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
|
if new_row.base_amount
|
||||||
else 0
|
else 0
|
||||||
)
|
)
|
||||||
@@ -851,129 +872,173 @@ class GrossProfitGenerator:
|
|||||||
return flt(last_purchase_rate[0][0]) if last_purchase_rate else 0
|
return flt(last_purchase_rate[0][0]) if last_purchase_rate else 0
|
||||||
|
|
||||||
def load_invoice_items(self):
|
def load_invoice_items(self):
|
||||||
conditions = ""
|
self.si_list = []
|
||||||
if self.filters.company:
|
|
||||||
conditions += " and `tabSales Invoice`.company = %(company)s"
|
SalesInvoice = frappe.qb.DocType("Sales Invoice")
|
||||||
if self.filters.from_date:
|
base_query = self.prepare_invoice_query()
|
||||||
conditions += " and posting_date >= %(from_date)s"
|
|
||||||
if self.filters.to_date:
|
|
||||||
conditions += " and posting_date <= %(to_date)s"
|
|
||||||
|
|
||||||
if self.filters.include_returned_invoices:
|
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:
|
else:
|
||||||
conditions += " and is_return = 0"
|
invoice_query = base_query.where(SalesInvoice.is_return == 0)
|
||||||
|
|
||||||
if self.filters.item_group:
|
self.si_list += invoice_query.run(as_dict=True)
|
||||||
conditions += f" and {get_item_group_condition(self.filters.item_group)}"
|
self.prepare_vouchers_to_ignore()
|
||||||
|
|
||||||
if self.filters.sales_person:
|
ret_invoice_query = base_query.where(
|
||||||
conditions += """
|
(SalesInvoice.is_return == 1) & SalesInvoice.return_against.isnotnull()
|
||||||
and exists(select 1
|
)
|
||||||
from `tabSales Team` st
|
if self.vouchers_to_ignore:
|
||||||
where st.parent = `tabSales Invoice`.name
|
ret_invoice_query = ret_invoice_query.where(
|
||||||
and st.sales_person = %(sales_person)s)
|
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":
|
if self.filters.group_by == "Sales Person":
|
||||||
sales_person_cols = """, sales.sales_person,
|
query = query.select(
|
||||||
sales.allocated_percentage * `tabSales Invoice Item`.base_net_amount / 100 as allocated_amount,
|
SalesTeam.sales_person,
|
||||||
sales.incentives
|
(SalesTeam.allocated_percentage * SalesInvoiceItem.base_net_amount / 100).as_(
|
||||||
"""
|
"allocated_amount"
|
||||||
sales_team_table = "left join `tabSales Team` sales on sales.parent = `tabSales Invoice`.name"
|
),
|
||||||
else:
|
SalesTeam.incentives,
|
||||||
sales_person_cols = ""
|
)
|
||||||
sales_team_table = ""
|
|
||||||
|
query = query.left_join(SalesTeam).on(SalesTeam.parent == SalesInvoice.name)
|
||||||
|
|
||||||
if self.filters.group_by == "Payment Term":
|
if self.filters.group_by == "Payment Term":
|
||||||
payment_term_cols = """,if(`tabSales Invoice`.is_return = 1,
|
query = query.select(
|
||||||
'{}',
|
Case()
|
||||||
coalesce(schedule.payment_term, '{}')) as payment_term,
|
.when(SalesInvoice.is_return == 1, _("Sales Return"))
|
||||||
schedule.invoice_portion,
|
.else_(Coalesce(PaymentSchedule.payment_term, _("No Terms")))
|
||||||
schedule.payment_amount """.format(_("Sales Return"), _("No Terms"))
|
.as_("payment_term"),
|
||||||
payment_term_table = """ left join `tabPayment Schedule` schedule on schedule.parent = `tabSales Invoice`.name and
|
PaymentSchedule.invoice_portion,
|
||||||
`tabSales Invoice`.is_return = 0 """
|
PaymentSchedule.payment_amount,
|
||||||
else:
|
)
|
||||||
payment_term_cols = ""
|
|
||||||
payment_term_table = ""
|
|
||||||
|
|
||||||
if self.filters.get("sales_invoice"):
|
query = query.left_join(PaymentSchedule).on(
|
||||||
conditions += " and `tabSales Invoice`.name = %(sales_invoice)s"
|
(PaymentSchedule.parent == SalesInvoice.name) & (SalesInvoice.is_return == 0)
|
||||||
|
)
|
||||||
|
|
||||||
if self.filters.get("item_code"):
|
query = query.orderby(SalesInvoice.posting_date, order=Order.desc).orderby(
|
||||||
conditions += " and `tabSales Invoice Item`.item_code = %(item_code)s"
|
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 = frappe.parse_json(self.filters.get("cost_center"))
|
||||||
self.filters.cost_center = get_cost_centers_with_children(self.filters.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"))
|
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)
|
for dim in get_accounting_dimensions(as_list=False) or []:
|
||||||
if accounting_dimensions:
|
if self.filters.get(dim.fieldname):
|
||||||
for dimension in accounting_dimensions:
|
if frappe.get_cached_value("DocType", dim.document_type, "is_tree"):
|
||||||
if self.filters.get(dimension.fieldname):
|
self.filters[dim.fieldname] = get_dimension_with_children(
|
||||||
if frappe.get_cached_value("DocType", dimension.document_type, "is_tree"):
|
dim.document_type, self.filters.get(dim.fieldname)
|
||||||
self.filters[dimension.fieldname] = get_dimension_with_children(
|
)
|
||||||
dimension.document_type, self.filters.get(dimension.fieldname)
|
query = query.where(SalesInvoiceItem[dim.fieldname].isin(self.filters[dim.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"
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.filters.get("warehouse"):
|
if self.filters.warehouse:
|
||||||
warehouse_details = frappe.db.get_value(
|
lft, rgt = frappe.db.get_value("Warehouse", self.filters.warehouse, ["lft", "rgt"])
|
||||||
"Warehouse", self.filters.get("warehouse"), ["lft", "rgt"], as_dict=1
|
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(
|
return query
|
||||||
"""
|
|
||||||
select
|
def prepare_vouchers_to_ignore(self):
|
||||||
`tabSales Invoice Item`.parenttype, `tabSales Invoice Item`.parent,
|
self.vouchers_to_ignore = tuple(row["parent"] for row in self.si_list)
|
||||||
`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,
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_delivery_notes(self):
|
def get_delivery_notes(self):
|
||||||
self.delivery_notes = frappe._dict({})
|
self.delivery_notes = frappe._dict({})
|
||||||
|
|||||||
@@ -470,7 +470,7 @@ class TestGrossProfit(IntegrationTestCase):
|
|||||||
"selling_amount": -100.0,
|
"selling_amount": -100.0,
|
||||||
"buying_amount": 0.0,
|
"buying_amount": 0.0,
|
||||||
"gross_profit": -100.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]
|
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}
|
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):
|
def test_profit_for_later_period_return(self):
|
||||||
month_start_date, month_end_date = get_first_day(nowdate()), get_last_day(nowdate())
|
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
|
# 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 = self.create_sales_invoice(qty=1, rate=100, do_not_save=True, do_not_submit=True)
|
||||||
sinv.set_posting_time = 1
|
sinv.set_posting_time = 1
|
||||||
sinv.posting_date = month_start_date
|
sinv.posting_date = sales_inv_date
|
||||||
sinv.save().submit()
|
sinv.save().submit()
|
||||||
|
|
||||||
# create credit note on next month start date
|
# create credit note on next month start date
|
||||||
cr_note = make_sales_return(sinv.name)
|
cr_note = make_sales_return(sinv.name)
|
||||||
cr_note.set_posting_time = 1
|
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()
|
cr_note.save().submit()
|
||||||
|
|
||||||
# apply filters for invoiced period
|
# apply filters for invoiced period
|
||||||
filters = frappe._dict(
|
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)
|
_, data = execute(filters=filters)
|
||||||
@@ -675,7 +678,7 @@ class TestGrossProfit(IntegrationTestCase):
|
|||||||
self.assertEqual(total.get("gross_profit_%"), 100.0)
|
self.assertEqual(total.get("gross_profit_%"), 100.0)
|
||||||
|
|
||||||
# extend filters upto returned period
|
# 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)
|
_, data = execute(filters=filters)
|
||||||
total = data[-1]
|
total = data[-1]
|
||||||
@@ -684,3 +687,63 @@ class TestGrossProfit(IntegrationTestCase):
|
|||||||
self.assertEqual(total.buying_amount, 0.0)
|
self.assertEqual(total.buying_amount, 0.0)
|
||||||
self.assertEqual(total.gross_profit, 0.0)
|
self.assertEqual(total.gross_profit, 0.0)
|
||||||
self.assertEqual(total.get("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,
|
"width": 60,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": _("Total Amount"),
|
"label": _("Taxable Amount"),
|
||||||
"fieldname": "total_amount",
|
"fieldname": "total_amount",
|
||||||
"fieldtype": "Currency",
|
"fieldtype": "Currency",
|
||||||
"width": 120,
|
"width": 120,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"label": _("Base Total"),
|
|
||||||
"fieldname": "base_total",
|
|
||||||
"fieldtype": "Currency",
|
|
||||||
"width": 120,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"label": _("Tax Amount"),
|
"label": _("Tax Amount"),
|
||||||
"fieldname": "tax_amount",
|
"fieldname": "tax_amount",
|
||||||
@@ -172,10 +166,16 @@ def get_columns(filters):
|
|||||||
"width": 120,
|
"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",
|
"fieldname": "grand_total",
|
||||||
"fieldtype": "Currency",
|
"fieldtype": "Currency",
|
||||||
"width": 120,
|
"width": 170,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": _("Reference Date"),
|
"label": _("Reference Date"),
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ def get_columns(filters):
|
|||||||
"width": 120,
|
"width": 120,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": _("Total Amount"),
|
"label": _("Total Taxable Amount"),
|
||||||
"fieldname": "total_amount",
|
"fieldname": "total_amount",
|
||||||
"fieldtype": "Float",
|
"fieldtype": "Float",
|
||||||
"width": 120,
|
"width": 120,
|
||||||
|
|||||||
@@ -513,12 +513,14 @@ frappe.ui.form.on("Asset", {
|
|||||||
},
|
},
|
||||||
|
|
||||||
is_composite_asset: function (frm) {
|
is_composite_asset: function (frm) {
|
||||||
if (frm.doc.is_composite_asset) {
|
if (frm.doc.docstatus == 0) {
|
||||||
frm.set_value("net_purchase_amount", 0);
|
if (frm.doc.is_composite_asset) {
|
||||||
} else {
|
frm.set_value("net_purchase_amount", 0);
|
||||||
frm.set_df_property("net_purchase_amount", "read_only", 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) {
|
make_sales_invoice: function (frm) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import frappe
|
import frappe
|
||||||
from frappe import _
|
from frappe import _
|
||||||
from frappe.model.document import Document
|
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
|
from erpnext.assets.doctype.asset_activity.asset_activity import add_asset_activity
|
||||||
|
|
||||||
@@ -34,6 +34,7 @@ class AssetMovement(Document):
|
|||||||
for d in self.assets:
|
for d in self.assets:
|
||||||
self.validate_asset(d)
|
self.validate_asset(d)
|
||||||
self.validate_movement(d)
|
self.validate_movement(d)
|
||||||
|
self.validate_transaction_date(d)
|
||||||
|
|
||||||
def validate_asset(self, d):
|
def validate_asset(self, d):
|
||||||
status, company = frappe.db.get_value("Asset", d.asset, ["status", "company"])
|
status, company = frappe.db.get_value("Asset", d.asset, ["status", "company"])
|
||||||
@@ -51,6 +52,18 @@ class AssetMovement(Document):
|
|||||||
else:
|
else:
|
||||||
self.validate_employee(d)
|
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):
|
def validate_location_and_employee(self, d):
|
||||||
self.validate_location(d)
|
self.validate_location(d)
|
||||||
self.validate_employee(d)
|
self.validate_employee(d)
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import unittest
|
|||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
from frappe.tests import IntegrationTestCase
|
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.setup.doctype.employee.test_employee import make_employee
|
||||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
|
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
|
||||||
|
|
||||||
@@ -147,6 +147,33 @@ class TestAssetMovement(IntegrationTestCase):
|
|||||||
movement1.cancel()
|
movement1.cancel()
|
||||||
self.assertEqual(frappe.db.get_value("Asset", asset.name, "location"), "Test Location")
|
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):
|
def create_asset_movement(**args):
|
||||||
args = frappe._dict(args)
|
args = frappe._dict(args)
|
||||||
@@ -165,9 +192,10 @@ def create_asset_movement(**args):
|
|||||||
"reference_name": args.reference_name,
|
"reference_name": args.reference_name,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
if not args.do_not_save:
|
||||||
movement.insert()
|
movement.insert(ignore_if_duplicate=True)
|
||||||
movement.submit()
|
if not args.do_not_submit:
|
||||||
|
movement.submit()
|
||||||
|
|
||||||
return movement
|
return movement
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,9 @@
|
|||||||
"engine": "InnoDB",
|
"engine": "InnoDB",
|
||||||
"field_order": [
|
"field_order": [
|
||||||
"supplier_section",
|
"supplier_section",
|
||||||
|
"company",
|
||||||
"title",
|
"title",
|
||||||
"naming_series",
|
"naming_series",
|
||||||
"supplier",
|
|
||||||
"supplier_name",
|
|
||||||
"order_confirmation_no",
|
"order_confirmation_no",
|
||||||
"order_confirmation_date",
|
"order_confirmation_date",
|
||||||
"get_items_from_open_material_requests",
|
"get_items_from_open_material_requests",
|
||||||
@@ -21,8 +20,9 @@
|
|||||||
"transaction_date",
|
"transaction_date",
|
||||||
"schedule_date",
|
"schedule_date",
|
||||||
"column_break1",
|
"column_break1",
|
||||||
"company",
|
"supplier",
|
||||||
"is_subcontracted",
|
"is_subcontracted",
|
||||||
|
"supplier_name",
|
||||||
"has_unit_price_items",
|
"has_unit_price_items",
|
||||||
"supplier_warehouse",
|
"supplier_warehouse",
|
||||||
"amended_from",
|
"amended_from",
|
||||||
@@ -1310,7 +1310,7 @@
|
|||||||
"idx": 105,
|
"idx": 105,
|
||||||
"is_submittable": 1,
|
"is_submittable": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2026-01-29 21:22:54.323838",
|
"modified": "2026-02-03 14:44:55.192192",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Buying",
|
"module": "Buying",
|
||||||
"name": "Purchase Order",
|
"name": "Purchase Order",
|
||||||
|
|||||||
@@ -135,14 +135,6 @@ frappe.ui.form.on("Supplier", {
|
|||||||
// indicators
|
// indicators
|
||||||
erpnext.utils.set_party_dashboard_indicators(frm);
|
erpnext.utils.set_party_dashboard_indicators(frm);
|
||||||
}
|
}
|
||||||
|
|
||||||
frm.set_query("supplier_group", () => {
|
|
||||||
return {
|
|
||||||
filters: {
|
|
||||||
is_group: 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
get_supplier_group_details: function (frm) {
|
get_supplier_group_details: function (frm) {
|
||||||
frappe.call({
|
frappe.call({
|
||||||
|
|||||||
@@ -167,6 +167,7 @@
|
|||||||
"in_list_view": 1,
|
"in_list_view": 1,
|
||||||
"in_standard_filter": 1,
|
"in_standard_filter": 1,
|
||||||
"label": "Supplier Group",
|
"label": "Supplier Group",
|
||||||
|
"link_filters": "[[\"Supplier Group\",\"is_group\",\"=\",0]]",
|
||||||
"oldfieldname": "supplier_type",
|
"oldfieldname": "supplier_type",
|
||||||
"oldfieldtype": "Link",
|
"oldfieldtype": "Link",
|
||||||
"options": "Supplier Group"
|
"options": "Supplier Group"
|
||||||
@@ -500,7 +501,7 @@
|
|||||||
"link_fieldname": "party"
|
"link_fieldname": "party"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"modified": "2026-01-16 15:56:31.139206",
|
"modified": "2026-02-06 12:58:01.398824",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Buying",
|
"module": "Buying",
|
||||||
"name": "Supplier",
|
"name": "Supplier",
|
||||||
|
|||||||
@@ -38,18 +38,18 @@ class EmailCampaign(Document):
|
|||||||
def set_date(self):
|
def set_date(self):
|
||||||
if getdate(self.start_date) < getdate(today()):
|
if getdate(self.start_date) < getdate(today()):
|
||||||
frappe.throw(_("Start Date cannot be before the current date"))
|
frappe.throw(_("Start Date cannot be before the current date"))
|
||||||
|
|
||||||
# set the end date as start date + max(send after days) in campaign schedule
|
# set the end date as start date + max(send after days) in campaign schedule
|
||||||
send_after_days = []
|
campaign = frappe.get_cached_doc("Campaign", self.campaign_name)
|
||||||
campaign = frappe.get_doc("Campaign", self.campaign_name)
|
send_after_days = [entry.send_after_days for entry in campaign.get("campaign_schedules")]
|
||||||
for entry in campaign.get("campaign_schedules"):
|
|
||||||
send_after_days.append(entry.send_after_days)
|
if not send_after_days:
|
||||||
try:
|
|
||||||
self.end_date = add_days(getdate(self.start_date), max(send_after_days))
|
|
||||||
except ValueError:
|
|
||||||
frappe.throw(
|
frappe.throw(
|
||||||
_("Please set up the Campaign Schedule in the Campaign {0}").format(self.campaign_name)
|
_("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):
|
def validate_lead(self):
|
||||||
lead_email_id = frappe.db.get_value("Lead", self.recipient, "email_id")
|
lead_email_id = frappe.db.get_value("Lead", self.recipient, "email_id")
|
||||||
if not lead_email_id:
|
if not lead_email_id:
|
||||||
@@ -77,58 +77,128 @@ class EmailCampaign(Document):
|
|||||||
start_date = getdate(self.start_date)
|
start_date = getdate(self.start_date)
|
||||||
end_date = getdate(self.end_date)
|
end_date = getdate(self.end_date)
|
||||||
today_date = getdate(today())
|
today_date = getdate(today())
|
||||||
|
|
||||||
if start_date > today_date:
|
if start_date > today_date:
|
||||||
self.db_set("status", "Scheduled", update_modified=False)
|
new_status = "Scheduled"
|
||||||
elif end_date >= today_date:
|
elif end_date >= today_date:
|
||||||
self.db_set("status", "In Progress", update_modified=False)
|
new_status = "In Progress"
|
||||||
elif end_date < today_date:
|
else:
|
||||||
self.db_set("status", "Completed", update_modified=False)
|
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
|
# called through hooks to send campaign mails to leads
|
||||||
def send_email_to_leads_or_contacts():
|
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_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)
|
if not email_campaigns:
|
||||||
campaign = frappe.get_cached_doc("Campaign", email_campaign.campaign_name)
|
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"):
|
for entry in campaign.get("campaign_schedules"):
|
||||||
scheduled_date = add_days(email_campaign.get("start_date"), entry.get("send_after_days"))
|
try:
|
||||||
if scheduled_date == getdate(today()):
|
scheduled_date = add_days(getdate(email_campaign.start_date), entry.get("send_after_days"))
|
||||||
send_mail(entry, email_campaign)
|
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):
|
def send_mail(entry, email_campaign):
|
||||||
recipient_list = []
|
campaign_for = email_campaign.get("email_campaign_for")
|
||||||
if email_campaign.email_campaign_for == "Email Group":
|
recipient = email_campaign.get("recipient")
|
||||||
for member in frappe.db.get_list(
|
sender_user = email_campaign.get("sender")
|
||||||
"Email Group Member", filters={"email_group": email_campaign.get("recipient")}, fields=["email"]
|
campaign_name = email_campaign.get("name")
|
||||||
):
|
|
||||||
recipient_list.append(member["email"])
|
# 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:
|
else:
|
||||||
recipient_list.append(
|
email_id = frappe.db.get_value(campaign_for, recipient, "email_id")
|
||||||
frappe.db.get_value(
|
if not email_id:
|
||||||
email_campaign.email_campaign_for, email_campaign.get("recipient"), "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"))
|
frappe.sendmail(
|
||||||
sender = frappe.db.get_value("User", email_campaign.get("sender"), "email")
|
recipients=recipient_list,
|
||||||
context = {"doc": frappe.get_doc(email_campaign.email_campaign_for, email_campaign.recipient)}
|
subject=subject,
|
||||||
# send mail and link communication to document
|
content=content,
|
||||||
comm = make(
|
sender=sender,
|
||||||
doctype="Email Campaign",
|
communication=comm["name"],
|
||||||
name=email_campaign.name,
|
queue_separately=True,
|
||||||
subject=frappe.render_template(email_template.get("subject"), context),
|
)
|
||||||
content=frappe.render_template(email_template.response_, context),
|
except Exception:
|
||||||
sender=sender,
|
frappe.log_error(title="Email Campaign Failed.")
|
||||||
bcc=recipient_list,
|
|
||||||
communication_medium="Email",
|
|
||||||
sent_or_received="Sent",
|
|
||||||
send_email=True,
|
|
||||||
email_template=email_template.name,
|
|
||||||
)
|
|
||||||
return comm
|
return comm
|
||||||
|
|
||||||
|
|
||||||
@@ -140,7 +210,12 @@ def unsubscribe_recipient(unsubscribe, method):
|
|||||||
|
|
||||||
# called through hooks to update email campaign status daily
|
# called through hooks to update email campaign status daily
|
||||||
def set_email_campaign_status():
|
def set_email_campaign_status():
|
||||||
email_campaigns = frappe.get_all("Email Campaign", filters={"status": ("!=", "Unsubscribed")})
|
email_campaigns = frappe.get_all(
|
||||||
for entry in email_campaigns:
|
"Email Campaign",
|
||||||
email_campaign = frappe.get_doc("Email Campaign", entry.name)
|
filters={"status": ("!=", "Unsubscribed")},
|
||||||
|
pluck="name",
|
||||||
|
)
|
||||||
|
|
||||||
|
for name in email_campaigns:
|
||||||
|
email_campaign = frappe.get_doc("Email Campaign", name)
|
||||||
email_campaign.update_status()
|
email_campaign.update_status()
|
||||||
|
|||||||
@@ -534,6 +534,7 @@ accounting_dimension_doctypes = [
|
|||||||
"Purchase Order Item",
|
"Purchase Order Item",
|
||||||
"Sales Order Item",
|
"Sales Order Item",
|
||||||
"Journal Entry Account",
|
"Journal Entry Account",
|
||||||
|
"Journal Entry Template Account",
|
||||||
"Material Request Item",
|
"Material Request Item",
|
||||||
"Delivery Note Item",
|
"Delivery Note Item",
|
||||||
"Purchase Receipt 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", {
|
frappe.ui.form.on("BOM Operation", {
|
||||||
finished_good(frm, cdt, cdn) {
|
finished_good(frm, cdt, cdn) {
|
||||||
let row = locals[cdt][cdn];
|
let row = locals[cdt][cdn];
|
||||||
if (row.finished_good === frm.doc.item) {
|
frappe.model.set_value(
|
||||||
frappe.model.set_value(row.doctype, row.name, "is_final_finished_good", 1);
|
row.doctype,
|
||||||
}
|
row.name,
|
||||||
|
"is_final_finished_good",
|
||||||
|
row.finished_good === frm.doc.item
|
||||||
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
bom_no(frm, cdt, cdn) {
|
bom_no(frm, cdt, cdn) {
|
||||||
|
|||||||
@@ -296,6 +296,57 @@ class BOM(WebsiteGenerator):
|
|||||||
self.set_process_loss_qty()
|
self.set_process_loss_qty()
|
||||||
self.validate_scrap_items()
|
self.validate_scrap_items()
|
||||||
self.set_default_uom()
|
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):
|
def set_default_uom(self):
|
||||||
if not self.get("items"):
|
if not self.get("items"):
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ from frappe.utils import (
|
|||||||
time_diff_in_hours,
|
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.bom.bom import add_additional_cost, get_bom_items_as_dict
|
||||||
from erpnext.manufacturing.doctype.manufacturing_settings.manufacturing_settings import (
|
from erpnext.manufacturing.doctype.manufacturing_settings.manufacturing_settings import (
|
||||||
get_mins_between_operations,
|
get_mins_between_operations,
|
||||||
@@ -167,6 +171,27 @@ class JobCard(Document):
|
|||||||
self.validate_work_order()
|
self.validate_work_order()
|
||||||
self.set_employees()
|
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):
|
def on_update(self):
|
||||||
self.validate_job_card_qty()
|
self.validate_job_card_qty()
|
||||||
|
|
||||||
@@ -732,6 +757,7 @@ class JobCard(Document):
|
|||||||
self.set_process_loss()
|
self.set_process_loss()
|
||||||
|
|
||||||
def on_submit(self):
|
def on_submit(self):
|
||||||
|
self.validate_inspection()
|
||||||
self.validate_transfer_qty()
|
self.validate_transfer_qty()
|
||||||
self.validate_job_card()
|
self.validate_job_card()
|
||||||
self.update_work_order()
|
self.update_work_order()
|
||||||
@@ -741,6 +767,66 @@ class JobCard(Document):
|
|||||||
self.update_work_order()
|
self.update_work_order()
|
||||||
self.set_transferred_qty()
|
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):
|
def validate_transfer_qty(self):
|
||||||
if (
|
if (
|
||||||
not self.finished_good
|
not self.finished_good
|
||||||
@@ -1251,10 +1337,26 @@ class JobCard(Document):
|
|||||||
frappe.db.set_value("Workstation", self.workstation, "status", status)
|
frappe.db.set_value("Workstation", self.workstation, "status", status)
|
||||||
|
|
||||||
def add_time_logs(self, **kwargs):
|
def add_time_logs(self, **kwargs):
|
||||||
row = None
|
|
||||||
kwargs = frappe._dict(kwargs)
|
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
|
update_status = False
|
||||||
|
|
||||||
for employee in kwargs.employees:
|
for employee in kwargs.employees:
|
||||||
kwargs.employee = employee.get("employee")
|
kwargs.employee = employee.get("employee")
|
||||||
if kwargs.from_time and not kwargs.to_time:
|
if kwargs.from_time and not kwargs.to_time:
|
||||||
@@ -1290,9 +1392,6 @@ class JobCard(Document):
|
|||||||
|
|
||||||
self.set_status(update_status=update_status)
|
self.set_status(update_status=update_status)
|
||||||
|
|
||||||
self.validate_time_logs(save=True)
|
|
||||||
self.save()
|
|
||||||
|
|
||||||
def update_workstation_status(self):
|
def update_workstation_status(self):
|
||||||
status_map = {
|
status_map = {
|
||||||
"Open": "Off",
|
"Open": "Off",
|
||||||
@@ -1341,6 +1440,9 @@ class JobCard(Document):
|
|||||||
employees=self.employee,
|
employees=self.employee,
|
||||||
sub_operation=kwargs.get("sub_operation"),
|
sub_operation=kwargs.get("sub_operation"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if self.docstatus == 1:
|
||||||
|
self.update_work_order()
|
||||||
else:
|
else:
|
||||||
self.add_time_logs(completed_qty=kwargs.qty, employees=self.employee)
|
self.add_time_logs(completed_qty=kwargs.qty, employees=self.employee)
|
||||||
self.save()
|
self.save()
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ from erpnext.manufacturing.doctype.job_card.job_card import (
|
|||||||
make_stock_entry as make_stock_entry_from_jc,
|
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.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.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.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||||
from erpnext.tests.utils import ERPNextTestSuite
|
from erpnext.tests.utils import ERPNextTestSuite
|
||||||
|
|
||||||
@@ -73,6 +74,68 @@ class TestJobCard(ERPNextTestSuite):
|
|||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
frappe.db.rollback()
|
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):
|
def test_job_card_operations(self):
|
||||||
job_cards = frappe.get_all(
|
job_cards = frappe.get_all(
|
||||||
"Job Card", filters={"work_order": self.work_order.name}, fields=["operation_id", "name"]
|
"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()
|
work_order.submit()
|
||||||
|
|
||||||
return work_order
|
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)
|
item_details = get_item_details(data.item_code, throw=False)
|
||||||
if self.combine_items:
|
if self.combine_items:
|
||||||
bom_no = item_details.bom_no
|
bom_no = item_details.get("bom_no")
|
||||||
if data.get("bom_no"):
|
if data.get("bom_no"):
|
||||||
bom_no = 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 "Material Consumption is check in Manufacturing Settings, allow Material Consumption
|
||||||
if (frm.doc.__onload && frm.doc.__onload.material_consumption == 1) {
|
if (frm.doc.__onload && frm.doc.__onload.material_consumption == 1) {
|
||||||
if (flt(doc.material_transferred_for_manufacturing) > 0 || frm.doc.skip_transfer) {
|
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"):
|
if self.is_new() and frappe.db.get_single_value("Stock Settings", "auto_reserve_stock"):
|
||||||
self.reserve_stock = 1
|
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):
|
def validate_operations_sequence(self):
|
||||||
if all([not op.sequence_id for op in self.operations]):
|
if all([not op.sequence_id for op in self.operations]):
|
||||||
for op in self.operations:
|
for op in self.operations:
|
||||||
@@ -1599,6 +1609,7 @@ class WorkOrder(Document):
|
|||||||
"item_code": row.item_code,
|
"item_code": row.item_code,
|
||||||
"voucher_detail_no": row.name,
|
"voucher_detail_no": row.name,
|
||||||
"warehouse": row.source_warehouse,
|
"warehouse": row.source_warehouse,
|
||||||
|
"status": ("not in", ["Closed", "Cancelled", "Completed"]),
|
||||||
},
|
},
|
||||||
pluck="name",
|
pluck="name",
|
||||||
):
|
):
|
||||||
@@ -1807,24 +1818,10 @@ class WorkOrder(Document):
|
|||||||
elif stock_entry.job_card:
|
elif stock_entry.job_card:
|
||||||
# Reserve the final product for the job card.
|
# Reserve the final product for the job card.
|
||||||
finished_good = frappe.db.get_value("Job Card", stock_entry.job_card, "finished_good")
|
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:
|
item_details = self.get_items_to_reserve_for_job_card(stock_entry, finished_good)
|
||||||
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
|
|
||||||
else:
|
else:
|
||||||
# Reserve the final product for the sales order.
|
# Reserve the final product for the sales order.
|
||||||
item_details = self.get_so_details()
|
item_details = self.get_so_details()
|
||||||
@@ -1878,6 +1875,53 @@ class WorkOrder(Document):
|
|||||||
|
|
||||||
return items
|
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):
|
def get_wo_details(self):
|
||||||
doctype = frappe.qb.DocType("Work Order")
|
doctype = frappe.qb.DocType("Work Order")
|
||||||
child_doctype = frappe.qb.DocType("Work Order Item")
|
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
|
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):
|
def execute(filters=None):
|
||||||
columns = get_columns(filters)
|
columns = get_columns(filters)
|
||||||
@@ -16,119 +18,98 @@ def execute(filters=None):
|
|||||||
|
|
||||||
|
|
||||||
def get_columns(filters):
|
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)
|
ranges = get_period_date_ranges(filters)
|
||||||
|
|
||||||
for _dummy, end_date in ranges:
|
for _dummy, end_date in ranges:
|
||||||
period = get_period(end_date, filters)
|
period = get_period(end_date, filters)
|
||||||
|
|
||||||
columns.append({"label": _(period), "fieldname": scrub(period), "fieldtype": "Float", "width": 120})
|
columns.append({"label": _(period), "fieldname": scrub(period), "fieldtype": "Float", "width": 120})
|
||||||
|
|
||||||
return columns
|
return columns
|
||||||
|
|
||||||
|
|
||||||
def get_periodic_data(filters, entry):
|
def get_work_orders(filters):
|
||||||
periodic_data = {
|
from_date = filters.get("from_date")
|
||||||
"Not Started": {},
|
to_date = filters.get("to_date")
|
||||||
"Overdue": {},
|
|
||||||
"Pending": {},
|
|
||||||
"Completed": {},
|
|
||||||
"Closed": {},
|
|
||||||
"Stopped": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
ranges = get_period_date_ranges(filters)
|
WorkOrder = frappe.qb.DocType("Work Order")
|
||||||
|
|
||||||
for from_date, end_date in ranges:
|
return (
|
||||||
period = get_period(end_date, filters)
|
frappe.qb.from_(WorkOrder)
|
||||||
for d in entry:
|
.select(WorkOrder.creation, WorkOrder.actual_end_date, WorkOrder.planned_end_date, WorkOrder.status)
|
||||||
if getdate(from_date) <= getdate(d.creation) <= getdate(end_date) and d.status not in [
|
.where(
|
||||||
"Draft",
|
(WorkOrder.docstatus == 1)
|
||||||
"Submitted",
|
& (WorkOrder.company == filters.get("company"))
|
||||||
"Completed",
|
& (
|
||||||
"Cancelled",
|
(WorkOrder.creation.between(from_date, to_date))
|
||||||
]:
|
| (WorkOrder.actual_end_date.between(from_date, to_date))
|
||||||
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):
|
.run(as_dict=True)
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def get_data(filters, columns):
|
def get_data(filters, columns):
|
||||||
data = []
|
ranges = build_ranges(filters)
|
||||||
entry = frappe.get_all(
|
period_labels = [scrub(pd) for _fd, _td, pd in ranges]
|
||||||
"Work Order",
|
periodic_data = {status: {pd: 0 for pd in period_labels} for status in WORK_ORDER_STATUS_LIST}
|
||||||
fields=[
|
entries = get_work_orders(filters)
|
||||||
"creation",
|
|
||||||
"actual_end_date",
|
|
||||||
"planned_end_date",
|
|
||||||
"status",
|
|
||||||
],
|
|
||||||
filters={"docstatus": 1, "company": filters["company"]},
|
|
||||||
)
|
|
||||||
|
|
||||||
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"]
|
if period := scrub(get_period_for_date(getdate(d.actual_end_date), ranges)):
|
||||||
chart_data = get_chart_data(periodic_data, columns)
|
periodic_data["Completed"][period] += 1
|
||||||
ranges = get_period_date_ranges(filters)
|
continue
|
||||||
|
|
||||||
for label in labels:
|
creation_date = getdate(d.creation)
|
||||||
work = {}
|
period = scrub(get_period_for_date(creation_date, ranges))
|
||||||
work["Status"] = _(label)
|
if not period:
|
||||||
for _dummy, end_date in ranges:
|
continue
|
||||||
period = get_period(end_date, filters)
|
|
||||||
if periodic_data.get(label).get(period):
|
if d.status in ("Not Started", "Closed", "Stopped"):
|
||||||
work[scrub(period)] = periodic_data.get(label).get(period)
|
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:
|
else:
|
||||||
work[scrub(period)] = 0.0
|
periodic_data["Pending"][period] += 1
|
||||||
data.append(work)
|
|
||||||
|
|
||||||
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):
|
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 = []
|
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:
|
return {"data": {"labels": period_labels, "datasets": datasets}, "type": "line"}
|
||||||
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
|
|
||||||
|
|||||||
@@ -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.migrate_tax_withholding_data
|
||||||
erpnext.patches.v16_0.update_corrected_cancelled_status
|
erpnext.patches.v16_0.update_corrected_cancelled_status
|
||||||
erpnext.patches.v16_0.fix_barcode_typo
|
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
|
erpnext.patches.v16_0.set_post_change_gl_entries_on_pos_settings
|
||||||
execute:frappe.delete_doc_if_exists("Workspace Sidebar", "Opening & Closing")
|
execute:frappe.delete_doc_if_exists("Workspace Sidebar", "Opening & Closing")
|
||||||
erpnext.patches.v15_0.create_accounting_dimensions_in_advance_taxes_and_charges
|
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.set_ordered_qty_in_quotation_item
|
||||||
erpnext.patches.v16_0.migrate_transaction_deletion_task_flags_to_status # 2
|
erpnext.patches.v16_0.migrate_transaction_deletion_task_flags_to_status # 2
|
||||||
erpnext.patches.v16_0.update_company_custom_field_in_bin
|
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) {
|
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) {
|
if (opts.source_doctype) {
|
||||||
|
|||||||
@@ -110,12 +110,6 @@ frappe.ui.form.ContactAddressQuickEntryForm = class ContactAddressQuickEntryForm
|
|||||||
options: "Country",
|
options: "Country",
|
||||||
mandatory_depends_on: "eval:doc.city || doc.address_line1",
|
mandatory_depends_on: "eval:doc.city || doc.address_line1",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: __("Customer POS Id"),
|
|
||||||
fieldname: "customer_pos_id",
|
|
||||||
fieldtype: "Data",
|
|
||||||
hidden: 1,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return variant_fields;
|
return variant_fields;
|
||||||
|
|||||||
@@ -621,6 +621,9 @@ def handle_mandatory_error(e, customer, lead_name):
|
|||||||
def get_ordered_items(quotation: str):
|
def get_ordered_items(quotation: str):
|
||||||
return frappe._dict(
|
return frappe._dict(
|
||||||
frappe.get_all(
|
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": [
|
"field_order": [
|
||||||
"customer_section",
|
"customer_section",
|
||||||
"column_break0",
|
"column_break0",
|
||||||
|
"company",
|
||||||
"naming_series",
|
"naming_series",
|
||||||
"customer",
|
|
||||||
"customer_name",
|
|
||||||
"tax_id",
|
|
||||||
"order_type",
|
"order_type",
|
||||||
"column_break_7",
|
"column_break_7",
|
||||||
"transaction_date",
|
"transaction_date",
|
||||||
"delivery_date",
|
"delivery_date",
|
||||||
"column_break1",
|
"column_break1",
|
||||||
|
"customer",
|
||||||
|
"customer_name",
|
||||||
|
"tax_id",
|
||||||
"po_no",
|
"po_no",
|
||||||
"po_date",
|
"po_date",
|
||||||
"company",
|
|
||||||
"skip_delivery_note",
|
"skip_delivery_note",
|
||||||
"has_unit_price_items",
|
"has_unit_price_items",
|
||||||
"is_subcontracted",
|
"is_subcontracted",
|
||||||
@@ -1458,9 +1458,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": "0",
|
"default": "0",
|
||||||
|
"depends_on": "eval:doc.order_type == 'Maintenance';",
|
||||||
"fieldname": "skip_delivery_note",
|
"fieldname": "skip_delivery_note",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"hidden": 1,
|
|
||||||
"hide_days": 1,
|
"hide_days": 1,
|
||||||
"hide_seconds": 1,
|
"hide_seconds": 1,
|
||||||
"label": "Skip Delivery Note",
|
"label": "Skip Delivery Note",
|
||||||
@@ -1713,7 +1713,7 @@
|
|||||||
"idx": 105,
|
"idx": 105,
|
||||||
"is_submittable": 1,
|
"is_submittable": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2026-01-29 21:23:48.362401",
|
"modified": "2026-02-06 11:06:16.092658",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Selling",
|
"module": "Selling",
|
||||||
"name": "Sales Order",
|
"name": "Sales Order",
|
||||||
|
|||||||
@@ -57,6 +57,28 @@ class TestSalesOrder(AccountsTestMixin, IntegrationTestCase):
|
|||||||
frappe.db.rollback()
|
frappe.db.rollback()
|
||||||
frappe.set_user("Administrator")
|
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})
|
@IntegrationTestCase.change_settings("Selling Settings", {"allow_negative_rates_for_items": 1})
|
||||||
def test_sales_order_with_negative_rate(self):
|
def test_sales_order_with_negative_rate(self):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -162,8 +162,6 @@ class EmailDigest(Document):
|
|||||||
context.purchase_order_list,
|
context.purchase_order_list,
|
||||||
context.purchase_orders_items_overdue_list,
|
context.purchase_orders_items_overdue_list,
|
||||||
) = self.get_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:
|
if not context:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -50,8 +50,17 @@ class SalesPartner(WebsiteGenerator):
|
|||||||
if not self.route:
|
if not self.route:
|
||||||
self.route = "partners/" + self.scrub(self.partner_name)
|
self.route = "partners/" + self.scrub(self.partner_name)
|
||||||
super().validate()
|
super().validate()
|
||||||
if self.partner_website and not self.partner_website.startswith("http"):
|
if self.partner_website:
|
||||||
self.partner_website = "http://" + 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):
|
def get_context(self, context):
|
||||||
address_names = frappe.db.get_all(
|
address_names = frappe.db.get_all(
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import frappe
|
import frappe
|
||||||
from frappe.model.db_query import DatabaseQuery
|
from frappe.desk.reportview import build_match_conditions
|
||||||
from frappe.utils import cint, flt
|
from frappe.utils import cint, flt
|
||||||
|
|
||||||
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
|
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
|
||||||
@@ -30,7 +30,7 @@ def get_data(
|
|||||||
filters.append(["item_code", "in", items])
|
filters.append(["item_code", "in", items])
|
||||||
try:
|
try:
|
||||||
# check if user has any restrictions based on user permissions on warehouse
|
# 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")]])
|
filters.append(["warehouse", "in", [w.name for w in frappe.get_list("Warehouse")]])
|
||||||
except frappe.PermissionError:
|
except frappe.PermissionError:
|
||||||
# user does not have access on warehouse
|
# user does not have access on warehouse
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import frappe
|
import frappe
|
||||||
from frappe.model.db_query import DatabaseQuery
|
from frappe.desk.reportview import build_match_conditions
|
||||||
from frappe.utils import flt, nowdate
|
from frappe.utils import flt, nowdate
|
||||||
|
|
||||||
from erpnext.stock.utils import get_stock_balance
|
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):
|
def get_warehouse_filter_based_on_permissions(filters):
|
||||||
try:
|
try:
|
||||||
# check if user has any restrictions based on user permissions on warehouse
|
# 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")]])
|
filters.append(["warehouse", "in", [w.name for w in frappe.get_list("Warehouse")]])
|
||||||
return False, filters
|
return False, filters
|
||||||
except frappe.PermissionError:
|
except frappe.PermissionError:
|
||||||
|
|||||||
@@ -123,6 +123,80 @@ class TestBatch(IntegrationTestCase):
|
|||||||
for d in batches:
|
for d in batches:
|
||||||
self.assertEqual(d.qty, batchwise_qty[(d.batch_no, d.warehouse)])
|
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):
|
def test_stock_entry_incoming(self):
|
||||||
"""Test batch creation via Stock Entry (Work Order)"""
|
"""Test batch creation via Stock Entry (Work Order)"""
|
||||||
|
|
||||||
@@ -610,6 +684,10 @@ def create_price_list_for_batch(item_code, batch, rate):
|
|||||||
).insert()
|
).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):
|
def make_new_batch(**args):
|
||||||
args = frappe._dict(args)
|
args = frappe._dict(args)
|
||||||
|
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ class InventoryDimension(Document):
|
|||||||
self.source_fieldname = scrub(self.dimension_name)
|
self.source_fieldname = scrub(self.dimension_name)
|
||||||
|
|
||||||
if not self.target_fieldname:
|
if not self.target_fieldname:
|
||||||
self.target_fieldname = scrub(self.reference_document)
|
self.target_fieldname = scrub(self.dimension_name)
|
||||||
|
|
||||||
def on_update(self):
|
def on_update(self):
|
||||||
self.add_custom_fields()
|
self.add_custom_fields()
|
||||||
|
|||||||
@@ -117,12 +117,12 @@ class TestInventoryDimension(IntegrationTestCase):
|
|||||||
inward.load_from_db()
|
inward.load_from_db()
|
||||||
|
|
||||||
sle_data = frappe.db.get_value(
|
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(inward.items[0].to_shelf, "Shelf 1")
|
||||||
self.assertEqual(sle_data.warehouse, warehouse)
|
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(
|
outward = make_stock_entry(
|
||||||
item_code=item_code,
|
item_code=item_code,
|
||||||
|
|||||||
@@ -30,7 +30,10 @@ frappe.ui.form.on("Material Request", {
|
|||||||
|
|
||||||
frm.set_query("from_warehouse", "items", function (doc) {
|
frm.set_query("from_warehouse", "items", function (doc) {
|
||||||
return {
|
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) {
|
frm.set_query("warehouse", "items", function (doc) {
|
||||||
return {
|
return {
|
||||||
filters: { company: doc.company },
|
filters: {
|
||||||
|
company: doc.company,
|
||||||
|
is_group: 0,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
frm.set_query("set_warehouse", function (doc) {
|
frm.set_query("set_warehouse", function (doc) {
|
||||||
return {
|
return {
|
||||||
filters: { company: doc.company },
|
filters: {
|
||||||
|
company: doc.company,
|
||||||
|
is_group: 0,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
frm.set_query("set_from_warehouse", function (doc) {
|
frm.set_query("set_from_warehouse", function (doc) {
|
||||||
return {
|
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.purpose = source.material_request_type
|
||||||
target.from_warehouse = source.set_from_warehouse
|
target.from_warehouse = source.set_from_warehouse
|
||||||
target.to_warehouse = source.set_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:
|
if source.job_card:
|
||||||
target.purpose = "Material Transfer for Manufacture"
|
target.purpose = "Material Transfer for Manufacture"
|
||||||
|
|||||||
@@ -1017,15 +1017,27 @@ class TestMaterialRequest(IntegrationTestCase):
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from erpnext.stock.doctype.pick_list.pick_list import create_stock_entry
|
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 = create_pick_list(mr.name)
|
||||||
pl.save()
|
pl.save()
|
||||||
pl.locations[0].qty = 5
|
pl.locations[0].qty = 5
|
||||||
pl.locations[0].stock_qty = 5
|
pl.locations[0].stock_qty = 5
|
||||||
pl.submit()
|
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_data = create_stock_entry(json.dumps(pl.as_dict()))
|
||||||
se = frappe.get_doc(se_data)
|
se = frappe.get_doc(se_data)
|
||||||
@@ -1044,6 +1056,15 @@ class TestMaterialRequest(IntegrationTestCase):
|
|||||||
|
|
||||||
def test_mr_pick_list_qty_validation(self):
|
def test_mr_pick_list_qty_validation(self):
|
||||||
"""Test for checking pick list qty validation from Material Request"""
|
"""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")
|
mr = make_material_request(material_request_type="Material Transfer")
|
||||||
pl = create_pick_list(mr.name)
|
pl = create_pick_list(mr.name)
|
||||||
@@ -1104,6 +1125,19 @@ class TestMaterialRequest(IntegrationTestCase):
|
|||||||
|
|
||||||
self.assertRaises(frappe.ValidationError, end_transit_2.submit)
|
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):
|
def get_in_transit_warehouse(company):
|
||||||
if not frappe.db.exists("Warehouse Type", "Transit"):
|
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):
|
def throw_negative_batch(self, batch_no, available_qty, precision):
|
||||||
from erpnext.stock.stock_ledger import NegativeStockError
|
from erpnext.stock.stock_ledger import NegativeStockError
|
||||||
|
|
||||||
|
if frappe.db.get_single_value("Stock Settings", "allow_negative_stock_for_batch"):
|
||||||
|
return
|
||||||
|
|
||||||
frappe.throw(
|
frappe.throw(
|
||||||
_(
|
_(
|
||||||
"""
|
"""
|
||||||
@@ -2690,7 +2693,10 @@ def get_auto_batch_nos(kwargs):
|
|||||||
|
|
||||||
available_batches = get_available_batches(kwargs)
|
available_batches = get_available_batches(kwargs)
|
||||||
stock_ledgers_batches = get_stock_ledgers_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()
|
sre_reserved_batches = frappe._dict()
|
||||||
if not kwargs.ignore_reserved_stock:
|
if not kwargs.ignore_reserved_stock:
|
||||||
|
|||||||
@@ -449,12 +449,12 @@ class StockEntry(StockController, SubcontractingInwardController):
|
|||||||
def set_job_card_data(self):
|
def set_job_card_data(self):
|
||||||
if self.job_card and not self.work_order:
|
if self.job_card and not self.work_order:
|
||||||
data = frappe.db.get_value(
|
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.fg_completed_qty = data.for_quantity
|
||||||
self.work_order = data.work_order
|
self.work_order = data.work_order
|
||||||
self.from_bom = 1
|
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):
|
def validate_job_card_fg_item(self):
|
||||||
if not self.job_card:
|
if not self.job_card:
|
||||||
@@ -971,6 +971,7 @@ class StockEntry(StockController, SubcontractingInwardController):
|
|||||||
|
|
||||||
precision = frappe.get_precision("Stock Entry Detail", "qty")
|
precision = frappe.get_precision("Stock Entry Detail", "qty")
|
||||||
for item_code, details in raw_materials.items():
|
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 matched_item := self.get_matched_items(item_code):
|
||||||
if flt(details.get("qty"), precision) != flt(matched_item.qty, precision):
|
if flt(details.get("qty"), precision) != flt(matched_item.qty, precision):
|
||||||
frappe.throw(
|
frappe.throw(
|
||||||
@@ -2005,6 +2006,7 @@ class StockEntry(StockController, SubcontractingInwardController):
|
|||||||
else:
|
else:
|
||||||
job_doc.set_consumed_qty_in_job_card_item(self)
|
job_doc.set_consumed_qty_in_job_card_item(self)
|
||||||
job_doc.set_manufactured_qty()
|
job_doc.set_manufactured_qty()
|
||||||
|
job_doc.update_work_order()
|
||||||
|
|
||||||
if self.work_order:
|
if self.work_order:
|
||||||
pro_doc = frappe.get_doc("Work Order", self.work_order)
|
pro_doc = frappe.get_doc("Work Order", self.work_order)
|
||||||
|
|||||||
@@ -2,12 +2,16 @@
|
|||||||
# For license information, please see license.txt
|
# For license information, please see license.txt
|
||||||
|
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
from frappe import _
|
from frappe import _
|
||||||
from frappe.model.document import Document
|
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.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):
|
class StockEntryType(Document):
|
||||||
@@ -77,7 +81,6 @@ class ManufactureEntry:
|
|||||||
self.stock_entry.fg_completed_qty = self.for_quantity
|
self.stock_entry.fg_completed_qty = self.for_quantity
|
||||||
self.stock_entry.project = self.project
|
self.stock_entry.project = self.project
|
||||||
self.stock_entry.job_card = self.job_card
|
self.stock_entry.job_card = self.job_card
|
||||||
self.stock_entry.work_order = self.work_order
|
|
||||||
self.stock_entry.set_stock_entry_type()
|
self.stock_entry.set_stock_entry_type()
|
||||||
|
|
||||||
self.prepare_source_warehouse()
|
self.prepare_source_warehouse()
|
||||||
@@ -107,11 +110,17 @@ class ManufactureEntry:
|
|||||||
"Manufacturing Settings", "backflush_raw_materials_based_on"
|
"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():
|
for item_code, _dict in item_dict.items():
|
||||||
_dict.from_warehouse = self.source_wh.get(item_code) or self.wip_warehouse
|
_dict.from_warehouse = self.source_wh.get(item_code) or self.wip_warehouse
|
||||||
_dict.to_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)
|
calculated_qty = flt(_dict.transferred_qty) - flt(_dict.consumed_qty)
|
||||||
if calculated_qty < 0:
|
if calculated_qty < 0:
|
||||||
frappe.throw(
|
frappe.throw(
|
||||||
@@ -119,9 +128,131 @@ class ManufactureEntry:
|
|||||||
)
|
)
|
||||||
|
|
||||||
_dict.qty = calculated_qty
|
_dict.qty = calculated_qty
|
||||||
|
self.update_available_serial_batches(_dict, available_serial_batches)
|
||||||
|
|
||||||
self.stock_entry.add_to_stock_entry_detail(item_dict)
|
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):
|
def get_items_from_job_card(self):
|
||||||
item_dict = {}
|
item_dict = {}
|
||||||
items = frappe.get_all(
|
items = frappe.get_all(
|
||||||
|
|||||||
@@ -523,6 +523,9 @@ class StockReconciliation(StockController):
|
|||||||
if abs(difference_amount) > 0:
|
if abs(difference_amount) > 0:
|
||||||
return True
|
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 (
|
if (
|
||||||
(item.qty is None or item.qty == item_dict.get("qty"))
|
(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"))
|
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)
|
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):
|
def create_batch_item_with_batch(item_name, batch_id):
|
||||||
batch_item_doc = create_item(item_name, is_stock_item=1)
|
batch_item_doc = create_item(item_name, is_stock_item=1)
|
||||||
|
|||||||
@@ -47,6 +47,7 @@
|
|||||||
"disable_serial_no_and_batch_selector",
|
"disable_serial_no_and_batch_selector",
|
||||||
"use_serial_batch_fields",
|
"use_serial_batch_fields",
|
||||||
"do_not_update_serial_batch_on_creation_of_auto_bundle",
|
"do_not_update_serial_batch_on_creation_of_auto_bundle",
|
||||||
|
"allow_negative_stock_for_batch",
|
||||||
"serial_and_batch_bundle_section",
|
"serial_and_batch_bundle_section",
|
||||||
"set_serial_and_batch_bundle_naming_based_on_naming_series",
|
"set_serial_and_batch_bundle_naming_based_on_naming_series",
|
||||||
"section_break_gnhq",
|
"section_break_gnhq",
|
||||||
@@ -546,6 +547,13 @@
|
|||||||
"fieldname": "validate_material_transfer_warehouses",
|
"fieldname": "validate_material_transfer_warehouses",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Validate Material Transfer Warehouses"
|
"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,
|
"hide_toolbar": 1,
|
||||||
@@ -554,7 +562,7 @@
|
|||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"issingle": 1,
|
"issingle": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2026-01-02 18:19:59.034785",
|
"modified": "2026-02-09 15:01:12.466175",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Stock",
|
"module": "Stock",
|
||||||
"name": "Stock Settings",
|
"name": "Stock Settings",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class StockSettings(Document):
|
|||||||
allow_from_pr: DF.Check
|
allow_from_pr: DF.Check
|
||||||
allow_internal_transfer_at_arms_length_price: DF.Check
|
allow_internal_transfer_at_arms_length_price: DF.Check
|
||||||
allow_negative_stock: DF.Check
|
allow_negative_stock: DF.Check
|
||||||
|
allow_negative_stock_for_batch: DF.Check
|
||||||
allow_partial_reservation: DF.Check
|
allow_partial_reservation: DF.Check
|
||||||
allow_to_edit_stock_uom_qty_for_purchase: DF.Check
|
allow_to_edit_stock_uom_qty_for_purchase: DF.Check
|
||||||
allow_to_edit_stock_uom_qty_for_sales: DF.Check
|
allow_to_edit_stock_uom_qty_for_sales: DF.Check
|
||||||
|
|||||||
@@ -314,7 +314,7 @@
|
|||||||
"type": "Link"
|
"type": "Link"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"modified": "2026-01-26 21:23:15.665712",
|
"modified": "2026-01-27 21:23:15.665712",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Accounts",
|
"module": "Accounts",
|
||||||
"name": "Invoicing",
|
"name": "Invoicing",
|
||||||
|
|||||||
Reference in New Issue
Block a user