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

chore: release v15
This commit is contained in:
ruthra kumar
2024-11-20 14:09:56 +05:30
committed by GitHub
61 changed files with 1781 additions and 209 deletions

View File

@@ -121,7 +121,7 @@
"label": "Account Type",
"oldfieldname": "account_type",
"oldfieldtype": "Select",
"options": "\nAccumulated Depreciation\nAsset Received But Not Billed\nBank\nCash\nChargeable\nCapital Work in Progress\nCost of Goods Sold\nCurrent Asset\nCurrent Liability\nDepreciation\nDirect Expense\nDirect Income\nEquity\nExpense Account\nExpenses Included In Asset Valuation\nExpenses Included In Valuation\nFixed Asset\nIncome Account\nIndirect Expense\nIndirect Income\nLiability\nPayable\nReceivable\nRound Off\nStock\nStock Adjustment\nStock Received But Not Billed\nService Received But Not Billed\nTax\nTemporary",
"options": "\nAccumulated Depreciation\nAsset Received But Not Billed\nBank\nCash\nChargeable\nCapital Work in Progress\nCost of Goods Sold\nCurrent Asset\nCurrent Liability\nDepreciation\nDirect Expense\nDirect Income\nEquity\nExpense Account\nExpenses Included In Asset Valuation\nExpenses Included In Valuation\nFixed Asset\nIncome Account\nIndirect Expense\nIndirect Income\nLiability\nPayable\nReceivable\nRound Off\nRound Off for Opening\nStock\nStock Adjustment\nStock Received But Not Billed\nService Received But Not Billed\nTax\nTemporary",
"search_index": 1
},
{
@@ -191,7 +191,7 @@
"idx": 1,
"is_tree": 1,
"links": [],
"modified": "2024-06-27 16:23:04.444354",
"modified": "2024-08-19 15:19:11.095045",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Account",

View File

@@ -60,6 +60,7 @@ class Account(NestedSet):
"Payable",
"Receivable",
"Round Off",
"Round Off for Opening",
"Stock",
"Stock Adjustment",
"Stock Received But Not Billed",

View File

@@ -74,6 +74,21 @@ class ExchangeRateRevaluation(Document):
if not (self.company and self.posting_date):
frappe.throw(_("Please select Company and Posting Date to getting entries"))
def before_submit(self):
self.remove_accounts_without_gain_loss()
def remove_accounts_without_gain_loss(self):
self.accounts = [account for account in self.accounts if account.gain_loss]
if not self.accounts:
frappe.throw(_("At least one account with exchange gain or loss is required"))
frappe.msgprint(
_("Removing rows without exchange gain or loss"),
alert=True,
indicator="yellow",
)
def on_cancel(self):
self.ignore_linked_doctypes = "GL Entry"
@@ -248,23 +263,23 @@ class ExchangeRateRevaluation(Document):
new_exchange_rate = get_exchange_rate(d.account_currency, company_currency, posting_date)
new_balance_in_base_currency = flt(d.balance_in_account_currency * new_exchange_rate)
gain_loss = flt(new_balance_in_base_currency, precision) - flt(d.balance, precision)
if gain_loss:
accounts.append(
{
"account": d.account,
"party_type": d.party_type,
"party": d.party,
"account_currency": d.account_currency,
"balance_in_base_currency": d.balance,
"balance_in_account_currency": d.balance_in_account_currency,
"zero_balance": d.zero_balance,
"current_exchange_rate": current_exchange_rate,
"new_exchange_rate": new_exchange_rate,
"new_balance_in_base_currency": new_balance_in_base_currency,
"new_balance_in_account_currency": d.balance_in_account_currency,
"gain_loss": gain_loss,
}
)
accounts.append(
{
"account": d.account,
"party_type": d.party_type,
"party": d.party,
"account_currency": d.account_currency,
"balance_in_base_currency": d.balance,
"balance_in_account_currency": d.balance_in_account_currency,
"zero_balance": d.zero_balance,
"current_exchange_rate": current_exchange_rate,
"new_exchange_rate": new_exchange_rate,
"new_balance_in_base_currency": new_balance_in_base_currency,
"new_balance_in_account_currency": d.balance_in_account_currency,
"gain_loss": gain_loss,
}
)
# Handle Accounts with '0' balance in Account/Base Currency
for d in [x for x in account_details if x.zero_balance]:
@@ -288,23 +303,22 @@ class ExchangeRateRevaluation(Document):
current_exchange_rate * d.balance_in_account_currency
)
if gain_loss:
accounts.append(
{
"account": d.account,
"party_type": d.party_type,
"party": d.party,
"account_currency": d.account_currency,
"balance_in_base_currency": d.balance,
"balance_in_account_currency": d.balance_in_account_currency,
"zero_balance": d.zero_balance,
"current_exchange_rate": current_exchange_rate,
"new_exchange_rate": new_exchange_rate,
"new_balance_in_base_currency": new_balance_in_base_currency,
"new_balance_in_account_currency": new_balance_in_account_currency,
"gain_loss": gain_loss,
}
)
accounts.append(
{
"account": d.account,
"party_type": d.party_type,
"party": d.party,
"account_currency": d.account_currency,
"balance_in_base_currency": d.balance,
"balance_in_account_currency": d.balance_in_account_currency,
"zero_balance": d.zero_balance,
"current_exchange_rate": current_exchange_rate,
"new_exchange_rate": new_exchange_rate,
"new_balance_in_base_currency": new_balance_in_base_currency,
"new_balance_in_account_currency": new_balance_in_account_currency,
"gain_loss": gain_loss,
}
)
return accounts

View File

@@ -72,10 +72,10 @@
},
{
"default": "0",
"description": "Less than 12 months.",
"description": "More/Less than 12 months.",
"fieldname": "is_short_year",
"fieldtype": "Check",
"label": "Is Short Year",
"label": "Is Short/Long Year",
"set_only_once": 1
}
],

View File

@@ -26,6 +26,10 @@ frappe.ui.form.on("Payment Entry", {
}
erpnext.accounts.dimensions.setup_dimension_filters(frm, frm.doctype);
if (frm.is_new()) {
set_default_party_type(frm);
}
},
setup: function (frm) {
@@ -403,6 +407,8 @@ frappe.ui.form.on("Payment Entry", {
},
payment_type: function (frm) {
set_default_party_type(frm);
if (frm.doc.payment_type == "Internal Transfer") {
$.each(
[
@@ -1776,3 +1782,16 @@ frappe.ui.form.on("Payment Entry Deduction", {
frm.events.set_unallocated_amount(frm);
},
});
function set_default_party_type(frm) {
if (frm.doc.party) return;
let party_type;
if (frm.doc.payment_type == "Receive") {
party_type = "Customer";
} else if (frm.doc.payment_type == "Pay") {
party_type = "Supplier";
}
if (party_type) frm.set_value("party_type", party_type);
}

View File

@@ -1146,6 +1146,12 @@ class PaymentEntry(AccountsController):
if self.payment_type in ("Receive", "Pay") and not self.get("party_account_field"):
self.setup_party_account_field()
company_currency = erpnext.get_company_currency(self.company)
if self.paid_from_account_currency != company_currency:
self.currency = self.paid_from_account_currency
elif self.paid_to_account_currency != company_currency:
self.currency = self.paid_to_account_currency
gl_entries = []
self.add_party_gl_entries(gl_entries)
self.add_bank_gl_entries(gl_entries)
@@ -1248,13 +1254,22 @@ class PaymentEntry(AccountsController):
base_unallocated_amount = self.unallocated_amount * exchange_rate
gle = party_gl_dict.copy()
gle.update(
{
dr_or_cr + "_in_account_currency": self.unallocated_amount,
dr_or_cr: base_unallocated_amount,
}
)
gle.update(
self.get_gl_dict(
{
"account": self.party_account,
"party_type": self.party_type,
"party": self.party,
"against": against_account,
"account_currency": self.party_account_currency,
"cost_center": self.cost_center,
dr_or_cr + "_in_account_currency": self.unallocated_amount,
dr_or_cr: base_unallocated_amount,
},
item=self,
)
)
if self.book_advance_payments_in_separate_party_account:
gle.update(
{

View File

@@ -956,6 +956,53 @@ class TestPaymentEntry(FrappeTestCase):
self.assertEqual(flt(expected_party_balance), party_balance)
self.assertEqual(flt(expected_party_account_balance, 2), flt(party_account_balance, 2))
def test_gl_of_multi_currency_payment_transaction(self):
from erpnext.setup.doctype.currency_exchange.test_currency_exchange import (
save_new_records,
test_records,
)
save_new_records(test_records)
paid_from = create_account(
parent_account="Current Liabilities - _TC",
account_name="_Test Cash USD",
company="_Test Company",
account_type="Cash",
account_currency="USD",
)
payment_entry = create_payment_entry(
party="_Test Supplier USD",
paid_from=paid_from,
paid_to="_Test Payable USD - _TC",
paid_amount=100,
save=True,
)
payment_entry.source_exchange_rate = 84.4
payment_entry.target_exchange_rate = 84.4
payment_entry.save()
payment_entry = payment_entry.submit()
gle = qb.DocType("GL Entry")
gl_entries = (
qb.from_(gle)
.select(
gle.account,
gle.debit,
gle.credit,
gle.debit_in_account_currency,
gle.credit_in_account_currency,
gle.debit_in_transaction_currency,
gle.credit_in_transaction_currency,
)
.orderby(gle.account)
.where(gle.voucher_no == payment_entry.name)
.run()
)
expected_gl_entries = (
(paid_from, 0.0, 8440.0, 0.0, 100.0, 0.0, 100.0),
("_Test Payable USD - _TC", 8440.0, 0.0, 100.0, 0.0, 100.0, 0.0),
)
self.assertEqual(gl_entries, expected_gl_entries)
def test_multi_currency_payment_entry_with_taxes(self):
payment_entry = create_payment_entry(
party="_Test Supplier USD", paid_to="_Test Payable USD - _TC", save=True

View File

@@ -211,12 +211,14 @@ class PaymentReconciliation(Document):
if self.get("cost_center"):
conditions.append(jea.cost_center == self.cost_center)
dr_or_cr = (
"credit_in_account_currency"
if erpnext.get_party_account_type(self.party_type) == "Receivable"
else "debit_in_account_currency"
)
conditions.append(jea[dr_or_cr].gt(0))
account_type = erpnext.get_party_account_type(self.party_type)
if account_type == "Receivable":
dr_or_cr = jea.credit_in_account_currency - jea.debit_in_account_currency
elif account_type == "Payable":
dr_or_cr = jea.debit_in_account_currency - jea.credit_in_account_currency
conditions.append(dr_or_cr.gt(0))
if self.bank_cash_account:
conditions.append(jea.against_account.like(f"%%{self.bank_cash_account}%%"))
@@ -231,7 +233,7 @@ class PaymentReconciliation(Document):
je.posting_date,
je.remark.as_("remarks"),
jea.name.as_("reference_row"),
jea[dr_or_cr].as_("amount"),
dr_or_cr.as_("amount"),
jea.is_advance,
jea.exchange_rate,
jea.account_currency.as_("currency"),
@@ -371,6 +373,10 @@ class PaymentReconciliation(Document):
if self.invoice_limit:
non_reconciled_invoices = non_reconciled_invoices[: self.invoice_limit]
non_reconciled_invoices = sorted(
non_reconciled_invoices, key=lambda k: k["posting_date"] or getdate(nowdate())
)
self.add_invoice_entries(non_reconciled_invoices)
def add_invoice_entries(self, non_reconciled_invoices):

View File

@@ -632,6 +632,42 @@ class TestPaymentReconciliation(FrappeTestCase):
self.assertEqual(len(pr.get("invoices")), 0)
self.assertEqual(len(pr.get("payments")), 0)
def test_negative_debit_or_credit_journal_against_invoice(self):
transaction_date = nowdate()
amount = 100
si = self.create_sales_invoice(qty=1, rate=amount, posting_date=transaction_date)
# credit debtors account to record a payment
je = self.create_journal_entry(self.bank, self.debit_to, amount, transaction_date)
je.accounts[1].party_type = "Customer"
je.accounts[1].party = self.customer
je.accounts[1].credit_in_account_currency = 0
je.accounts[1].debit_in_account_currency = -1 * amount
je.save()
je.submit()
pr = self.create_payment_reconciliation()
pr.get_unreconciled_entries()
invoices = [x.as_dict() for x in pr.get("invoices")]
payments = [x.as_dict() for x in pr.get("payments")]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
# Difference amount should not be calculated for base currency accounts
for row in pr.allocation:
self.assertEqual(flt(row.get("difference_amount")), 0.0)
pr.reconcile()
# assert outstanding
si.reload()
self.assertEqual(si.status, "Paid")
self.assertEqual(si.outstanding_amount, 0)
# check PR tool output
self.assertEqual(len(pr.get("invoices")), 0)
self.assertEqual(len(pr.get("payments")), 0)
def test_journal_against_journal(self):
transaction_date = nowdate()
sales = "Sales - _PR"
@@ -954,6 +990,100 @@ class TestPaymentReconciliation(FrappeTestCase):
frappe.db.get_value("Journal Entry", jea_parent.parent, "voucher_type"), "Exchange Gain Or Loss"
)
def test_difference_amount_via_negative_debit_or_credit_journal_entry(self):
# Make Sale Invoice
si = self.create_sales_invoice(
qty=1, rate=100, posting_date=nowdate(), do_not_save=True, do_not_submit=True
)
si.customer = self.customer4
si.currency = "EUR"
si.conversion_rate = 85
si.debit_to = self.debtors_eur
si.save().submit()
# Make payment using Journal Entry
je1 = self.create_journal_entry("HDFC - _PR", self.debtors_eur, 100, nowdate())
je1.multi_currency = 1
je1.accounts[0].exchange_rate = 1
je1.accounts[0].credit_in_account_currency = -8000
je1.accounts[0].credit = -8000
je1.accounts[0].debit_in_account_currency = 0
je1.accounts[0].debit = 0
je1.accounts[1].party_type = "Customer"
je1.accounts[1].party = self.customer4
je1.accounts[1].exchange_rate = 80
je1.accounts[1].credit_in_account_currency = 100
je1.accounts[1].credit = 8000
je1.accounts[1].debit_in_account_currency = 0
je1.accounts[1].debit = 0
je1.save()
je1.submit()
je2 = self.create_journal_entry("HDFC - _PR", self.debtors_eur, 200, nowdate())
je2.multi_currency = 1
je2.accounts[0].exchange_rate = 1
je2.accounts[0].credit_in_account_currency = -16000
je2.accounts[0].credit = -16000
je2.accounts[0].debit_in_account_currency = 0
je2.accounts[0].debit = 0
je2.accounts[1].party_type = "Customer"
je2.accounts[1].party = self.customer4
je2.accounts[1].exchange_rate = 80
je2.accounts[1].credit_in_account_currency = 200
je1.accounts[1].credit = 16000
je1.accounts[1].debit_in_account_currency = 0
je1.accounts[1].debit = 0
je2.save()
je2.submit()
pr = self.create_payment_reconciliation()
pr.party = self.customer4
pr.receivable_payable_account = self.debtors_eur
pr.get_unreconciled_entries()
self.assertEqual(len(pr.invoices), 1)
self.assertEqual(len(pr.payments), 2)
# Test exact payment allocation
invoices = [x.as_dict() for x in pr.invoices]
payments = [pr.payments[0].as_dict()]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
self.assertEqual(pr.allocation[0].allocated_amount, 100)
self.assertEqual(pr.allocation[0].difference_amount, -500)
# Test partial payment allocation (with excess payment entry)
pr.set("allocation", [])
pr.get_unreconciled_entries()
invoices = [x.as_dict() for x in pr.invoices]
payments = [pr.payments[1].as_dict()]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
pr.allocation[0].difference_account = "Exchange Gain/Loss - _PR"
self.assertEqual(pr.allocation[0].allocated_amount, 100)
self.assertEqual(pr.allocation[0].difference_amount, -500)
# Check if difference journal entry gets generated for difference amount after reconciliation
pr.reconcile()
total_credit_amount = frappe.db.get_all(
"Journal Entry Account",
{"account": self.debtors_eur, "docstatus": 1, "reference_name": si.name},
"sum(credit) as amount",
group_by="reference_name",
)[0].amount
# total credit includes the exchange gain/loss amount
self.assertEqual(flt(total_credit_amount, 2), 8500)
jea_parent = frappe.db.get_all(
"Journal Entry Account",
filters={"account": self.debtors_eur, "docstatus": 1, "reference_name": si.name, "credit": 500},
fields=["parent"],
)[0]
self.assertEqual(
frappe.db.get_value("Journal Entry", jea_parent.parent, "voucher_type"), "Exchange Gain Or Loss"
)
def test_difference_amount_via_payment_entry(self):
# Make Sale Invoice
si = self.create_sales_invoice(

View File

@@ -945,17 +945,18 @@ def validate_payment(doc, method=None):
@frappe.whitelist()
def get_open_payment_requests_query(doctype, txt, searchfield, start, page_len, filters):
# permission checks in `get_list()`
reference_doctype = filters.get("reference_doctype")
reference_name = filters.get("reference_doctype")
filters = frappe._dict(filters)
if not reference_doctype or not reference_name:
if not filters.reference_doctype or not filters.reference_name:
return []
if txt:
filters.name = ["like", f"%{txt}%"]
open_payment_requests = frappe.get_list(
"Payment Request",
filters={
"reference_doctype": filters["reference_doctype"],
"reference_name": filters["reference_name"],
**filters,
"status": ["!=", "Paid"],
"outstanding_amount": ["!=", 0], # for compatibility with old data
"docstatus": 1,

View File

@@ -446,7 +446,20 @@ def get_pricing_rule_for_item(args, doc=None, for_validate=False):
if isinstance(pricing_rule, str):
pricing_rule = frappe.get_cached_doc("Pricing Rule", pricing_rule)
update_pricing_rule_uom(pricing_rule, args)
pricing_rule.apply_rule_on_other_items = get_pricing_rule_items(pricing_rule) or []
fetch_other_item = True if pricing_rule.apply_rule_on_other else False
pricing_rule.apply_rule_on_other_items = (
get_pricing_rule_items(pricing_rule, other_items=fetch_other_item) or []
)
if pricing_rule.coupon_code_based == 1:
if not args.coupon_code:
return item_details
coupon_code = frappe.db.get_value(
doctype="Coupon Code", filters={"pricing_rule": pricing_rule.name}, fieldname="name"
)
if args.coupon_code != coupon_code:
continue
if pricing_rule.get("suggestion"):
continue
@@ -473,9 +486,6 @@ def get_pricing_rule_for_item(args, doc=None, for_validate=False):
pricing_rule.apply_rule_on_other_items
)
if pricing_rule.coupon_code_based == 1 and args.coupon_code is None:
return item_details
if not pricing_rule.validate_applied_rule:
if pricing_rule.price_or_product_discount == "Price":
apply_price_discount_rule(pricing_rule, item_details, args)

View File

@@ -1537,10 +1537,29 @@ class PurchaseInvoice(BuyingController):
# eg: rounding_adjustment = 0.01 and exchange rate = 0.05 and precision of base_rounding_adjustment is 2
# then base_rounding_adjustment becomes zero and error is thrown in GL Entry
if not self.is_internal_transfer() and self.rounding_adjustment and self.base_rounding_adjustment:
round_off_account, round_off_cost_center = get_round_off_account_and_cost_center(
(
round_off_account,
round_off_cost_center,
round_off_for_opening,
) = get_round_off_account_and_cost_center(
self.company, "Purchase Invoice", self.name, self.use_company_roundoff_cost_center
)
if self.is_opening == "Yes" and self.rounding_adjustment:
if not round_off_for_opening:
frappe.throw(
_(
"Opening Invoice has rounding adjustment of {0}.<br><br> '{1}' account is required to post these values. Please set it in Company: {2}.<br><br> Or, '{3}' can be enabled to not post any rounding adjustment."
).format(
frappe.bold(self.rounding_adjustment),
frappe.bold("Round Off for Opening"),
get_link_to_form("Company", self.company),
frappe.bold("Disable Rounded Total"),
)
)
else:
round_off_account = round_off_for_opening
gl_entries.append(
self.get_gl_dict(
{

View File

@@ -2365,6 +2365,65 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
item.reload()
self.assertEqual(item.last_purchase_rate, 0)
def test_opening_invoice_rounding_adjustment_validation(self):
pi = make_purchase_invoice(do_not_save=1)
pi.items[0].rate = 99.98
pi.items[0].qty = 1
pi.items[0].expense_account = "Temporary Opening - _TC"
pi.is_opening = "Yes"
pi.save()
self.assertRaises(frappe.ValidationError, pi.submit)
def _create_opening_roundoff_account(self, company_name):
liability_root = frappe.db.get_all(
"Account",
filters={"company": company_name, "root_type": "Liability", "disabled": 0},
order_by="lft",
limit=1,
)[0]
# setup round off account
if acc := frappe.db.exists(
"Account",
{
"account_name": "Round Off for Opening",
"account_type": "Round Off for Opening",
"company": company_name,
},
):
frappe.db.set_value("Company", company_name, "round_off_for_opening", acc)
else:
acc = frappe.new_doc("Account")
acc.company = company_name
acc.parent_account = liability_root.name
acc.account_name = "Round Off for Opening"
acc.account_type = "Round Off for Opening"
acc.save()
frappe.db.set_value("Company", company_name, "round_off_for_opening", acc.name)
def test_ledger_entries_of_opening_invoice_with_rounding_adjustment(self):
pi = make_purchase_invoice(do_not_save=1)
pi.items[0].rate = 99.98
pi.items[0].qty = 1
pi.items[0].expense_account = "Temporary Opening - _TC"
pi.is_opening = "Yes"
pi.save()
self._create_opening_roundoff_account(pi.company)
pi.submit()
actual = frappe.db.get_all(
"GL Entry",
filters={"voucher_no": pi.name, "is_opening": "Yes", "is_cancelled": False},
fields=["account", "debit", "credit", "is_opening"],
order_by="account,debit",
)
expected = [
{"account": "Creditors - _TC", "debit": 0.0, "credit": 100.0, "is_opening": "Yes"},
{"account": "Round Off for Opening - _TC", "debit": 0.02, "credit": 0.0, "is_opening": "Yes"},
{"account": "Temporary Opening - _TC", "debit": 99.98, "credit": 0.0, "is_opening": "Yes"},
]
self.assertEqual(len(actual), 3)
self.assertEqual(expected, actual)
def set_advance_flag(company, flag, default_account):
frappe.db.set_value(

View File

@@ -1633,10 +1633,29 @@ class SalesInvoice(SellingController):
and self.base_rounding_adjustment
and not self.is_internal_transfer()
):
round_off_account, round_off_cost_center = get_round_off_account_and_cost_center(
(
round_off_account,
round_off_cost_center,
round_off_for_opening,
) = get_round_off_account_and_cost_center(
self.company, "Sales Invoice", self.name, self.use_company_roundoff_cost_center
)
if self.is_opening == "Yes" and self.rounding_adjustment:
if not round_off_for_opening:
frappe.throw(
_(
"Opening Invoice has rounding adjustment of {0}.<br><br> '{1}' account is required to post these values. Please set it in Company: {2}.<br><br> Or, '{3}' can be enabled to not post any rounding adjustment."
).format(
frappe.bold(self.rounding_adjustment),
frappe.bold("Round Off for Opening"),
get_link_to_form("Company", self.company),
frappe.bold("Disable Rounded Total"),
)
)
else:
round_off_account = round_off_for_opening
gl_entries.append(
self.get_gl_dict(
{

View File

@@ -4033,6 +4033,108 @@ class TestSalesInvoice(FrappeTestCase):
self.assertTrue(all([x == "Credit Note" for x in gl_entries]))
def test_validation_on_opening_invoice_with_rounding(self):
si = create_sales_invoice(qty=1, rate=99.98, do_not_submit=True)
si.is_opening = "Yes"
si.items[0].income_account = "Temporary Opening - _TC"
si.save()
self.assertRaises(frappe.ValidationError, si.submit)
def _create_opening_roundoff_account(self, company_name):
liability_root = frappe.db.get_all(
"Account",
filters={"company": company_name, "root_type": "Liability", "disabled": 0},
order_by="lft",
limit=1,
)[0]
# setup round off account
if acc := frappe.db.exists(
"Account",
{
"account_name": "Round Off for Opening",
"account_type": "Round Off for Opening",
"company": company_name,
},
):
frappe.db.set_value("Company", company_name, "round_off_for_opening", acc)
else:
acc = frappe.new_doc("Account")
acc.company = company_name
acc.parent_account = liability_root.name
acc.account_name = "Round Off for Opening"
acc.account_type = "Round Off for Opening"
acc.save()
frappe.db.set_value("Company", company_name, "round_off_for_opening", acc.name)
def test_opening_invoice_with_rounding_adjustment(self):
si = create_sales_invoice(qty=1, rate=99.98, do_not_submit=True)
si.is_opening = "Yes"
si.items[0].income_account = "Temporary Opening - _TC"
si.save()
self._create_opening_roundoff_account(si.company)
si.reload()
si.submit()
res = frappe.db.get_all(
"GL Entry",
filters={"voucher_no": si.name, "is_opening": "Yes"},
fields=["account", "debit", "credit", "is_opening"],
)
self.assertEqual(len(res), 3)
def _create_opening_invoice_with_inclusive_tax(self):
si = create_sales_invoice(qty=1, rate=90, do_not_submit=True)
si.is_opening = "Yes"
si.items[0].income_account = "Temporary Opening - _TC"
item_template = si.items[0].as_dict()
item_template.name = None
item_template.rate = 55
si.append("items", item_template)
si.append(
"taxes",
{
"charge_type": "On Net Total",
"account_head": "_Test Account Service Tax - _TC",
"cost_center": "_Test Cost Center - _TC",
"description": "Testing...",
"rate": 5,
"included_in_print_rate": True,
},
)
# there will be 0.01 precision loss between Dr and Cr
# caused by 'included_in_print_tax' option
si.save()
return si
def test_rounding_validation_for_opening_with_inclusive_tax(self):
si = self._create_opening_invoice_with_inclusive_tax()
# 'Round Off for Opening' not set in Company master
# Ledger level validation must be thrown
self.assertRaises(frappe.ValidationError, si.submit)
def test_ledger_entries_on_opening_invoice_with_rounding_loss_by_inclusive_tax(self):
si = self._create_opening_invoice_with_inclusive_tax()
# 'Round Off for Opening' is set in Company master
self._create_opening_roundoff_account(si.company)
si.submit()
actual = frappe.db.get_all(
"GL Entry",
filters={"voucher_no": si.name, "is_opening": "Yes", "is_cancelled": False},
fields=["account", "debit", "credit", "is_opening"],
order_by="account,debit",
)
expected = [
{"account": "_Test Account Service Tax - _TC", "debit": 0.0, "credit": 6.9, "is_opening": "Yes"},
{"account": "Debtors - _TC", "debit": 145.0, "credit": 0.0, "is_opening": "Yes"},
{"account": "Round Off for Opening - _TC", "debit": 0.0, "credit": 0.01, "is_opening": "Yes"},
{"account": "Temporary Opening - _TC", "debit": 0.0, "credit": 138.09, "is_opening": "Yes"},
]
self.assertEqual(len(actual), 4)
self.assertEqual(expected, actual)
def set_advance_flag(company, flag, default_account):
frappe.db.set_value(

View File

@@ -7,7 +7,7 @@ import copy
import frappe
from frappe import _
from frappe.model.meta import get_field_precision
from frappe.utils import cint, flt, formatdate, getdate, now
from frappe.utils import cint, flt, formatdate, get_link_to_form, getdate, now
import erpnext
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
@@ -496,16 +496,36 @@ def raise_debit_credit_not_equal_error(debit_credit_diff, voucher_type, voucher_
)
def has_opening_entries(gl_map: list) -> bool:
for x in gl_map:
if x.is_opening == "Yes":
return True
return False
def make_round_off_gle(gl_map, debit_credit_diff, precision):
round_off_account, round_off_cost_center = get_round_off_account_and_cost_center(
round_off_account, round_off_cost_center, round_off_for_opening = get_round_off_account_and_cost_center(
gl_map[0].company, gl_map[0].voucher_type, gl_map[0].voucher_no
)
round_off_gle = frappe._dict()
round_off_account_exists = False
has_opening_entry = has_opening_entries(gl_map)
if has_opening_entry:
if not round_off_for_opening:
frappe.throw(
_("Please set '{0}' in Company: {1}").format(
frappe.bold("Round Off for Opening"), get_link_to_form("Company", gl_map[0].company)
)
)
account = round_off_for_opening
else:
account = round_off_account
if gl_map[0].voucher_type != "Period Closing Voucher":
for d in gl_map:
if d.account == round_off_account:
if d.account == account:
round_off_gle = d
if d.debit:
debit_credit_diff -= flt(d.debit) - flt(d.credit)
@@ -523,7 +543,7 @@ def make_round_off_gle(gl_map, debit_credit_diff, precision):
round_off_gle.update(
{
"account": round_off_account,
"account": account,
"debit_in_account_currency": abs(debit_credit_diff) if debit_credit_diff < 0 else 0,
"credit_in_account_currency": debit_credit_diff if debit_credit_diff > 0 else 0,
"debit": abs(debit_credit_diff) if debit_credit_diff < 0 else 0,
@@ -537,6 +557,9 @@ def make_round_off_gle(gl_map, debit_credit_diff, precision):
}
)
if has_opening_entry:
round_off_gle.update({"is_opening": "Yes"})
update_accounting_dimensions(round_off_gle)
if not round_off_account_exists:
gl_map.append(round_off_gle)
@@ -561,9 +584,9 @@ def update_accounting_dimensions(round_off_gle):
def get_round_off_account_and_cost_center(company, voucher_type, voucher_no, use_company_default=False):
round_off_account, round_off_cost_center = frappe.get_cached_value(
"Company", company, ["round_off_account", "round_off_cost_center"]
) or [None, None]
round_off_account, round_off_cost_center, round_off_for_opening = frappe.get_cached_value(
"Company", company, ["round_off_account", "round_off_cost_center", "round_off_for_opening"]
) or [None, None, None]
# Use expense account as fallback
if not round_off_account:
@@ -578,12 +601,20 @@ def get_round_off_account_and_cost_center(company, voucher_type, voucher_no, use
round_off_cost_center = parent_cost_center
if not round_off_account:
frappe.throw(_("Please mention Round Off Account in Company"))
frappe.throw(
_("Please mention '{0}' in Company: {1}").format(
frappe.bold("Round Off Account"), get_link_to_form("Company", company)
)
)
if not round_off_cost_center:
frappe.throw(_("Please mention Round Off Cost Center in Company"))
frappe.throw(
_("Please mention '{0}' in Company: {1}").format(
frappe.bold("Round Off Cost Center"), get_link_to_form("Company", company)
)
)
return round_off_account, round_off_cost_center
return round_off_account, round_off_cost_center, round_off_for_opening
def make_reverse_gl_entries(

View File

@@ -630,6 +630,16 @@ def update_reference_in_journal_entry(d, journal_entry, do_not_save=False):
if jv_detail.get("reference_type") in ["Sales Order", "Purchase Order"]:
update_advance_paid.append((jv_detail.reference_type, jv_detail.reference_name))
rev_dr_or_cr = (
"debit_in_account_currency"
if d["dr_or_cr"] == "credit_in_account_currency"
else "credit_in_account_currency"
)
if jv_detail.get(rev_dr_or_cr):
d["dr_or_cr"] = rev_dr_or_cr
d["allocated_amount"] = d["allocated_amount"] * -1
d["unadjusted_amount"] = d["unadjusted_amount"] * -1
if flt(d["unadjusted_amount"]) - flt(d["allocated_amount"]) != 0:
# adjust the unreconciled balance
amount_in_account_currency = flt(d["unadjusted_amount"]) - flt(d["allocated_amount"])

View File

@@ -1298,7 +1298,11 @@ class AccountsController(TransactionBase):
d.exchange_gain_loss = difference
def make_precision_loss_gl_entry(self, gl_entries):
round_off_account, round_off_cost_center = get_round_off_account_and_cost_center(
(
round_off_account,
round_off_cost_center,
round_off_for_opening,
) = get_round_off_account_and_cost_center(
self.company, "Purchase Invoice", self.name, self.use_company_roundoff_cost_center
)

View File

@@ -167,6 +167,9 @@ class SellingController(StockController):
total = 0.0
sales_team = self.get("sales_team")
self.validate_sales_team(sales_team)
for sales_person in sales_team:
self.round_floats_in(sales_person)
@@ -186,6 +189,20 @@ class SellingController(StockController):
if sales_team and total != 100.0:
throw(_("Total allocated percentage for sales team should be 100"))
def validate_sales_team(self, sales_team):
sales_persons = [d.sales_person for d in sales_team]
if not sales_persons:
return
sales_person_status = frappe.db.get_all(
"Sales Person", filters={"name": ["in", sales_persons]}, fields=["name", "enabled"]
)
for row in sales_person_status:
if not row.enabled:
frappe.throw(_("Sales Person <b>{0}</b> is disabled.").format(row.name))
def validate_max_discount(self):
for d in self.get("items"):
if d.item_code:

View File

@@ -839,6 +839,15 @@ class StockController(AccountsController):
if not dimension:
continue
if (
self.doctype in ["Purchase Invoice", "Purchase Receipt"]
and row.get("rejected_warehouse")
and sl_dict.get("warehouse") == row.get("rejected_warehouse")
):
fieldname = f"rejected_{dimension.source_fieldname}"
sl_dict[dimension.target_fieldname] = row.get(fieldname)
continue
if self.doctype in [
"Purchase Invoice",
"Purchase Receipt",

0
erpnext/edi/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1,51 @@
// Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
frappe.ui.form.on("Code List", {
refresh: (frm) => {
if (!frm.doc.__islocal) {
frm.add_custom_button(__("Import Genericode File"), function () {
erpnext.edi.import_genericode(frm);
});
}
},
setup: (frm) => {
frm.savetrash = () => {
frm.validate_form_action("Delete");
frappe.confirm(
__(
"Are you sure you want to delete {0}?<p>This action will also delete all associated Common Code documents.</p>",
[frm.docname.bold()]
),
function () {
return frappe.call({
method: "frappe.client.delete",
args: {
doctype: frm.doctype,
name: frm.docname,
},
freeze: true,
freeze_message: __("Deleting {0} and all associated Common Code documents...", [
frm.docname,
]),
callback: function (r) {
if (!r.exc) {
frappe.utils.play_sound("delete");
frappe.model.clear_doc(frm.doctype, frm.docname);
window.history.back();
}
},
});
}
);
};
frm.set_query("default_common_code", function (doc) {
return {
filters: {
code_list: doc.name,
},
};
});
},
});

View File

@@ -0,0 +1,112 @@
{
"actions": [],
"allow_copy": 1,
"allow_rename": 1,
"autoname": "prompt",
"creation": "2024-09-29 06:55:03.920375",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"title",
"canonical_uri",
"url",
"default_common_code",
"column_break_nkls",
"version",
"publisher",
"publisher_id",
"section_break_npxp",
"description"
],
"fields": [
{
"fieldname": "title",
"fieldtype": "Data",
"label": "Title"
},
{
"fieldname": "publisher",
"fieldtype": "Data",
"in_standard_filter": 1,
"label": "Publisher"
},
{
"columns": 1,
"fieldname": "version",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Version"
},
{
"fieldname": "description",
"fieldtype": "Small Text",
"label": "Description"
},
{
"fieldname": "canonical_uri",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Canonical URI"
},
{
"fieldname": "column_break_nkls",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_npxp",
"fieldtype": "Section Break"
},
{
"fieldname": "publisher_id",
"fieldtype": "Data",
"in_standard_filter": 1,
"label": "Publisher ID"
},
{
"fieldname": "url",
"fieldtype": "Data",
"label": "URL",
"options": "URL"
},
{
"description": "This value shall be used when no matching Common Code for a record is found.",
"fieldname": "default_common_code",
"fieldtype": "Link",
"label": "Default Common Code",
"options": "Common Code"
}
],
"index_web_pages_for_search": 1,
"links": [
{
"link_doctype": "Common Code",
"link_fieldname": "code_list"
}
],
"modified": "2024-11-16 17:01:40.260293",
"modified_by": "Administrator",
"module": "EDI",
"name": "Code List",
"naming_rule": "Set by user",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"search_fields": "description",
"show_title_field_in_link": 1,
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"title_field": "title"
}

View File

@@ -0,0 +1,125 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
from typing import TYPE_CHECKING
import frappe
from frappe.model.document import Document
if TYPE_CHECKING:
from lxml.etree import Element
class CodeList(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.types import DF
canonical_uri: DF.Data | None
default_common_code: DF.Link | None
description: DF.SmallText | None
publisher: DF.Data | None
publisher_id: DF.Data | None
title: DF.Data | None
url: DF.Data | None
version: DF.Data | None
# end: auto-generated types
def on_trash(self):
if not frappe.flags.in_bulk_delete:
self.__delete_linked_docs()
def __delete_linked_docs(self):
self.db_set("default_common_code", None)
linked_docs = frappe.get_all(
"Common Code",
filters={"code_list": self.name},
fields=["name"],
)
for doc in linked_docs:
frappe.delete_doc("Common Code", doc.name)
def get_codes_for(self, doctype: str, name: str) -> tuple[str]:
"""Get the applicable codes for a doctype and name"""
return get_codes_for(self.name, doctype, name)
def get_docnames_for(self, doctype: str, code: str) -> tuple[str]:
"""Get the mapped docnames for a doctype and code"""
return get_docnames_for(self.name, doctype, code)
def get_default_code(self) -> str | None:
"""Get the default common code for this code list"""
return (
frappe.db.get_value("Common Code", self.default_common_code, "common_code")
if self.default_common_code
else None
)
def from_genericode(self, root: "Element"):
"""Extract Code List details from genericode XML"""
self.title = root.find(".//Identification/ShortName").text
self.version = root.find(".//Identification/Version").text
self.canonical_uri = root.find(".//CanonicalUri").text
# optionals
self.description = getattr(root.find(".//Identification/LongName"), "text", None)
self.publisher = getattr(root.find(".//Identification/Agency/ShortName"), "text", None)
if not self.publisher:
self.publisher = getattr(root.find(".//Identification/Agency/LongName"), "text", None)
self.publisher_id = getattr(root.find(".//Identification/Agency/Identifier"), "text", None)
self.url = getattr(root.find(".//Identification/LocationUri"), "text", None)
def get_codes_for(code_list: str, doctype: str, name: str) -> tuple[str]:
"""Return the common code for a given record"""
CommonCode = frappe.qb.DocType("Common Code")
DynamicLink = frappe.qb.DocType("Dynamic Link")
codes = (
frappe.qb.from_(CommonCode)
.join(DynamicLink)
.on((CommonCode.name == DynamicLink.parent) & (DynamicLink.parenttype == "Common Code"))
.select(CommonCode.common_code)
.where(
(DynamicLink.link_doctype == doctype)
& (DynamicLink.link_name == name)
& (CommonCode.code_list == code_list)
)
.distinct()
.orderby(CommonCode.common_code)
).run()
return tuple(c[0] for c in codes) if codes else ()
def get_docnames_for(code_list: str, doctype: str, code: str) -> tuple[str]:
"""Return the record name for a given common code"""
CommonCode = frappe.qb.DocType("Common Code")
DynamicLink = frappe.qb.DocType("Dynamic Link")
docnames = (
frappe.qb.from_(CommonCode)
.join(DynamicLink)
.on((CommonCode.name == DynamicLink.parent) & (DynamicLink.parenttype == "Common Code"))
.select(DynamicLink.link_name)
.where(
(DynamicLink.link_doctype == doctype)
& (CommonCode.common_code == code)
& (CommonCode.code_list == code_list)
)
.distinct()
.orderby(DynamicLink.idx)
).run()
return tuple(d[0] for d in docnames) if docnames else ()
def get_default_code(code_list: str) -> str | None:
"""Return the default common code for a given code list"""
code_id = frappe.db.get_value("Code List", code_list, "default_common_code")
return frappe.db.get_value("Common Code", code_id, "common_code") if code_id else None

View File

@@ -0,0 +1,218 @@
frappe.provide("erpnext.edi");
erpnext.edi.import_genericode = function (listview_or_form) {
let doctype = "Code List";
let docname = undefined;
if (listview_or_form.doc !== undefined) {
docname = listview_or_form.doc.name;
}
new frappe.ui.FileUploader({
method: "erpnext.edi.doctype.code_list.code_list_import.import_genericode",
doctype: doctype,
docname: docname,
allow_toggle_private: false,
allow_take_photo: false,
on_success: function (_file_doc, r) {
listview_or_form.refresh();
show_column_selection_dialog(r.message);
},
});
};
function show_column_selection_dialog(context) {
let title_description = __("If there is no title column, use the code column for the title.");
let default_title = get_default(context.columns, ["name", "Name", "code-name", "scheme-name"]);
let fields = [
{
fieldtype: "HTML",
fieldname: "code_list_info",
options: `<div class="text-muted">${__(
"You are importing data for the code list:"
)} ${frappe.utils.get_form_link(
"Code List",
context.code_list,
true,
context.code_list_title
)}</div>`,
},
{
fieldtype: "Section Break",
},
{
fieldname: "import_column",
label: __("Import"),
fieldtype: "Column Break",
},
{
fieldname: "title_column",
label: __("as Title"),
fieldtype: "Select",
reqd: 1,
options: context.columns,
default: default_title,
description: default_title ? null : title_description,
},
{
fieldname: "code_column",
label: __("as Code"),
fieldtype: "Select",
options: context.columns,
reqd: 1,
default: get_default(context.columns, ["code", "Code", "value"]),
},
{
fieldname: "filters_column",
label: __("Filter"),
fieldtype: "Column Break",
},
];
if (context.columns.length > 2) {
fields.splice(5, 0, {
fieldname: "description_column",
label: __("as Description"),
fieldtype: "Select",
options: [null].concat(context.columns),
default: get_default(context.columns, [
"description",
"Description",
"remark",
__("description"),
__("Description"),
]),
});
}
// Add filterable columns
for (let column in context.filterable_columns) {
fields.push({
fieldname: `filter_${column}`,
label: __("by {}", [column]),
fieldtype: "Select",
options: [null].concat(context.filterable_columns[column]),
});
}
fields.push(
{
fieldname: "preview_section",
label: __("Preview"),
fieldtype: "Section Break",
},
{
fieldname: "preview_html",
fieldtype: "HTML",
}
);
let d = new frappe.ui.Dialog({
title: __("Select Columns and Filters"),
fields: fields,
primary_action_label: __("Import"),
size: "large", // This will make the modal wider
primary_action(values) {
let filters = {};
for (let field in values) {
if (field.startsWith("filter_") && values[field]) {
filters[field.replace("filter_", "")] = values[field];
}
}
frappe
.xcall("erpnext.edi.doctype.code_list.code_list_import.process_genericode_import", {
code_list_name: context.code_list,
file_name: context.file,
code_column: values.code_column,
title_column: values.title_column,
description_column: values.description_column,
filters: filters,
})
.then((count) => {
frappe.msgprint(__("Import completed. {0} common codes created.", [count]));
});
d.hide();
},
});
d.fields_dict.code_column.df.onchange = () => update_preview(d, context);
d.fields_dict.title_column.df.onchange = (e) => {
let field = d.fields_dict.title_column;
if (!e.target.value) {
field.df.description = title_description;
field.refresh();
} else {
field.df.description = null;
field.refresh();
}
update_preview(d, context);
};
// Add onchange events for filterable columns
for (let column in context.filterable_columns) {
d.fields_dict[`filter_${column}`].df.onchange = () => update_preview(d, context);
}
d.show();
update_preview(d, context);
}
/**
* Return the first key from the keys array that is found in the columns array.
*/
function get_default(columns, keys) {
return keys.find((key) => columns.includes(key));
}
function update_preview(dialog, context) {
let code_column = dialog.get_value("code_column");
let title_column = dialog.get_value("title_column");
let description_column = dialog.get_value("description_column");
let html = '<table class="table table-bordered"><thead><tr>';
if (title_column) html += `<th>${__("Title")}</th>`;
if (code_column) html += `<th>${__("Code")}</th>`;
if (description_column) html += `<th>${__("Description")}</th>`;
// Add headers for filterable columns
for (let column in context.filterable_columns) {
if (dialog.get_value(`filter_${column}`)) {
html += `<th>${__(column)}</th>`;
}
}
html += "</tr></thead><tbody>";
for (let i = 0; i < 3; i++) {
html += "<tr>";
if (title_column) {
let title = context.example_values[title_column][i] || "";
html += `<td title="${title}">${truncate(title)}</td>`;
}
if (code_column) {
let code = context.example_values[code_column][i] || "";
html += `<td title="${code}">${truncate(code)}</td>`;
}
if (description_column) {
let description = context.example_values[description_column][i] || "";
html += `<td title="${description}">${truncate(description)}</td>`;
}
// Add values for filterable columns
for (let column in context.filterable_columns) {
if (dialog.get_value(`filter_${column}`)) {
let value = context.example_values[column][i] || "";
html += `<td title="${value}">${truncate(value)}</td>`;
}
}
html += "</tr>";
}
html += "</tbody></table>";
dialog.fields_dict.preview_html.$wrapper.html(html);
}
function truncate(value, maxLength = 40) {
if (typeof value !== "string") return "";
return value.length > maxLength ? value.substring(0, maxLength - 3) + "..." : value;
}

View File

@@ -0,0 +1,140 @@
import json
import frappe
import requests
from frappe import _
from lxml import etree
URL_PREFIXES = ("http://", "https://")
@frappe.whitelist()
def import_genericode():
doctype = "Code List"
docname = frappe.form_dict.docname
content = frappe.local.uploaded_file
# recover the content, if it's a link
if (file_url := frappe.local.uploaded_file_url) and file_url.startswith(URL_PREFIXES):
try:
# If it's a URL, fetch the content and make it a local file (for durable audit)
response = requests.get(frappe.local.uploaded_file_url)
response.raise_for_status()
frappe.local.uploaded_file = content = response.content
frappe.local.uploaded_filename = frappe.local.uploaded_file_url.split("/")[-1]
frappe.local.uploaded_file_url = None
except Exception as e:
frappe.throw(f"<pre>{e!s}</pre>", title=_("Fetching Error"))
if file_url := frappe.local.uploaded_file_url:
file_path = frappe.utils.file_manager.get_file_path(file_url)
with open(file_path.encode(), mode="rb") as f:
content = f.read()
# Parse the xml content
parser = etree.XMLParser(remove_blank_text=True)
try:
root = etree.fromstring(content, parser=parser)
except Exception as e:
frappe.throw(f"<pre>{e!s}</pre>", title=_("Parsing Error"))
# Extract the name (CanonicalVersionUri) from the parsed XML
name = root.find(".//CanonicalVersionUri").text
docname = docname or name
if frappe.db.exists(doctype, docname):
code_list = frappe.get_doc(doctype, docname)
if code_list.name != name:
frappe.throw(_("The uploaded file does not match the selected Code List."))
else:
# Create a new Code List document with the extracted name
code_list = frappe.new_doc(doctype)
code_list.name = name
code_list.from_genericode(root)
code_list.save()
# Attach the file and provide a recoverable identifier
file_doc = frappe.get_doc(
{
"doctype": "File",
"attached_to_doctype": "Code List",
"attached_to_name": code_list.name,
"folder": "Home/Attachments",
"file_name": frappe.local.uploaded_filename,
"file_url": frappe.local.uploaded_file_url,
"is_private": 1,
"content": content,
}
).save()
# Get available columns and example values
columns, example_values, filterable_columns = get_genericode_columns_and_examples(root)
return {
"code_list": code_list.name,
"code_list_title": code_list.title,
"file": file_doc.name,
"columns": columns,
"example_values": example_values,
"filterable_columns": filterable_columns,
}
@frappe.whitelist()
def process_genericode_import(
code_list_name: str,
file_name: str,
code_column: str,
title_column: str | None = None,
description_column: str | None = None,
filters: str | None = None,
):
from erpnext.edi.doctype.common_code.common_code import import_genericode
column_map = {"code": code_column, "title": title_column, "description": description_column}
return import_genericode(code_list_name, file_name, column_map, json.loads(filters) if filters else None)
def get_genericode_columns_and_examples(root):
columns = []
example_values = {}
filterable_columns = {}
# Get column names
for column in root.findall(".//Column"):
column_id = column.get("Id")
columns.append(column_id)
example_values[column_id] = []
filterable_columns[column_id] = set()
# Get all values and count unique occurrences
for row in root.findall(".//SimpleCodeList/Row"):
for value in row.findall("Value"):
column_id = value.get("ColumnRef")
if column_id not in columns:
# Handle undeclared column
columns.append(column_id)
example_values[column_id] = []
filterable_columns[column_id] = set()
simple_value = value.find("./SimpleValue")
if simple_value is None:
continue
filterable_columns[column_id].add(simple_value.text)
# Get example values (up to 3) and filter columns with cardinality <= 5
for row in root.findall(".//SimpleCodeList/Row")[:3]:
for value in row.findall("Value"):
column_id = value.get("ColumnRef")
simple_value = value.find("./SimpleValue")
if simple_value is None:
continue
example_values[column_id].append(simple_value.text)
filterable_columns = {k: list(v) for k, v in filterable_columns.items() if len(v) <= 5}
return columns, example_values, filterable_columns

View File

@@ -0,0 +1,8 @@
frappe.listview_settings["Code List"] = {
onload: function (listview) {
listview.page.add_inner_button(__("Import Genericode File"), function () {
erpnext.edi.import_genericode(listview);
});
},
hide_name_column: true,
};

View File

@@ -0,0 +1,9 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
class TestCodeList(FrappeTestCase):
pass

View File

@@ -0,0 +1,8 @@
// Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
// frappe.ui.form.on("Common Code", {
// refresh(frm) {
// },
// });

View File

@@ -0,0 +1,103 @@
{
"actions": [],
"autoname": "hash",
"creation": "2024-09-29 07:01:18.133067",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"code_list",
"title",
"common_code",
"description",
"column_break_wxsw",
"additional_data",
"section_break_rhgh",
"applies_to"
],
"fields": [
{
"fieldname": "code_list",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Code List",
"options": "Code List",
"reqd": 1,
"search_index": 1
},
{
"fieldname": "title",
"fieldtype": "Data",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Title",
"length": 300,
"reqd": 1
},
{
"fieldname": "column_break_wxsw",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_rhgh",
"fieldtype": "Section Break"
},
{
"fieldname": "applies_to",
"fieldtype": "Table",
"label": "Applies To",
"options": "Dynamic Link"
},
{
"fieldname": "common_code",
"fieldtype": "Data",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Common Code",
"length": 300,
"reqd": 1,
"search_index": 1
},
{
"fieldname": "additional_data",
"fieldtype": "Code",
"label": "Additional Data",
"max_height": "190px",
"read_only": 1
},
{
"fieldname": "description",
"fieldtype": "Small Text",
"in_list_view": 1,
"label": "Description",
"max_height": "60px"
}
],
"links": [],
"modified": "2024-11-06 07:46:17.175687",
"modified_by": "Administrator",
"module": "EDI",
"name": "Common Code",
"naming_rule": "Random",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"search_fields": "common_code,description",
"show_title_field_in_link": 1,
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"title_field": "title"
}

View File

@@ -0,0 +1,114 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import hashlib
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils.data import get_link_to_form
from lxml import etree
class CommonCode(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.core.doctype.dynamic_link.dynamic_link import DynamicLink
from frappe.types import DF
additional_data: DF.Code | None
applies_to: DF.Table[DynamicLink]
code_list: DF.Link
common_code: DF.Data
description: DF.SmallText | None
title: DF.Data
# end: auto-generated types
def validate(self):
self.validate_distinct_references()
def validate_distinct_references(self):
"""Ensure no two Common Codes of the same Code List are linked to the same document."""
for link in self.applies_to:
existing_links = frappe.get_all(
"Common Code",
filters=[
["name", "!=", self.name],
["code_list", "=", self.code_list],
["Dynamic Link", "link_doctype", "=", link.link_doctype],
["Dynamic Link", "link_name", "=", link.link_name],
],
fields=["name", "common_code"],
)
if existing_links:
existing_link = existing_links[0]
frappe.throw(
_("{0} {1} is already linked to Common Code {2}.").format(
link.link_doctype,
link.link_name,
get_link_to_form("Common Code", existing_link["name"], existing_link["common_code"]),
)
)
def from_genericode(self, column_map: dict, xml_element: "etree.Element"):
"""Populate the Common Code document from a genericode XML element
Args:
column_map (dict): A mapping of column names to XML column references. Keys: code, title, description
code (etree.Element): The XML element representing a code in the genericode file
"""
title_column = column_map.get("title")
code_column = column_map["code"]
description_column = column_map.get("description")
self.common_code = xml_element.find(f"./Value[@ColumnRef='{code_column}']/SimpleValue").text
if title_column:
simple_value_title = xml_element.find(f"./Value[@ColumnRef='{title_column}']/SimpleValue")
self.title = simple_value_title.text if simple_value_title is not None else self.common_code
if description_column:
simple_value_descr = xml_element.find(f"./Value[@ColumnRef='{description_column}']/SimpleValue")
self.description = simple_value_descr.text if simple_value_descr is not None else None
self.additional_data = etree.tostring(xml_element, encoding="unicode", pretty_print=True)
def simple_hash(input_string, length=6):
return hashlib.blake2b(input_string.encode(), digest_size=length // 2).hexdigest()
def import_genericode(code_list: str, file_name: str, column_map: dict, filters: dict | None = None):
"""Import genericode file and create Common Code entries"""
file_path = frappe.utils.file_manager.get_file_path(file_name)
parser = etree.XMLParser(remove_blank_text=True)
tree = etree.parse(file_path, parser=parser)
root = tree.getroot()
# Construct the XPath expression
xpath_expr = ".//SimpleCodeList/Row"
filter_conditions = [
f"Value[@ColumnRef='{column_ref}']/SimpleValue='{value}'" for column_ref, value in filters.items()
]
if filter_conditions:
xpath_expr += "[" + " and ".join(filter_conditions) + "]"
elements = root.xpath(xpath_expr)
total_elements = len(elements)
for i, xml_element in enumerate(elements, start=1):
common_code: "CommonCode" = frappe.new_doc("Common Code")
common_code.code_list = code_list
common_code.from_genericode(column_map, xml_element)
common_code.save()
frappe.publish_progress(i / total_elements * 100, title=_("Importing Common Codes"))
return total_elements
def on_doctype_update():
frappe.db.add_index("Common Code", ["code_list", "common_code"])

View File

@@ -0,0 +1,8 @@
frappe.listview_settings["Common Code"] = {
onload: function (listview) {
listview.page.add_inner_button(__("Import Genericode File"), function () {
erpnext.edi.import_genericode(listview);
});
},
hide_name_column: true,
};

View File

@@ -0,0 +1,9 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
class TestCommonCode(FrappeTestCase):
pass

View File

@@ -35,6 +35,14 @@ doctype_js = {
"Newsletter": "public/js/newsletter.js",
"Contact": "public/js/contact.js",
}
doctype_list_js = {
"Code List": [
"edi/doctype/code_list/code_list_import.js",
],
"Common Code": [
"edi/doctype/code_list/code_list_import.js",
],
}
override_doctype_class = {"Address": "erpnext.accounts.custom.address.ERPNextAddress"}

View File

@@ -18,3 +18,4 @@ Communication
Telephony
Bulk Transaction
Subcontracting
EDI

View File

@@ -8,44 +8,142 @@ def execute():
def update_sales_invoice_remarks():
si_list = frappe.db.get_all(
"Sales Invoice",
filters={
"docstatus": 1,
"remarks": "No Remarks",
"po_no": ["!=", ""],
},
fields=["name", "po_no"],
)
"""
Update remarks in Sales Invoice.
Some sites may have very large volume of sales invoices.
In such cases, updating documents one by one won't be successful, especially during site migration step.
Refer to the bug report: https://github.com/frappe/erpnext/issues/43634
In this case, a bulk update must be done.
for doc in si_list:
remarks = _("Against Customer Order {0}").format(doc.po_no)
update_remarks("Sales Invoice", doc.name, remarks)
Step 1: Update remarks in GL Entries
Step 2: Update remarks in Payment Ledger Entries
Step 3: Update remarks in Sales Invoice - Should be last step
"""
### Step 1: Update remarks in GL Entries
update_sales_invoice_gle_remarks()
### Step 2: Update remarks in Payment Ledger Entries
update_sales_invoice_ple_remarks()
### Step 3: Update remarks in Sales Invoice
update_query = """
UPDATE `tabSales Invoice`
SET remarks = concat('Against Customer Order ', po_no)
WHERE po_no <> '' AND docstatus = %(docstatus)s and remarks = %(remarks)s
"""
# Data for update query
values = {"remarks": "No Remarks", "docstatus": 1}
# Execute query
frappe.db.sql(update_query, values=values, as_dict=0)
def update_purchase_invoice_remarks():
pi_list = frappe.db.get_all(
"Purchase Invoice",
filters={
"docstatus": 1,
"remarks": "No Remarks",
"bill_no": ["!=", ""],
},
fields=["name", "bill_no"],
)
"""
Update remarks in Purchase Invoice.
Some sites may have very large volume of purchase invoices.
In such cases, updating documents one by one wont be successful, especially during site migration step.
Refer to the bug report: https://github.com/frappe/erpnext/issues/43634
In this case, a bulk update must be done.
for doc in pi_list:
remarks = _("Against Supplier Invoice {0}").format(doc.bill_no)
update_remarks("Purchase Invoice", doc.name, remarks)
Step 1: Update remarks in GL Entries
Step 2: Update remarks in Payment Ledger Entries
Step 3: Update remarks in Purchase Invoice - Should be last step
"""
### Step 1: Update remarks in GL Entries
update_purchase_invoice_gle_remarks()
### Step 2: Update remarks in Payment Ledger Entries
update_purchase_invoice_ple_remarks()
### Step 3: Update remarks in Purchase Invoice
update_query = """
UPDATE `tabPurchase Invoice`
SET remarks = concat('Against Supplier Invoice ', bill_no)
WHERE bill_no <> '' AND docstatus = %(docstatus)s and remarks = %(remarks)s
"""
# Data for update query
values = {"remarks": "No Remarks", "docstatus": 1}
# Execute query
frappe.db.sql(update_query, values=values, as_dict=0)
def update_remarks(doctype, docname, remarks):
filters = {
"voucher_type": doctype,
"remarks": "No Remarks",
"voucher_no": docname,
}
def update_sales_invoice_gle_remarks():
## Update query to update GL Entry - Updates all entries which are for Sales Invoice with No Remarks
update_query = """
UPDATE
`tabGL Entry` as gle
INNER JOIN `tabSales Invoice` as si
ON gle.voucher_type = 'Sales Invoice' AND gle.voucher_no = si.name AND gle.remarks = %(remarks)s
SET
gle.remarks = concat('Against Customer Order ', si.po_no)
WHERE si.po_no <> '' AND si.docstatus = %(docstatus)s and si.remarks = %(remarks)s
"""
frappe.db.set_value(doctype, docname, "remarks", remarks)
frappe.db.set_value("GL Entry", filters, "remarks", remarks)
frappe.db.set_value("Payment Ledger Entry", filters, "remarks", remarks)
# Data for update query
values = {"remarks": "No Remarks", "docstatus": 1}
# Execute query
frappe.db.sql(update_query, values=values, as_dict=0)
def update_sales_invoice_ple_remarks():
## Update query to update Payment Ledger Entry - Updates all entries which are for Sales Invoice with No Remarks
update_query = """
UPDATE
`tabPayment Ledger Entry` as ple
INNER JOIN `tabSales Invoice` as si
ON ple.voucher_type = 'Sales Invoice' AND ple.voucher_no = si.name AND ple.remarks = %(remarks)s
SET
ple.remarks = concat('Against Customer Order ', si.po_no)
WHERE si.po_no <> '' AND si.docstatus = %(docstatus)s and si.remarks = %(remarks)s
"""
### Data for update query
values = {"remarks": "No Remarks", "docstatus": 1}
### Execute query
frappe.db.sql(update_query, values=values, as_dict=0)
def update_purchase_invoice_gle_remarks():
### Query to update GL Entry - Updates all entries which are for Purchase Invoice with No Remarks
update_query = """
UPDATE
`tabGL Entry` as gle
INNER JOIN `tabPurchase Invoice` as pi
ON gle.voucher_type = 'Purchase Invoice' AND gle.voucher_no = pi.name AND gle.remarks = %(remarks)s
SET
gle.remarks = concat('Against Supplier Invoice ', pi.bill_no)
WHERE pi.bill_no <> '' AND pi.docstatus = %(docstatus)s and pi.remarks = %(remarks)s
"""
### Data for update query
values = {"remarks": "No Remarks", "docstatus": 1}
### Execute query
frappe.db.sql(update_query, values=values, as_dict=0)
def update_purchase_invoice_ple_remarks():
### Query to update Payment Ledger Entry - Updates all entries which are for Purchase Invoice with No Remarks
update_query = """
UPDATE
`tabPayment Ledger Entry` as ple
INNER JOIN `tabPurchase Invoice` as pi
ON ple.voucher_type = 'Purchase Invoice' AND ple.voucher_no = pi.name AND ple.remarks = %(remarks)s
SET
ple.remarks = concat('Against Supplier Invoice ', pi.bill_no)
WHERE pi.bill_no <> '' AND pi.docstatus = %(docstatus)s and pi.remarks = %(remarks)s
"""
### Data for update query
values = {"remarks": "No Remarks", "docstatus": 1}
### Execute query
frappe.db.sql(update_query, values=values, as_dict=0)

View File

@@ -169,10 +169,14 @@ class Timesheet(Document):
task.save()
tasks.append(data.task)
elif data.project and data.project not in projects:
frappe.get_doc("Project", data.project).update_project()
if data.project and data.project not in projects:
projects.append(data.project)
for project in projects:
project_doc = frappe.get_doc("Project", project)
project_doc.update_project()
project_doc.save()
def validate_dates(self):
for data in self.time_logs:
if data.from_time and data.to_time and time_diff_in_hours(data.to_time, data.from_time) < 0:

View File

@@ -1237,8 +1237,8 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
},
callback: function(r) {
if(!r.exc) {
me.apply_price_list(item, true)
frappe.model.set_value(cdt, cdn, 'conversion_factor', r.message.conversion_factor);
me.apply_price_list(item, true);
}
}
});

View File

@@ -100,6 +100,7 @@ erpnext.accounts.unreconcile_payment = {
fieldtype: "Table",
read_only: 1,
fields: child_table_fields,
cannot_add_rows: true,
},
];
@@ -123,7 +124,6 @@ erpnext.accounts.unreconcile_payment = {
title: "UnReconcile Allocations",
fields: unreconcile_dialog_fields,
size: "large",
cannot_add_rows: true,
primary_action_label: "UnReconcile",
primary_action(values) {
let selected_allocations = values.allocations.filter((x) => x.__checked);

View File

@@ -65,6 +65,7 @@
"grand_total",
"rounding_adjustment",
"rounded_total",
"disable_rounded_total",
"in_words",
"section_break_44",
"apply_discount_on",
@@ -661,6 +662,7 @@
"width": "200px"
},
{
"depends_on": "eval:!doc.disable_rounded_total",
"fieldname": "base_rounding_adjustment",
"fieldtype": "Currency",
"label": "Rounding Adjustment (Company Currency)",
@@ -709,6 +711,7 @@
"width": "200px"
},
{
"depends_on": "eval:!doc.disable_rounded_total",
"fieldname": "rounding_adjustment",
"fieldtype": "Currency",
"label": "Rounding Adjustment",
@@ -1067,13 +1070,19 @@
"fieldname": "named_place",
"fieldtype": "Data",
"label": "Named Place"
},
{
"default": "0",
"fieldname": "disable_rounded_total",
"fieldtype": "Check",
"label": "Disable Rounded Total"
}
],
"icon": "fa fa-shopping-cart",
"idx": 82,
"is_submittable": 1,
"links": [],
"modified": "2024-03-20 16:04:21.567847",
"modified": "2024-11-07 18:37:11.715189",
"modified_by": "Administrator",
"module": "Selling",
"name": "Quotation",

View File

@@ -61,6 +61,7 @@ class Quotation(SellingController):
customer_address: DF.Link | None
customer_group: DF.Link | None
customer_name: DF.Data | None
disable_rounded_total: DF.Check
discount_amount: DF.Currency
enq_det: DF.Text | None
grand_total: DF.Currency

View File

@@ -715,6 +715,20 @@ class TestQuotation(FrappeTestCase):
item_doc.taxes = []
item_doc.save()
def test_grand_total_and_rounded_total_values(self):
quotation = make_quotation(qty=6, rate=12.3, do_not_submit=1)
self.assertEqual(quotation.grand_total, 73.8)
self.assertEqual(quotation.rounding_adjustment, 0.2)
self.assertEqual(quotation.rounded_total, 74)
quotation.disable_rounded_total = 1
quotation.save()
self.assertEqual(quotation.grand_total, 73.8)
self.assertEqual(quotation.rounding_adjustment, 0)
self.assertEqual(quotation.rounded_total, 0)
test_records = frappe.get_test_records("Quotation")

View File

@@ -1230,7 +1230,10 @@ def get_events(start, end, filters=None):
""",
{"start": start, "end": end},
as_dict=True,
update={"allDay": 0},
update={
"allDay": 0,
"convertToUserTz": 0,
},
)
return data

View File

@@ -8,6 +8,7 @@ frappe.views.calendar["Sales Order"] = {
id: "name",
title: "customer_name",
allDay: "allDay",
convertToUserTz: "convertToUserTz",
},
gantt: true,
filters: [

View File

@@ -272,7 +272,7 @@ erpnext.PointOfSale.ItemDetails = class {
};
this.warehouse_control.df.get_query = () => {
return {
filters: { company: this.events.get_frm().doc.company },
filters: { company: this.events.get_frm().doc.company, is_group: 0 },
};
};
this.warehouse_control.refresh();

View File

@@ -252,6 +252,7 @@ erpnext.company.setup_queries = function (frm) {
["default_expense_account", { root_type: "Expense" }],
["default_income_account", { root_type: "Income" }],
["round_off_account", { root_type: "Expense" }],
["round_off_for_opening", { root_type: "Liability", account_type: "Round Off for Opening" }],
["write_off_account", { root_type: "Expense" }],
["default_deferred_expense_account", {}],
["default_deferred_revenue_account", {}],

View File

@@ -49,6 +49,7 @@
"default_cash_account",
"default_receivable_account",
"round_off_account",
"round_off_for_opening",
"round_off_cost_center",
"write_off_account",
"exchange_gain_loss_account",
@@ -801,6 +802,12 @@
"fieldtype": "Link",
"label": "Default Operating Cost Account",
"options": "Account"
},
{
"fieldname": "round_off_for_opening",
"fieldtype": "Link",
"label": "Round Off for Opening",
"options": "Account"
}
],
"icon": "fa fa-building",
@@ -808,7 +815,7 @@
"image_field": "company_logo",
"is_tree": 1,
"links": [],
"modified": "2024-07-24 18:17:56.413971",
"modified": "2024-08-02 11:34:46.785377",
"modified_by": "Administrator",
"module": "Setup",
"name": "Company",

View File

@@ -91,6 +91,7 @@ class Company(NestedSet):
rgt: DF.Int
round_off_account: DF.Link | None
round_off_cost_center: DF.Link | None
round_off_for_opening: DF.Link | None
sales_monthly_history: DF.SmallText | None
series_for_depreciation_entry: DF.Data | None
stock_adjustment_account: DF.Link | None

View File

@@ -1,30 +1,32 @@
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
// License: GNU General Public License v3. See license.txt
extend_cscript(cur_frm.cscript, {
onload: function () {
if (cur_frm.doc.__islocal) {
cur_frm.set_value("to_currency", frappe.defaults.get_global_default("currency"));
frappe.ui.form.on("Currency Exchange", {
onload: function (frm) {
if (frm.doc.__islocal) {
frm.set_value("to_currency", frappe.defaults.get_global_default("currency"));
}
},
refresh: function () {
cur_frm.cscript.set_exchange_rate_label();
refresh: function (frm) {
// Don't trigger on Quick Entry form
if (typeof frm.is_dialog === "undefined") {
frm.trigger("set_exchange_rate_label");
}
},
from_currency: function () {
cur_frm.cscript.set_exchange_rate_label();
from_currency: function (frm) {
frm.trigger("set_exchange_rate_label");
},
to_currency: function () {
cur_frm.cscript.set_exchange_rate_label();
to_currency: function (frm) {
frm.trigger("set_exchange_rate_label");
},
set_exchange_rate_label: function () {
if (cur_frm.doc.from_currency && cur_frm.doc.to_currency) {
var default_label = __(frappe.meta.docfield_map[cur_frm.doctype]["exchange_rate"].label);
cur_frm.fields_dict.exchange_rate.set_label(
default_label + repl(" (1 %(from_currency)s = [?] %(to_currency)s)", cur_frm.doc)
set_exchange_rate_label: function (frm) {
if (frm.doc.from_currency && frm.doc.to_currency) {
var default_label = __(frappe.meta.docfield_map[frm.doctype]["exchange_rate"].label);
frm.fields_dict.exchange_rate.set_label(
default_label + repl(" (1 %(from_currency)s = [?] %(to_currency)s)", frm.doc)
);
}
},

View File

@@ -71,14 +71,9 @@ class CustomerGroup(NestedSet):
)
def on_update(self):
self.validate_name_with_customer()
super().on_update()
self.validate_one_root()
def validate_name_with_customer(self):
if frappe.db.exists("Customer", self.name):
frappe.msgprint(_("A customer with the same name already exists"), raise_exception=1)
def get_parent_customer_groups(customer_group):
lft, rgt = frappe.db.get_value("Customer Group", customer_group, ["lft", "rgt"])

View File

@@ -87,7 +87,10 @@ def simple_to_detailed(templates):
def from_detailed_data(company_name, data):
"""Create Taxes and Charges Templates from detailed data."""
charts_company_name = company_name
if frappe.db.get_value("Company", company_name, "create_chart_of_accounts_based_on"):
if (
frappe.db.get_value("Company", company_name, "create_chart_of_accounts_based_on")
== "Existing Company"
):
charts_company_name = frappe.db.get_value("Company", company_name, "existing_company")
coa_name = frappe.db.get_value("Company", charts_company_name, "chart_of_accounts")
coa_data = data.get("chart_of_accounts", {})

View File

@@ -15,7 +15,7 @@
"module": "Stock",
"name": "Oldest Items",
"number_of_groups": 0,
"owner": "rohitw1991@gmail.com",
"owner": "Administrator",
"report_name": "Stock Ageing",
"roles": [],
"timeseries": 0,

View File

@@ -107,6 +107,7 @@ class InventoryDimension(Document):
self.source_fieldname,
f"to_{self.source_fieldname}",
f"from_{self.source_fieldname}",
f"rejected_{self.source_fieldname}",
],
)
}
@@ -171,12 +172,12 @@ class InventoryDimension(Document):
if label_start_with:
label = f"{label_start_with} {self.dimension_name}"
return [
dimension_fields = [
dict(
fieldname="inventory_dimension",
fieldtype="Section Break",
insert_after=self.get_insert_after_fieldname(doctype),
label="Inventory Dimension",
label=_("Inventory Dimension"),
collapsible=1,
),
dict(
@@ -184,13 +185,29 @@ class InventoryDimension(Document):
fieldtype="Link",
insert_after="inventory_dimension",
options=self.reference_document,
label=label,
label=_(label),
search_index=1,
reqd=self.reqd,
mandatory_depends_on=self.mandatory_depends_on,
),
]
if doctype in ["Purchase Invoice Item", "Purchase Receipt Item"]:
dimension_fields.append(
dict(
fieldname="rejected_" + self.source_fieldname,
fieldtype="Link",
insert_after=self.source_fieldname,
options=self.reference_document,
label=_("Rejected " + self.dimension_name),
search_index=1,
reqd=self.reqd,
mandatory_depends_on=self.mandatory_depends_on,
)
)
return dimension_fields
def add_custom_fields(self):
custom_fields = {}

View File

@@ -269,21 +269,47 @@ class TestInventoryDimension(FrappeTestCase):
item_code = "Test Inventory Dimension Item"
create_item(item_code)
warehouse = create_warehouse("Store Warehouse")
rj_warehouse = create_warehouse("RJ Warehouse")
if not frappe.db.exists("Store", "Rejected Store"):
frappe.get_doc({"doctype": "Store", "store_name": "Rejected Store"}).insert(
ignore_permissions=True
)
# Purchase Receipt -> Inward in Store 1
pr_doc = make_purchase_receipt(
item_code=item_code, warehouse=warehouse, qty=10, rate=100, do_not_submit=True
item_code=item_code,
warehouse=warehouse,
qty=10,
rejected_qty=5,
rate=100,
rejected_warehouse=rj_warehouse,
do_not_submit=True,
)
pr_doc.items[0].store = "Store 1"
pr_doc.items[0].rejected_store = "Rejected Store"
pr_doc.save()
pr_doc.submit()
entries = get_voucher_sl_entries(pr_doc.name, ["warehouse", "store", "incoming_rate"])
entries = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_no": pr_doc.name, "warehouse": warehouse},
fields=["store"],
order_by="creation",
)
self.assertEqual(entries[0].warehouse, warehouse)
self.assertEqual(entries[0].store, "Store 1")
entries = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_no": pr_doc.name, "warehouse": rj_warehouse},
fields=["store"],
order_by="creation",
)
self.assertEqual(entries[0].store, "Rejected Store")
# Stock Entry -> Transfer from Store 1 to Store 2
se_doc = make_stock_entry(
item_code=item_code, qty=10, from_warehouse=warehouse, to_warehouse=warehouse, do_not_save=True

View File

@@ -749,10 +749,6 @@ class SerialandBatchBundle(Document):
)
def validate_incorrect_serial_nos(self, serial_nos):
if self.voucher_type == "Stock Entry" and self.voucher_no:
if frappe.get_cached_value("Stock Entry", self.voucher_no, "purpose") == "Repack":
return
incorrect_serial_nos = frappe.get_all(
"Serial No",
filters={"name": ("in", serial_nos), "item_code": ("!=", self.item_code)},

View File

@@ -970,61 +970,6 @@ class TestStockEntry(FrappeTestCase):
self.assertRaises(frappe.ValidationError, ste.submit)
def test_same_serial_nos_in_repack_or_manufacture_entries(self):
s1 = make_serialized_item(target_warehouse="_Test Warehouse - _TC")
serial_nos = get_serial_nos_from_bundle(s1.get("items")[0].serial_and_batch_bundle)
s2 = make_stock_entry(
item_code="_Test Serialized Item With Series",
source="_Test Warehouse - _TC",
qty=2,
basic_rate=100,
purpose="Repack",
serial_no=serial_nos,
do_not_save=True,
)
frappe.flags.use_serial_and_batch_fields = True
cls_obj = SerialBatchCreation(
{
"type_of_transaction": "Inward",
"serial_and_batch_bundle": s2.items[0].serial_and_batch_bundle,
"item_code": "_Test Serialized Item",
"warehouse": "_Test Warehouse - _TC",
}
)
cls_obj.duplicate_package()
bundle_id = cls_obj.serial_and_batch_bundle
doc = frappe.get_doc("Serial and Batch Bundle", bundle_id)
doc.db_set(
{
"item_code": "_Test Serialized Item",
"warehouse": "_Test Warehouse - _TC",
}
)
doc.load_from_db()
s2.append(
"items",
{
"item_code": "_Test Serialized Item",
"t_warehouse": "_Test Warehouse - _TC",
"qty": 2,
"basic_rate": 120,
"expense_account": "Stock Adjustment - _TC",
"conversion_factor": 1.0,
"cost_center": "_Test Cost Center - _TC",
"serial_and_batch_bundle": bundle_id,
},
)
s2.submit()
s2.cancel()
frappe.flags.use_serial_and_batch_fields = False
def test_quality_check(self):
item_code = "_Test Item For QC"
if not frappe.db.exists("Item", item_code):

View File

@@ -47,7 +47,23 @@ frappe.query_reports["Stock Ledger Variance"] = {
fieldname: "difference_in",
fieldtype: "Select",
label: __("Difference In"),
options: ["", "Qty", "Value", "Valuation"],
options: [
{
// Check "Stock Ledger Invariant Check" report with A - B column
label: __("Quantity (A - B)"),
value: "Qty",
},
{
// Check "Stock Ledger Invariant Check" report with G - D column
label: __("Value (G - D)"),
value: "Value",
},
{
// Check "Stock Ledger Invariant Check" report with I - K column
label: __("Valuation (I - K)"),
value: "Valuation",
},
],
},
{
fieldname: "include_disabled",

View File

@@ -1,6 +1,8 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import json
import frappe
from frappe import _
from frappe.utils import cint, flt
@@ -270,12 +272,16 @@ def has_difference(row, precision, difference_in, valuation_method):
value_diff = flt(row.diff_value_diff, precision)
valuation_diff = flt(row.valuation_diff, precision)
else:
qty_diff = flt(row.difference_in_qty, precision) or flt(row.fifo_qty_diff, precision)
value_diff = (
flt(row.diff_value_diff, precision)
or flt(row.fifo_value_diff, precision)
or flt(row.fifo_difference_diff, precision)
)
qty_diff = flt(row.difference_in_qty, precision)
value_diff = flt(row.diff_value_diff, precision)
if row.stock_queue and json.loads(row.stock_queue):
value_diff = value_diff or (
flt(row.fifo_value_diff, precision) or flt(row.fifo_difference_diff, precision)
)
qty_diff = qty_diff or flt(row.fifo_qty_diff, precision)
valuation_diff = flt(row.valuation_diff, precision) or flt(row.fifo_valuation_diff, precision)
if difference_in == "Qty" and qty_diff: