Compare commits

..

1 Commits

Author SHA1 Message Date
marination
7e5cf18066 fix: Wrong bank_ac_no filter + simplify convoluted logic 2025-01-07 13:08:07 +05:30
74 changed files with 1979 additions and 1076 deletions

View File

@@ -4,7 +4,7 @@ import inspect
import frappe
from frappe.utils.user import is_website_user
__version__ = "15.48.4"
__version__ = "15.45.4"
def get_default_company(user=None):

View File

@@ -40,13 +40,9 @@
"show_payment_schedule_in_print",
"currency_exchange_section",
"allow_stale",
"column_break_yuug",
"stale_days",
"section_break_jpd0",
"auto_reconcile_payments",
"auto_reconciliation_job_trigger",
"reconciliation_queue_size",
"column_break_resa",
"stale_days",
"invoicing_settings_tab",
"accounts_transactions_settings_section",
"over_billing_allowance",
@@ -493,28 +489,6 @@
"fieldname": "create_pr_in_draft_status",
"fieldtype": "Check",
"label": "Create in Draft Status"
},
{
"fieldname": "column_break_yuug",
"fieldtype": "Column Break"
},
{
"fieldname": "column_break_resa",
"fieldtype": "Column Break"
},
{
"default": "15",
"description": "Interval should be between 1 to 59 MInutes",
"fieldname": "auto_reconciliation_job_trigger",
"fieldtype": "Int",
"label": "Auto Reconciliation Job Trigger"
},
{
"default": "5",
"description": "Documents Processed on each trigger. Queue Size should be between 5 and 100",
"fieldname": "reconciliation_queue_size",
"fieldtype": "Int",
"label": "Reconciliation Queue Size"
}
],
"icon": "icon-cog",
@@ -522,7 +496,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2025-01-13 17:38:39.661320",
"modified": "2024-07-26 06:48:52.714630",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Settings",

View File

@@ -10,7 +10,6 @@ from frappe.custom.doctype.property_setter.property_setter import make_property_
from frappe.model.document import Document
from frappe.utils import cint
from erpnext.accounts.utils import sync_auto_reconcile_config
from erpnext.stock.utils import check_pending_reposting
@@ -28,7 +27,6 @@ class AccountsSettings(Document):
allow_multi_currency_invoices_against_single_party_account: DF.Check
allow_stale: DF.Check
auto_reconcile_payments: DF.Check
auto_reconciliation_job_trigger: DF.Int
automatically_fetch_payment_terms: DF.Check
automatically_process_deferred_accounting_entry: DF.Check
book_asset_depreciation_entry_automatically: DF.Check
@@ -53,7 +51,6 @@ class AccountsSettings(Document):
over_billing_allowance: DF.Currency
post_change_gl_entries: DF.Check
receivable_payable_remarks_length: DF.Int
reconciliation_queue_size: DF.Int
role_allowed_to_over_bill: DF.Link | None
round_row_wise_tax: DF.Check
show_balance_in_coa: DF.Check
@@ -93,8 +90,6 @@ class AccountsSettings(Document):
if clear_cache:
frappe.clear_cache()
self.validate_and_sync_auto_reconcile_config()
def validate_stale_days(self):
if not self.allow_stale and cint(self.stale_days) <= 0:
frappe.msgprint(
@@ -119,17 +114,3 @@ class AccountsSettings(Document):
def validate_pending_reposts(self):
if self.acc_frozen_upto:
check_pending_reposting(self.acc_frozen_upto)
def validate_and_sync_auto_reconcile_config(self):
if self.has_value_changed("auto_reconciliation_job_trigger"):
if (
cint(self.auto_reconciliation_job_trigger) > 0
and cint(self.auto_reconciliation_job_trigger) < 60
):
sync_auto_reconcile_config(self.auto_reconciliation_job_trigger)
else:
frappe.throw(_("Cron Interval should be between 1 and 59 Min"))
if self.has_value_changed("reconciliation_queue_size"):
if cint(self.reconciliation_queue_size) < 5 or cint(self.reconciliation_queue_size) > 100:
frappe.throw(_("Queue Size should be between 5 and 100"))

View File

@@ -120,7 +120,6 @@ frappe.ui.form.on("Bank Reconciliation Tool", {
args: {
bank_account: frm.doc.bank_account,
till_date: frappe.datetime.add_days(frm.doc.bank_statement_from_date, -1),
company: frm.doc.company,
},
callback: (response) => {
frm.set_value("account_opening_balance", response.message);
@@ -136,7 +135,6 @@ frappe.ui.form.on("Bank Reconciliation Tool", {
args: {
bank_account: frm.doc.bank_account,
till_date: frm.doc.bank_statement_to_date,
company: frm.doc.company,
},
callback: (response) => {
frm.cleared_balance = response.message;

View File

@@ -79,17 +79,10 @@ def get_bank_transactions(bank_account, from_date=None, to_date=None):
@frappe.whitelist()
def get_account_balance(bank_account, till_date, company):
def get_account_balance(bank_account, till_date):
# returns account balance till the specified date
account = frappe.db.get_value("Bank Account", bank_account, "account")
filters = frappe._dict(
{
"account": account,
"report_date": till_date,
"include_pos_transactions": 1,
"company": company,
}
)
filters = frappe._dict({"account": account, "report_date": till_date, "include_pos_transactions": 1})
data = get_entries(filters)
balance_as_per_system = get_balance_on(filters["account"], filters["report_date"])
@@ -101,7 +94,11 @@ def get_account_balance(bank_account, till_date, company):
amounts_not_reflected_in_system = get_amounts_not_reflected_in_system(filters)
return flt(balance_as_per_system) - flt(total_debit) + flt(total_credit) + amounts_not_reflected_in_system
bank_bal = (
flt(balance_as_per_system) - flt(total_debit) + flt(total_credit) + amounts_not_reflected_in_system
)
return bank_bal
@frappe.whitelist()

View File

@@ -49,41 +49,39 @@ class AutoMatchbyAccountIBAN:
return result
def match_account_in_party(self) -> tuple | None:
"""Check if there is a IBAN/Account No. match in Customer/Supplier/Employee"""
result = None
parties = get_parties_in_order(self.deposit)
or_filters = self.get_or_filters()
"""
Returns (Party Type, Party) if a matching account is found in Bank Account or Employee:
1. Get party from a matching (iban/account no) Bank Account
2. If not found, get party from Employee with matching bank account details (iban/account no)
"""
if not (self.bank_party_account_number or self.bank_party_iban):
# Nothing to match
return None
for party in parties:
party_result = frappe.db.get_all(
"Bank Account", or_filters=or_filters, pluck="party", limit_page_length=1
)
# Search for a matching Bank Account that has party set
party_result = frappe.db.get_all(
"Bank Account",
or_filters=self.get_or_filters(),
filters={"party_type": ("is", "set"), "party": ("is", "set")},
fields=["party", "party_type"],
limit_page_length=1,
)
if result := party_result[0] if party_result else None:
return (result["party_type"], result["party"])
if party == "Employee" and not party_result:
# Search in Bank Accounts first for Employee, and then Employee record
if "bank_account_no" in or_filters:
or_filters["bank_ac_no"] = or_filters.pop("bank_account_no")
# If no party is found, search in Employee (since it has bank account details)
employee_result = frappe.db.get_all(
"Employee", or_filters=self.get_or_filters("Employee"), pluck="name", limit_page_length=1
)
if employee_result:
return ("Employee", employee_result[0])
party_result = frappe.db.get_all(
party, or_filters=or_filters, pluck="name", limit_page_length=1
)
if "bank_ac_no" in or_filters:
or_filters["bank_account_no"] = or_filters.pop("bank_ac_no")
if party_result:
result = (
party,
party_result[0],
)
break
return result
def get_or_filters(self) -> dict:
def get_or_filters(self, party: str | None = None) -> dict:
"""Return OR filters for Bank Account and IBAN"""
or_filters = {}
if self.bank_party_account_number:
or_filters["bank_account_no"] = self.bank_party_account_number
bank_ac_field = "bank_ac_no" if party == "Employee" else "bank_account_no"
or_filters[bank_ac_field] = self.bank_party_account_number
if self.bank_party_iban:
or_filters["iban"] = self.bank_party_iban

View File

@@ -21,7 +21,6 @@
"party_name",
"book_advance_payments_in_separate_party_account",
"reconcile_on_advance_payment_date",
"advance_reconciliation_takes_effect_on",
"column_break_11",
"bank_account",
"party_bank_account",
@@ -783,16 +782,6 @@
"options": "No\nYes",
"print_hide": 1,
"search_index": 1
},
{
"default": "Oldest Of Invoice Or Advance",
"fetch_from": "company.reconciliation_takes_effect_on",
"fieldname": "advance_reconciliation_takes_effect_on",
"fieldtype": "Select",
"hidden": 1,
"label": "Advance Reconciliation Takes Effect On",
"no_copy": 1,
"options": "Advance Payment Date\nOldest Of Invoice Or Advance\nReconciliation Date"
}
],
"index_web_pages_for_search": 1,
@@ -806,7 +795,7 @@
"table_fieldname": "payment_entries"
}
],
"modified": "2025-01-13 16:03:47.169699",
"modified": "2024-11-07 11:19:19.320883",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Entry",

View File

@@ -1401,26 +1401,16 @@ class PaymentEntry(AccountsController):
"voucher_detail_no": invoice.name,
}
if invoice.reconcile_effect_on:
posting_date = invoice.reconcile_effect_on
if self.reconcile_on_advance_payment_date:
posting_date = self.posting_date
else:
# For backwards compatibility
# Supporting reposting on payment entries reconciled before select field introduction
if self.advance_reconciliation_takes_effect_on == "Advance Payment Date":
posting_date = self.posting_date
elif self.advance_reconciliation_takes_effect_on == "Oldest Of Invoice Or Advance":
date_field = "posting_date"
if invoice.reference_doctype in ["Sales Order", "Purchase Order"]:
date_field = "transaction_date"
posting_date = frappe.db.get_value(
invoice.reference_doctype, invoice.reference_name, date_field
)
date_field = "posting_date"
if invoice.reference_doctype in ["Sales Order", "Purchase Order"]:
date_field = "transaction_date"
posting_date = frappe.db.get_value(invoice.reference_doctype, invoice.reference_name, date_field)
if getdate(posting_date) < getdate(self.posting_date):
posting_date = self.posting_date
elif self.advance_reconciliation_takes_effect_on == "Reconciliation Date":
posting_date = nowdate()
frappe.db.set_value("Payment Entry Reference", invoice.name, "reconcile_effect_on", posting_date)
if getdate(posting_date) < getdate(self.posting_date):
posting_date = self.posting_date
dr_or_cr, account = self.get_dr_and_account_for_advances(invoice)
args_dict["account"] = account

View File

@@ -13,7 +13,6 @@
"payment_term_outstanding",
"account_type",
"payment_type",
"reconcile_effect_on",
"column_break_4",
"total_amount",
"outstanding_amount",
@@ -145,18 +144,12 @@
"is_virtual": 1,
"label": "Payment Request Outstanding",
"read_only": 1
},
{
"fieldname": "reconcile_effect_on",
"fieldtype": "Date",
"label": "Reconcile Effect On",
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-01-13 15:56:18.895082",
"modified": "2024-09-16 18:11:50.019343",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Entry Reference",

View File

@@ -30,7 +30,6 @@ class PaymentEntryReference(Document):
payment_term: DF.Link | None
payment_term_outstanding: DF.Float
payment_type: DF.Data | None
reconcile_effect_on: DF.Date | None
reference_doctype: DF.Link
reference_name: DF.DynamicLink
total_amount: DF.Float

View File

@@ -6,7 +6,6 @@ import frappe
from frappe import qb
from frappe.tests.utils import FrappeTestCase, change_settings
from frappe.utils import add_days, add_years, flt, getdate, nowdate, today
from frappe.utils.data import getdate as convert_to_date
from erpnext import get_default_cost_center
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
@@ -1672,7 +1671,7 @@ class TestPaymentReconciliation(FrappeTestCase):
{
"book_advance_payments_in_separate_party_account": 1,
"default_advance_paid_account": self.advance_payable_account,
"reconciliation_takes_effect_on": "Advance Payment Date",
"reconcile_on_advance_payment_date": 1,
},
)
@@ -1721,7 +1720,7 @@ class TestPaymentReconciliation(FrappeTestCase):
{
"book_advance_payments_in_separate_party_account": 1,
"default_advance_received_account": self.advance_receivable_account,
"reconciliation_takes_effect_on": "Oldest Of Invoice Or Advance",
"reconcile_on_advance_payment_date": 0,
},
)
amount = 200.0
@@ -1830,7 +1829,7 @@ class TestPaymentReconciliation(FrappeTestCase):
{
"book_advance_payments_in_separate_party_account": 1,
"default_advance_paid_account": self.advance_payable_account,
"reconciliation_takes_effect_on": "Oldest Of Invoice Or Advance",
"reconcile_on_advance_payment_date": 0,
},
)
amount = 200.0
@@ -2049,102 +2048,6 @@ class TestPaymentReconciliation(FrappeTestCase):
self.assertEqual(pr.get("invoices"), [])
self.assertEqual(pr.get("payments"), [])
def test_advance_reconciliation_effect_on_same_date(self):
frappe.db.set_value(
"Company",
self.company,
{
"book_advance_payments_in_separate_party_account": 1,
"default_advance_received_account": self.advance_receivable_account,
"reconciliation_takes_effect_on": "Reconciliation Date",
},
)
inv_date = convert_to_date(add_days(nowdate(), -1))
adv_date = convert_to_date(add_days(nowdate(), -2))
si = self.create_sales_invoice(posting_date=inv_date, qty=1, rate=200)
pe = self.create_payment_entry(posting_date=adv_date, amount=80).save().submit()
pr = self.create_payment_reconciliation()
pr.from_invoice_date = add_days(nowdate(), -1)
pr.to_invoice_date = nowdate()
pr.from_payment_date = add_days(nowdate(), -2)
pr.to_payment_date = nowdate()
pr.default_advance_account = self.advance_receivable_account
# reconcile multiple payments against invoice
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()
si.reload()
self.assertEqual(si.status, "Partly Paid")
# check PR tool output post reconciliation
self.assertEqual(len(pr.get("invoices")), 1)
self.assertEqual(pr.get("invoices")[0].get("outstanding_amount"), 120)
self.assertEqual(pr.get("payments"), [])
# Assert Ledger Entries
gl_entries = frappe.db.get_all(
"GL Entry",
filters={"voucher_no": pe.name},
fields=["account", "posting_date", "voucher_no", "against_voucher", "debit", "credit"],
order_by="account, against_voucher, debit",
)
expected_gl = [
{
"account": self.advance_receivable_account,
"posting_date": adv_date,
"voucher_no": pe.name,
"against_voucher": pe.name,
"debit": 0.0,
"credit": 80.0,
},
{
"account": self.advance_receivable_account,
"posting_date": convert_to_date(nowdate()),
"voucher_no": pe.name,
"against_voucher": pe.name,
"debit": 80.0,
"credit": 0.0,
},
{
"account": self.debit_to,
"posting_date": convert_to_date(nowdate()),
"voucher_no": pe.name,
"against_voucher": si.name,
"debit": 0.0,
"credit": 80.0,
},
{
"account": self.bank,
"posting_date": adv_date,
"voucher_no": pe.name,
"against_voucher": None,
"debit": 80.0,
"credit": 0.0,
},
]
self.assertEqual(expected_gl, gl_entries)
# cancel PE
pe.reload()
pe.cancel()
pr.get_unreconciled_entries()
# check PR tool output
self.assertEqual(len(pr.get("invoices")), 1)
self.assertEqual(len(pr.get("payments")), 0)
self.assertEqual(pr.get("invoices")[0].get("outstanding_amount"), 200)
def make_customer(customer_name, currency=None):
if not frappe.db.exists("Customer", customer_name):

View File

@@ -1,6 +1,7 @@
{
"actions": [],
"autoname": "format:ACC-PPR-{#####}",
"beta": 1,
"creation": "2023-03-30 21:28:39.793927",
"default_view": "List",
"doctype": "DocType",
@@ -157,7 +158,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2025-01-08 08:22:14.798085",
"modified": "2024-08-27 14:48:56.715320",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Process Payment Reconciliation",
@@ -191,4 +192,4 @@
"sort_order": "DESC",
"states": [],
"title_field": "company"
}
}

View File

@@ -210,9 +210,9 @@ def trigger_reconciliation_for_queued_docs():
docs_to_trigger = []
unique_filters = set()
queue_size = frappe.db.get_single_value("Accounts Settings", "reconciliation_queue_size") or 5
queue_size = 5
fields = ["company", "party_type", "party", "receivable_payable_account", "default_advance_account"]
fields = ["company", "party_type", "party", "receivable_payable_account"]
def get_filters_as_tuple(fields, doc):
filters = ()

View File

@@ -1,6 +1,7 @@
{
"actions": [],
"autoname": "format:PPR-LOG-{##}",
"beta": 1,
"creation": "2023-03-13 15:00:09.149681",
"default_view": "List",
"doctype": "DocType",
@@ -109,7 +110,7 @@
"in_create": 1,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-01-08 08:22:19.104975",
"modified": "2023-11-02 11:32:12.254018",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Process Payment Reconciliation Log",

View File

@@ -1623,7 +1623,7 @@
{
"default": "1",
"depends_on": "eval: doc.is_return && doc.return_against",
"description": "Debit Note will update it's own outstanding amount, even if 'Return Against' is specified.",
"description": "Debit Note will update it's own outstanding amount, even if \"Return Against\" is specified.",
"fieldname": "update_outstanding_for_self",
"fieldtype": "Check",
"label": "Update Outstanding for Self"
@@ -1633,7 +1633,7 @@
"idx": 204,
"is_submittable": 1,
"links": [],
"modified": "2025-01-14 11:39:04.564610",
"modified": "2024-10-25 18:13:01.944477",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice",

View File

@@ -994,51 +994,47 @@ frappe.ui.form.on("Sales Invoice", {
refresh: function (frm) {
if (frm.doc.docstatus === 0 && !frm.doc.is_return) {
frm.add_custom_button(
__("Timesheet"),
function () {
let d = new frappe.ui.Dialog({
title: __("Fetch Timesheet"),
fields: [
{
label: __("From"),
fieldname: "from_time",
fieldtype: "Date",
reqd: 1,
},
{
fieldtype: "Column Break",
fieldname: "col_break_1",
},
{
label: __("To"),
fieldname: "to_time",
fieldtype: "Date",
reqd: 1,
},
{
label: __("Project"),
fieldname: "project",
fieldtype: "Link",
options: "Project",
default: frm.doc.project,
},
],
primary_action: function () {
const data = d.get_values();
frm.events.add_timesheet_data(frm, {
from_time: data.from_time,
to_time: data.to_time,
project: data.project,
});
d.hide();
frm.add_custom_button(__("Fetch Timesheet"), function () {
let d = new frappe.ui.Dialog({
title: __("Fetch Timesheet"),
fields: [
{
label: __("From"),
fieldname: "from_time",
fieldtype: "Date",
reqd: 1,
},
primary_action_label: __("Get Timesheets"),
});
d.show();
},
__("Get Items From")
);
{
fieldtype: "Column Break",
fieldname: "col_break_1",
},
{
label: __("To"),
fieldname: "to_time",
fieldtype: "Date",
reqd: 1,
},
{
label: __("Project"),
fieldname: "project",
fieldtype: "Link",
options: "Project",
default: frm.doc.project,
},
],
primary_action: function () {
const data = d.get_values();
frm.events.add_timesheet_data(frm, {
from_time: data.from_time,
to_time: data.to_time,
project: data.project,
});
d.hide();
},
primary_action_label: __("Get Timesheets"),
});
d.show();
});
}
if (frm.doc.is_debit_note) {

View File

@@ -2161,7 +2161,7 @@
{
"default": "1",
"depends_on": "eval: doc.is_return && doc.return_against",
"description": "Credit Note will update it's own outstanding amount, even if 'Return Against' is specified.",
"description": "Credit Note will update it's own outstanding amount, even if \"Return Against\" is specified.",
"fieldname": "update_outstanding_for_self",
"fieldtype": "Check",
"label": "Update Outstanding for Self",
@@ -2186,7 +2186,7 @@
"link_fieldname": "consolidated_invoice"
}
],
"modified": "2025-01-14 11:38:30.446370",
"modified": "2024-11-26 12:34:09.110690",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice",

View File

@@ -39,7 +39,7 @@ from erpnext.controllers.selling_controller import SellingController
from erpnext.projects.doctype.timesheet.timesheet import get_projectwise_timesheet_data
from erpnext.setup.doctype.company.company import update_company_current_month_sales
from erpnext.stock.doctype.delivery_note.delivery_note import update_billed_amount_based_on_so
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
from erpnext.stock.doctype.serial_no.serial_no import get_delivery_note_serial_no, get_serial_nos
form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
@@ -321,7 +321,9 @@ class SalesInvoice(SellingController):
self.set_against_income_account()
self.validate_time_sheets_are_submitted()
self.validate_multiple_billing("Delivery Note", "dn_detail", "amount")
if self.is_return:
if not self.is_return:
self.validate_serial_numbers()
else:
self.timesheets = []
self.update_packing_list()
self.set_billing_hours_and_amount()
@@ -365,7 +367,7 @@ class SalesInvoice(SellingController):
if self.update_stock:
frappe.throw(_("'Update Stock' cannot be checked for fixed asset sale"))
elif asset.status in ("Scrapped", "Cancelled", "Capitalized") or (
elif asset.status in ("Scrapped", "Cancelled", "Capitalized", "Decapitalized") or (
asset.status == "Sold" and not self.is_return
):
frappe.throw(
@@ -1704,6 +1706,53 @@ class SalesInvoice(SellingController):
self.set("write_off_amount", reference_doc.get("write_off_amount"))
self.due_date = None
def validate_serial_numbers(self):
"""
validate serial number agains Delivery Note and Sales Invoice
"""
self.set_serial_no_against_delivery_note()
self.validate_serial_against_delivery_note()
def set_serial_no_against_delivery_note(self):
for item in self.items:
if item.serial_no and item.delivery_note and item.qty != len(get_serial_nos(item.serial_no)):
item.serial_no = get_delivery_note_serial_no(item.item_code, item.qty, item.delivery_note)
def validate_serial_against_delivery_note(self):
"""
validate if the serial numbers in Sales Invoice Items are same as in
Delivery Note Item
"""
for item in self.items:
if not item.delivery_note or not item.dn_detail:
continue
serial_nos = frappe.db.get_value("Delivery Note Item", item.dn_detail, "serial_no") or ""
dn_serial_nos = set(get_serial_nos(serial_nos))
serial_nos = item.serial_no or ""
si_serial_nos = set(get_serial_nos(serial_nos))
serial_no_diff = si_serial_nos - dn_serial_nos
if serial_no_diff:
dn_link = frappe.utils.get_link_to_form("Delivery Note", item.delivery_note)
serial_no_msg = ", ".join(frappe.bold(d) for d in serial_no_diff)
msg = _("Row #{0}: The following Serial Nos are not present in Delivery Note {1}:").format(
item.idx, dn_link
)
msg += " " + serial_no_msg
frappe.throw(msg=msg, title=_("Serial Nos Mismatch"))
if item.serial_no and cint(item.qty) != len(si_serial_nos):
frappe.throw(
_("Row #{0}: {1} Serial numbers required for Item {2}. You have provided {3}.").format(
item.idx, item.qty, item.item_code, len(si_serial_nos)
)
)
def update_project(self):
unique_projects = list(set([d.project for d in self.get("items") if d.project]))
if self.project and self.project not in unique_projects:

View File

@@ -697,7 +697,7 @@ class Subscription(Document):
self.status = "Cancelled"
self.cancelation_date = nowdate()
if to_generate_invoice and self.cancelation_date >= self.current_invoice_start:
if to_generate_invoice:
self.generate_invoice(self.current_invoice_start, self.cancelation_date)
self.save()

View File

@@ -555,11 +555,9 @@ def get_tds_amount(ldc, parties, inv, tax_details, vouchers):
else:
tax_withholding_net_total = inv.get("tax_withholding_net_total", 0)
has_cumulative_threshold_breached = (
if (threshold and tax_withholding_net_total >= threshold) or (
cumulative_threshold and (supp_credit_amt + supp_inv_credit_amt) >= cumulative_threshold
)
if (threshold and tax_withholding_net_total >= threshold) or (has_cumulative_threshold_breached):
):
# Get net total again as TDS is calculated on net total
# Grand is used to just check for threshold breach
net_total = (
@@ -567,7 +565,9 @@ def get_tds_amount(ldc, parties, inv, tax_details, vouchers):
)
supp_credit_amt += net_total
if has_cumulative_threshold_breached and cint(tax_details.tax_on_excess_amount):
if (cumulative_threshold and supp_credit_amt >= cumulative_threshold) and cint(
tax_details.tax_on_excess_amount
):
supp_credit_amt = net_total + tax_withholding_net_total - cumulative_threshold
if ldc and is_valid_certificate(ldc, inv.get("posting_date") or inv.get("transaction_date"), 0):

View File

@@ -318,7 +318,7 @@ def get_columns(additional_table_columns, filters):
"width": 100,
},
{
"label": _("Rate"),
"label": _("Tax Rate"),
"fieldname": "rate",
"fieldtype": "Float",
"options": "currency",

View File

@@ -712,23 +712,6 @@ def update_reference_in_payment_entry(
}
update_advance_paid = []
# Update Reconciliation effect date in reference
if payment_entry.book_advance_payments_in_separate_party_account:
if payment_entry.advance_reconciliation_takes_effect_on == "Advance Payment Date":
reconcile_on = payment_entry.posting_date
elif payment_entry.advance_reconciliation_takes_effect_on == "Oldest Of Invoice Or Advance":
date_field = "posting_date"
if d.against_voucher_type in ["Sales Order", "Purchase Order"]:
date_field = "transaction_date"
reconcile_on = frappe.db.get_value(d.against_voucher_type, d.against_voucher, date_field)
if getdate(reconcile_on) < getdate(payment_entry.posting_date):
reconcile_on = payment_entry.posting_date
elif payment_entry.advance_reconciliation_takes_effect_on == "Reconciliation Date":
reconcile_on = nowdate()
reference_details.update({"reconcile_effect_on": reconcile_on})
if d.voucher_detail_no:
existing_row = payment_entry.get("references", {"name": d["voucher_detail_no"]})[0]
@@ -2250,38 +2233,3 @@ def run_ledger_health_checks():
doc.general_and_payment_ledger_mismatch = True
doc.checked_on = run_date
doc.save()
def sync_auto_reconcile_config(auto_reconciliation_job_trigger: int = 15):
auto_reconciliation_job_trigger = auto_reconciliation_job_trigger or frappe.db.get_single_value(
"Accounts Settings", "auto_reconciliation_job_trigger"
)
method = "erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation.trigger_reconciliation_for_queued_docs"
sch_event = frappe.get_doc(
"Scheduler Event", {"scheduled_against": "Process Payment Reconciliation", "method": method}
)
if frappe.db.get_value("Scheduled Job Type", {"method": method}):
frappe.get_doc(
"Scheduled Job Type",
{
"method": method,
},
).update(
{
"cron_format": f"0/{auto_reconciliation_job_trigger} * * * *",
"scheduler_event": sch_event.name,
}
).save()
else:
frappe.get_doc(
{
"doctype": "Scheduled Job Type",
"method": method,
"scheduler_event": sch_event.name,
"cron_format": f"0/{auto_reconciliation_job_trigger} * * * *",
"create_log": True,
"stopped": False,
"frequency": "Cron",
}
).save()

View File

@@ -378,7 +378,7 @@
"in_standard_filter": 1,
"label": "Status",
"no_copy": 1,
"options": "Draft\nSubmitted\nPartially Depreciated\nFully Depreciated\nSold\nScrapped\nIn Maintenance\nOut of Order\nIssue\nReceipt\nCapitalized\nWork In Progress",
"options": "Draft\nSubmitted\nPartially Depreciated\nFully Depreciated\nSold\nScrapped\nIn Maintenance\nOut of Order\nIssue\nReceipt\nCapitalized\nDecapitalized",
"read_only": 1
},
{
@@ -595,7 +595,7 @@
"link_fieldname": "target_asset"
}
],
"modified": "2024-12-26 14:23:20.968882",
"modified": "2024-08-26 23:28:29.095139",
"modified_by": "Administrator",
"module": "Assets",
"name": "Asset",

View File

@@ -111,7 +111,7 @@ class Asset(AccountsController):
"Issue",
"Receipt",
"Capitalized",
"Work In Progress",
"Decapitalized",
]
supplier: DF.Link | None
total_asset_cost: DF.Currency

View File

@@ -1,6 +1,5 @@
frappe.listview_settings["Asset"] = {
add_fields: ["status", "docstatus"],
has_indicator_for_draft: 1,
add_fields: ["status"],
get_indicator: function (doc) {
if (doc.status === "Fully Depreciated") {
return [__("Fully Depreciated"), "green", "status,=,Fully Depreciated"];
@@ -8,10 +7,8 @@ frappe.listview_settings["Asset"] = {
return [__("Partially Depreciated"), "grey", "status,=,Partially Depreciated"];
} else if (doc.status === "Sold") {
return [__("Sold"), "green", "status,=,Sold"];
} else if (doc.status === "Work In Progress") {
return [__("Work In Progress"), "orange", "status,=,Work In Progress"];
} else if (doc.status === "Capitalized") {
return [__("Capitalized"), "grey", "status,=,Capitalized"];
} else if (["Capitalized", "Decapitalized"].includes(doc.status)) {
return [__(doc.status), "grey", "status,=," + doc.status];
} else if (doc.status === "Scrapped") {
return [__("Scrapped"), "grey", "status,=,Scrapped"];
} else if (doc.status === "In Maintenance") {
@@ -24,7 +21,7 @@ frappe.listview_settings["Asset"] = {
return [__("Receipt"), "green", "status,=,Receipt"];
} else if (doc.status === "Submitted") {
return [__("Submitted"), "blue", "status,=,Submitted"];
} else if (doc.status === "Draft" || doc.docstatus === 0) {
} else if (doc.status === "Draft") {
return [__("Draft"), "red", "status,=,Draft"];
}
},

View File

@@ -436,7 +436,7 @@ def scrap_asset(asset_name):
if asset.docstatus != 1:
frappe.throw(_("Asset {0} must be submitted").format(asset.name))
elif asset.status in ("Cancelled", "Sold", "Scrapped", "Capitalized"):
elif asset.status in ("Cancelled", "Sold", "Scrapped", "Capitalized", "Decapitalized"):
frappe.throw(_("Asset {0} cannot be scrapped, as it is already {1}").format(asset.name, asset.status))
date = today()

View File

@@ -1725,10 +1725,6 @@ def create_asset(**args):
},
)
if asset.is_composite_asset:
asset.gross_purchase_amount = 0
asset.purchase_amount = 0
if not args.do_not_save:
try:
asset.insert(ignore_if_duplicate=True)

View File

@@ -36,7 +36,11 @@ erpnext.assets.AssetCapitalization = class AssetCapitalization extends erpnext.s
me.setup_warehouse_query();
me.frm.set_query("target_item_code", function () {
return erpnext.queries.item({ is_stock_item: 0, is_fixed_asset: 1 });
if (me.frm.doc.entry_type == "Capitalization") {
return erpnext.queries.item({ is_stock_item: 0, is_fixed_asset: 1 });
} else {
return erpnext.queries.item({ is_stock_item: 1, is_fixed_asset: 0 });
}
});
me.frm.set_query("target_asset", function () {
@@ -47,7 +51,7 @@ erpnext.assets.AssetCapitalization = class AssetCapitalization extends erpnext.s
me.frm.set_query("asset", "asset_items", function () {
var filters = {
status: ["not in", ["Draft", "Scrapped", "Sold", "Capitalized"]],
status: ["not in", ["Draft", "Scrapped", "Sold", "Capitalized", "Decapitalized"]],
docstatus: 1,
};

View File

@@ -8,26 +8,30 @@
"engine": "InnoDB",
"field_order": [
"title",
"naming_series",
"capitalization_method",
"target_item_code",
"target_item_name",
"target_asset",
"target_asset_name",
"target_qty",
"target_asset_location",
"column_break_9",
"company",
"posting_date",
"posting_time",
"set_posting_time",
"finance_book",
"target_batch_no",
"target_serial_no",
"amended_from",
"naming_series",
"entry_type",
"target_item_name",
"target_is_fixed_asset",
"target_has_batch_no",
"target_has_serial_no",
"column_break_9",
"capitalization_method",
"target_item_code",
"target_asset_location",
"target_asset",
"target_asset_name",
"target_warehouse",
"target_qty",
"target_stock_uom",
"target_batch_no",
"target_serial_no",
"column_break_5",
"finance_book",
"posting_date",
"posting_time",
"set_posting_time",
"amended_from",
"section_break_16",
"stock_items",
"stock_items_total",
@@ -54,12 +58,12 @@
"label": "Title"
},
{
"depends_on": "eval:(doc.target_item_code && !doc.__islocal && doc.capitalization_method !== 'Choose a WIP composite asset') || doc.capitalization_method=='Create a new composite asset'",
"depends_on": "eval:(doc.target_item_code && !doc.__islocal && doc.capitalization_method !== 'Choose a WIP composite asset') || ((doc.entry_type=='Capitalization' && doc.capitalization_method=='Create a new composite asset') || doc.entry_type=='Decapitalization')",
"fieldname": "target_item_code",
"fieldtype": "Link",
"in_standard_filter": 1,
"label": "Target Item Code",
"mandatory_depends_on": "eval:doc.capitalization_method=='Create a new composite asset'",
"mandatory_depends_on": "eval:(doc.entry_type=='Capitalization' && doc.capitalization_method=='Create a new composite asset') || doc.entry_type=='Decapitalization'",
"options": "Item"
},
{
@@ -80,18 +84,22 @@
"read_only": 1
},
{
"depends_on": "eval:(doc.target_asset && !doc.__islocal) || doc.capitalization_method=='Choose a WIP composite asset'",
"fieldname": "column_break_5",
"fieldtype": "Column Break"
},
{
"depends_on": "eval:(doc.target_asset && !doc.__islocal) || (doc.entry_type=='Capitalization' && doc.capitalization_method=='Choose a WIP composite asset')",
"fieldname": "target_asset",
"fieldtype": "Link",
"in_standard_filter": 1,
"label": "Target Asset",
"mandatory_depends_on": "eval:doc.capitalization_method=='Choose a WIP composite asset'",
"mandatory_depends_on": "eval:doc.entry_type=='Capitalization' && doc.capitalization_method=='Choose a WIP composite asset'",
"no_copy": 1,
"options": "Asset",
"read_only_depends_on": "eval:doc.capitalization_method=='Create a new composite asset'"
"read_only_depends_on": "eval:(doc.entry_type=='Decapitalization') || (doc.entry_type=='Capitalization' && doc.capitalization_method=='Create a new composite asset')"
},
{
"depends_on": "eval:(doc.target_asset_name && !doc.__islocal) || (doc.target_asset && doc.capitalization_method=='Choose a WIP composite asset')",
"depends_on": "eval:(doc.target_asset_name && !doc.__islocal) || (doc.target_asset && doc.entry_type=='Capitalization' && doc.capitalization_method=='Choose a WIP composite asset')",
"fetch_from": "target_asset.asset_name",
"fieldname": "target_asset_name",
"fieldtype": "Data",
@@ -154,7 +162,7 @@
"read_only": 1
},
{
"depends_on": "eval:doc.docstatus == 0 || (doc.stock_items && doc.stock_items.length)",
"depends_on": "eval:doc.entry_type=='Capitalization' && (doc.docstatus == 0 || (doc.stock_items && doc.stock_items.length))",
"fieldname": "section_break_16",
"fieldtype": "Section Break",
"label": "Consumed Stock Items"
@@ -165,6 +173,14 @@
"label": "Stock Items",
"options": "Asset Capitalization Stock Item"
},
{
"depends_on": "eval:doc.entry_type=='Decapitalization'",
"fieldname": "target_warehouse",
"fieldtype": "Link",
"label": "Target Warehouse",
"mandatory_depends_on": "eval:doc.entry_type=='Decapitalization'",
"options": "Warehouse"
},
{
"depends_on": "target_has_batch_no",
"fieldname": "target_batch_no",
@@ -174,9 +190,20 @@
},
{
"default": "1",
"depends_on": "eval:doc.entry_type=='Decapitalization'",
"fieldname": "target_qty",
"fieldtype": "Float",
"label": "Target Qty"
"label": "Target Qty",
"read_only_depends_on": "eval:doc.entry_type=='Capitalization'"
},
{
"depends_on": "eval:doc.entry_type=='Decapitalization'",
"fetch_from": "target_item_code.stock_uom",
"fieldname": "target_stock_uom",
"fieldtype": "Link",
"label": "Stock UOM",
"options": "UOM",
"read_only": 1
},
{
"default": "0",
@@ -214,6 +241,16 @@
"label": "Assets",
"options": "Asset Capitalization Asset Item"
},
{
"default": "Capitalization",
"fieldname": "entry_type",
"fieldtype": "Select",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Entry Type",
"options": "Capitalization\nDecapitalization",
"reqd": 1
},
{
"fieldname": "stock_items_total",
"fieldtype": "Currency",
@@ -235,7 +272,7 @@
"options": "Finance Book"
},
{
"depends_on": "eval:doc.docstatus == 0 || (doc.service_items && doc.service_items.length)",
"depends_on": "eval:doc.entry_type=='Capitalization' && (doc.docstatus == 0 || (doc.service_items && doc.service_items.length))",
"fieldname": "service_expenses_section",
"fieldtype": "Section Break",
"label": "Service Expenses"
@@ -300,24 +337,26 @@
"read_only": 1
},
{
"depends_on": "eval:doc.capitalization_method=='Create a new composite asset'",
"depends_on": "eval:doc.entry_type=='Capitalization' && doc.capitalization_method=='Create a new composite asset'",
"fieldname": "target_asset_location",
"fieldtype": "Link",
"label": "Target Asset Location",
"mandatory_depends_on": "eval:doc.capitalization_method=='Create a new composite asset'",
"mandatory_depends_on": "eval:doc.entry_type=='Capitalization' && doc.capitalization_method=='Create a new composite asset'",
"options": "Location"
},
{
"depends_on": "eval:doc.entry_type=='Capitalization'",
"fieldname": "capitalization_method",
"fieldtype": "Select",
"label": "Capitalization Method",
"mandatory_depends_on": "eval:doc.entry_type=='Capitalization'",
"options": "\nCreate a new composite asset\nChoose a WIP composite asset"
}
],
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2025-01-08 13:14:33.008458",
"modified": "2023-10-03 22:55:59.461456",
"modified_by": "Administrator",
"module": "Assets",
"name": "Asset Capitalization",
@@ -361,4 +400,4 @@
"title_field": "title",
"track_changes": 1,
"track_seen": 1
}
}

View File

@@ -41,6 +41,7 @@ force_fields = [
"target_is_fixed_asset",
"target_has_serial_no",
"target_has_batch_no",
"target_stock_uom",
"stock_uom",
"fixed_asset_account",
"valuation_rate",
@@ -72,6 +73,7 @@ class AssetCapitalization(StockController):
capitalization_method: DF.Literal["", "Create a new composite asset", "Choose a WIP composite asset"]
company: DF.Link
cost_center: DF.Link | None
entry_type: DF.Literal["Capitalization", "Decapitalization"]
finance_book: DF.Link | None
naming_series: DF.Literal["ACC-ASC-.YYYY.-"]
posting_date: DF.Date
@@ -94,6 +96,8 @@ class AssetCapitalization(StockController):
target_item_name: DF.Data | None
target_qty: DF.Float
target_serial_no: DF.SmallText | None
target_stock_uom: DF.Link | None
target_warehouse: DF.Link | None
title: DF.Data | None
total_value: DF.Currency
# end: auto-generated types
@@ -186,18 +190,31 @@ class AssetCapitalization(StockController):
def validate_target_item(self):
target_item = frappe.get_cached_doc("Item", self.target_item_code)
if not target_item.is_fixed_asset:
if not target_item.is_fixed_asset and not target_item.is_stock_item:
frappe.throw(
_("Target Item {0} is neither a Fixed Asset nor a Stock Item").format(target_item.name)
)
if self.entry_type == "Capitalization" and not target_item.is_fixed_asset:
frappe.throw(_("Target Item {0} must be a Fixed Asset item").format(target_item.name))
elif self.entry_type == "Decapitalization" and not target_item.is_stock_item:
frappe.throw(_("Target Item {0} must be a Stock Item").format(target_item.name))
if target_item.is_fixed_asset:
self.target_qty = 1
if flt(self.target_qty) <= 0:
frappe.throw(_("Target Qty must be a positive number"))
if not target_item.is_stock_item:
self.target_warehouse = None
if not target_item.has_batch_no:
self.target_batch_no = None
if not target_item.has_serial_no:
self.target_serial_no = ""
if target_item.is_stock_item and not self.target_warehouse:
frappe.throw(_("Target Warehouse is mandatory for Decapitalization"))
self.validate_item(target_item)
def validate_target_asset(self):
@@ -214,7 +231,7 @@ class AssetCapitalization(StockController):
)
)
if target_asset.status in ("Scrapped", "Sold", "Capitalized"):
if target_asset.status in ("Scrapped", "Sold", "Capitalized", "Decapitalized"):
frappe.throw(
_("Target Asset {0} cannot be {1}").format(target_asset.name, target_asset.status)
)
@@ -256,7 +273,7 @@ class AssetCapitalization(StockController):
asset = self.get_asset_for_validation(d.asset)
if asset.status in ("Draft", "Scrapped", "Sold", "Capitalized"):
if asset.status in ("Draft", "Scrapped", "Sold", "Capitalized", "Decapitalized"):
frappe.throw(
_("Row #{0}: Consumed Asset {1} cannot be {2}").format(
d.idx, asset.name, asset.status
@@ -297,6 +314,9 @@ class AssetCapitalization(StockController):
d.cost_center = frappe.get_cached_value("Company", self.company, "cost_center")
def validate_source_mandatory(self):
if not self.target_is_fixed_asset and not self.get("asset_items"):
frappe.throw(_("Consumed Asset Items is mandatory for Decapitalization"))
if self.capitalization_method == "Create a new composite asset" and not (
self.get("stock_items") or self.get("asset_items")
):
@@ -400,6 +420,18 @@ class AssetCapitalization(StockController):
)
sl_entries.append(sle)
if self.entry_type == "Decapitalization" and not self.target_is_fixed_asset:
sle = self.get_sl_entries(
self,
{
"item_code": self.target_item_code,
"warehouse": self.target_warehouse,
"actual_qty": flt(self.target_qty),
"incoming_rate": flt(self.target_incoming_rate),
},
)
sl_entries.append(sle)
# reverse sl entries if cancel
if self.docstatus == 2:
sl_entries.reverse()
@@ -442,18 +474,21 @@ class AssetCapitalization(StockController):
return gl_entries
def get_target_account(self):
from erpnext.assets.doctype.asset.asset import is_cwip_accounting_enabled
if self.target_is_fixed_asset:
from erpnext.assets.doctype.asset.asset import is_cwip_accounting_enabled
asset_category = frappe.get_cached_value("Asset", self.target_asset, "asset_category")
if is_cwip_accounting_enabled(asset_category):
target_account = get_asset_category_account(
"capital_work_in_progress_account",
asset_category=asset_category,
company=self.company,
)
return target_account if target_account else self.target_fixed_asset_account
asset_category = frappe.get_cached_value("Asset", self.target_asset, "asset_category")
if is_cwip_accounting_enabled(asset_category):
target_account = get_asset_category_account(
"capital_work_in_progress_account",
asset_category=asset_category,
company=self.company,
)
return target_account if target_account else self.target_fixed_asset_account
else:
return self.target_fixed_asset_account
else:
return self.target_fixed_asset_account
return self.warehouse_account[self.target_warehouse]["account"]
def get_gl_entries_for_consumed_stock_items(self, gl_entries, target_account, target_against, precision):
# Consumed Stock Items
@@ -554,9 +589,33 @@ class AssetCapitalization(StockController):
item=self,
)
)
else:
# Target Stock Item
sle_list = self.sle_map.get(self.name)
for sle in sle_list:
stock_value_difference = flt(sle.stock_value_difference, precision)
account = self.warehouse_account[sle.warehouse]["account"]
gl_entries.append(
self.get_gl_dict(
{
"account": account,
"against": ", ".join(target_against),
"cost_center": self.cost_center,
"project": self.get("project"),
"remarks": self.get("remarks") or "Accounting Entry for Stock",
"debit": stock_value_difference,
},
self.warehouse_account[sle.warehouse]["account_currency"],
item=self,
)
)
def create_target_asset(self):
if self.capitalization_method != "Create a new composite asset":
if (
self.entry_type != "Capitalization"
or self.capitalization_method != "Create a new composite asset"
):
return
total_target_asset_value = flt(self.total_value, self.precision("total_value"))
@@ -579,7 +638,6 @@ class AssetCapitalization(StockController):
self.target_fixed_asset_account = get_asset_category_account(
"fixed_asset_account", item=self.target_item_code, company=asset_doc.company
)
asset_doc.set_status("Work In Progress")
add_asset_activity(
asset_doc.name,
@@ -595,15 +653,17 @@ class AssetCapitalization(StockController):
)
def update_target_asset(self):
if self.capitalization_method != "Choose a WIP composite asset":
if (
self.entry_type != "Capitalization"
or self.capitalization_method != "Choose a WIP composite asset"
):
return
total_target_asset_value = flt(self.total_value, self.precision("total_value"))
asset_doc = frappe.get_doc("Asset", self.target_asset)
asset_doc.gross_purchase_amount += total_target_asset_value
asset_doc.purchase_amount += total_target_asset_value
asset_doc.set_status("Work In Progress")
asset_doc.gross_purchase_amount = total_target_asset_value
asset_doc.purchase_amount = total_target_asset_value
asset_doc.flags.ignore_validate = True
asset_doc.save()
@@ -638,6 +698,14 @@ class AssetCapitalization(StockController):
get_link_to_form("Asset Capitalization", self.name)
),
)
else:
asset.set_status("Decapitalized")
add_asset_activity(
asset.name,
_("Asset decapitalized after Asset Capitalization {0} was submitted").format(
get_link_to_form("Asset Capitalization", self.name)
),
)
else:
asset.set_status()
add_asset_activity(
@@ -659,12 +727,16 @@ def get_target_item_details(item_code=None, company=None):
# Set Item Details
out.target_item_name = item.item_name
out.target_stock_uom = item.stock_uom
out.target_is_fixed_asset = cint(item.is_fixed_asset)
out.target_has_batch_no = cint(item.has_batch_no)
out.target_has_serial_no = cint(item.has_serial_no)
if out.target_is_fixed_asset:
out.target_qty = 1
out.target_warehouse = None
else:
out.target_asset = None
if not out.target_has_batch_no:
out.target_batch_no = None

View File

@@ -61,6 +61,7 @@ class TestAssetCapitalization(unittest.TestCase):
# Create and submit Asset Captitalization
asset_capitalization = create_asset_capitalization(
entry_type="Capitalization",
capitalization_method="Create a new composite asset",
target_item_code="Macbook Pro",
target_asset_location="Test Location",
@@ -75,6 +76,7 @@ class TestAssetCapitalization(unittest.TestCase):
)
# Test Asset Capitalization values
self.assertEqual(asset_capitalization.entry_type, "Capitalization")
self.assertEqual(asset_capitalization.target_qty, 1)
self.assertEqual(asset_capitalization.stock_items[0].valuation_rate, stock_rate)
@@ -94,7 +96,6 @@ class TestAssetCapitalization(unittest.TestCase):
target_asset = frappe.get_doc("Asset", asset_capitalization.target_asset)
self.assertEqual(target_asset.gross_purchase_amount, total_amount)
self.assertEqual(target_asset.purchase_amount, total_amount)
self.assertEqual(target_asset.status, "Work In Progress")
# Test Consumed Asset values
self.assertEqual(consumed_asset.db_get("status"), "Capitalized")
@@ -150,6 +151,7 @@ class TestAssetCapitalization(unittest.TestCase):
# Create and submit Asset Captitalization
asset_capitalization = create_asset_capitalization(
entry_type="Capitalization",
capitalization_method="Create a new composite asset",
target_item_code="Macbook Pro",
target_asset_location="Test Location",
@@ -164,6 +166,7 @@ class TestAssetCapitalization(unittest.TestCase):
)
# Test Asset Capitalization values
self.assertEqual(asset_capitalization.entry_type, "Capitalization")
self.assertEqual(asset_capitalization.target_qty, 1)
self.assertEqual(asset_capitalization.stock_items[0].valuation_rate, stock_rate)
@@ -240,6 +243,7 @@ class TestAssetCapitalization(unittest.TestCase):
# Create and submit Asset Captitalization
asset_capitalization = create_asset_capitalization(
entry_type="Capitalization",
capitalization_method="Choose a WIP composite asset",
target_asset=wip_composite_asset.name,
target_asset_location="Test Location",
@@ -251,6 +255,7 @@ class TestAssetCapitalization(unittest.TestCase):
)
# Test Asset Capitalization values
self.assertEqual(asset_capitalization.entry_type, "Capitalization")
self.assertEqual(asset_capitalization.capitalization_method, "Choose a WIP composite asset")
self.assertEqual(asset_capitalization.target_qty, 1)
@@ -265,7 +270,6 @@ class TestAssetCapitalization(unittest.TestCase):
target_asset = frappe.get_doc("Asset", asset_capitalization.target_asset)
self.assertEqual(target_asset.gross_purchase_amount, total_amount)
self.assertEqual(target_asset.purchase_amount, total_amount)
self.assertEqual(target_asset.status, "Work In Progress")
# Test General Ledger Entries
expected_gle = {
@@ -291,6 +295,110 @@ class TestAssetCapitalization(unittest.TestCase):
self.assertFalse(get_actual_gle_dict(asset_capitalization.name))
self.assertFalse(get_actual_sle_dict(asset_capitalization.name))
def test_decapitalization_with_depreciation(self):
# Variables
purchase_date = "2020-01-01"
depreciation_start_date = "2020-12-31"
capitalization_date = "2021-06-30"
total_number_of_depreciations = 3
expected_value_after_useful_life = 10_000
consumed_asset_purchase_value = 100_000
consumed_asset_current_value = 70_000
consumed_asset_value_before_disposal = 55_000
target_qty = 10
target_incoming_rate = 5500
depreciation_before_disposal_amount = 15_000
accumulated_depreciation = 45_000
# to accomodate for depreciation on disposal calculation minor difference
consumed_asset_value_before_disposal = 55_123.29
target_incoming_rate = 5512.329
depreciation_before_disposal_amount = 14_876.71
accumulated_depreciation = 44_876.71
# Create assets
consumed_asset = create_depreciation_asset(
asset_name="Asset Capitalization Consumable Asset",
asset_value=consumed_asset_purchase_value,
purchase_date=purchase_date,
depreciation_start_date=depreciation_start_date,
depreciation_method="Straight Line",
total_number_of_depreciations=total_number_of_depreciations,
frequency_of_depreciation=12,
expected_value_after_useful_life=expected_value_after_useful_life,
company="_Test Company with perpetual inventory",
submit=1,
)
first_asset_depr_schedule = get_asset_depr_schedule_doc(consumed_asset.name, "Active")
self.assertEqual(first_asset_depr_schedule.status, "Active")
# Create and submit Asset Captitalization
asset_capitalization = create_asset_capitalization(
entry_type="Decapitalization",
posting_date=capitalization_date, # half a year
target_item_code="Capitalization Target Stock Item",
target_qty=target_qty,
consumed_asset=consumed_asset.name,
company="_Test Company with perpetual inventory",
submit=1,
)
# Test Asset Capitalization values
self.assertEqual(asset_capitalization.entry_type, "Decapitalization")
self.assertEqual(
asset_capitalization.asset_items[0].current_asset_value, consumed_asset_current_value
)
self.assertEqual(
asset_capitalization.asset_items[0].asset_value, consumed_asset_value_before_disposal
)
self.assertEqual(asset_capitalization.asset_items_total, consumed_asset_value_before_disposal)
self.assertEqual(asset_capitalization.total_value, consumed_asset_value_before_disposal)
self.assertEqual(asset_capitalization.target_incoming_rate, target_incoming_rate)
# Test Consumed Asset values
consumed_asset.reload()
self.assertEqual(consumed_asset.status, "Decapitalized")
first_asset_depr_schedule.load_from_db()
second_asset_depr_schedule = get_asset_depr_schedule_doc(consumed_asset.name, "Active")
self.assertEqual(second_asset_depr_schedule.status, "Active")
self.assertEqual(first_asset_depr_schedule.status, "Cancelled")
depr_schedule_of_consumed_asset = second_asset_depr_schedule.get("depreciation_schedule")
consumed_depreciation_schedule = [
d
for d in depr_schedule_of_consumed_asset
if getdate(d.schedule_date) == getdate(capitalization_date)
]
self.assertTrue(consumed_depreciation_schedule and consumed_depreciation_schedule[0].journal_entry)
self.assertEqual(
consumed_depreciation_schedule[0].depreciation_amount, depreciation_before_disposal_amount
)
# Test General Ledger Entries
expected_gle = {
"_Test Warehouse - TCP1": consumed_asset_value_before_disposal,
"_Test Accumulated Depreciations - TCP1": accumulated_depreciation,
"_Test Fixed Asset - TCP1": -consumed_asset_purchase_value,
}
actual_gle = get_actual_gle_dict(asset_capitalization.name)
self.assertEqual(actual_gle, expected_gle)
# Cancel Asset Capitalization and make test entries and status are reversed
asset_capitalization.reload()
asset_capitalization.cancel()
self.assertEqual(consumed_asset.db_get("status"), "Partially Depreciated")
self.assertFalse(get_actual_gle_dict(asset_capitalization.name))
self.assertFalse(get_actual_sle_dict(asset_capitalization.name))
def test_capitalize_only_service_item(self):
company = "_Test Company"
# Variables
@@ -310,6 +418,7 @@ class TestAssetCapitalization(unittest.TestCase):
# Create and submit Asset Captitalization
asset_capitalization = create_asset_capitalization(
entry_type="Capitalization",
capitalization_method="Choose a WIP composite asset",
target_asset=wip_composite_asset.name,
target_asset_location="Test Location",
@@ -357,11 +466,13 @@ def create_asset_capitalization(**args):
target_item_code = target_asset.item_code or args.target_item_code
company = target_asset.company or args.company or "_Test Company"
warehouse = args.warehouse or create_warehouse("_Test Warehouse", company=company)
target_warehouse = args.target_warehouse or warehouse
source_warehouse = args.source_warehouse or warehouse
asset_capitalization = frappe.new_doc("Asset Capitalization")
asset_capitalization.update(
{
"entry_type": args.entry_type or "Capitalization",
"capitalization_method": args.capitalization_method or None,
"company": company,
"posting_date": args.posting_date or now.strftime("%Y-%m-%d"),
@@ -369,6 +480,7 @@ def create_asset_capitalization(**args):
"target_item_code": target_item_code,
"target_asset": target_asset.name,
"target_asset_location": "Test Location",
"target_warehouse": target_warehouse,
"target_qty": flt(args.target_qty) or 1,
"target_batch_no": args.target_batch_no,
"target_serial_no": args.target_serial_no,

View File

@@ -13,7 +13,6 @@ from frappe.utils import (
flt,
get_first_day,
get_last_day,
get_link_to_form,
getdate,
is_last_day_of_the_month,
month_diff,
@@ -1063,7 +1062,7 @@ def make_new_active_asset_depr_schedules_and_cancel_current_ones(
if not current_asset_depr_schedule_doc:
frappe.throw(
_("Asset Depreciation Schedule not found for Asset {0} and Finance Book {1}").format(
get_link_to_form("Asset", asset_doc.name), row.finance_book
asset_doc.name, row.finance_book
)
)
@@ -1109,7 +1108,7 @@ def get_temp_asset_depr_schedule_doc(
if not current_asset_depr_schedule_doc:
frappe.throw(
_("Asset Depreciation Schedule not found for Asset {0} and Finance Book {1}").format(
get_link_to_form("Asset", asset_doc.name), row.finance_book
asset_doc.name, row.finance_book
)
)

View File

@@ -66,12 +66,12 @@ def get_conditions(filters):
conditions["cost_center"] = filters.get("cost_center")
if status:
# In Store assets are those that are not sold or scrapped or capitalized
# In Store assets are those that are not sold or scrapped or capitalized or decapitalized
operand = "not in"
if status not in "In Location":
operand = "in"
conditions["status"] = (operand, ["Sold", "Scrapped", "Capitalized"])
conditions["status"] = (operand, ["Sold", "Scrapped", "Capitalized", "Decapitalized"])
return conditions
@@ -272,9 +272,9 @@ def get_asset_depreciation_amount_map(filters, finance_book):
query = query.where(asset.cost_center == filters.cost_center)
if filters.status:
if filters.status == "In Location":
query = query.where(asset.status.notin(["Sold", "Scrapped", "Capitalized"]))
query = query.where(asset.status.notin(["Sold", "Scrapped", "Capitalized", "Decapitalized"]))
else:
query = query.where(asset.status.isin(["Sold", "Scrapped", "Capitalized"]))
query = query.where(asset.status.isin(["Sold", "Scrapped", "Capitalized", "Decapitalized"]))
if finance_book:
query = query.where((gle.finance_book.isin([cstr(finance_book), ""])) | (gle.finance_book.isnull()))
else:

View File

@@ -777,14 +777,7 @@ class AccountsController(TransactionBase):
ret = get_item_details(args, self, for_validate=for_validate, overwrite_warehouse=False)
for fieldname, value in ret.items():
if item.meta.get_field(fieldname) and value is not None:
if (
item.get(fieldname) is None
or fieldname in force_item_fields
or (
fieldname in ["serial_no", "batch_no"]
and item.get("use_serial_batch_fields")
)
):
if item.get(fieldname) is None or fieldname in force_item_fields:
item.set(fieldname, value)
elif fieldname in ["cost_center", "conversion_factor"] and not item.get(

View File

@@ -1257,7 +1257,6 @@ def add_items_in_ste(ste_doc, row, qty, rm_details, rm_detail_field="sco_rm_deta
"item_code": row.item_details["rm_item_code"],
"subcontracted_item": row.item_details["main_item_code"],
"serial_no": "\n".join(row.serial_no) if row.serial_no else "",
"use_serial_batch_fields": 1,
}
)
@@ -1298,13 +1297,10 @@ def make_return_stock_entry_for_subcontract(
if not value.qty:
continue
if item_details := value.get("item_details"):
item_details["serial_and_batch_bundle"] = None
if value.batch_no:
for batch_no, qty in value.batch_no.items():
if qty > 0:
add_items_in_ste(ste_doc, value, qty, rm_details, rm_detail_field, batch_no)
add_items_in_ste(ste_doc, value, value.qty, rm_details, rm_detail_field, batch_no)
else:
add_items_in_ste(ste_doc, value, value.qty, rm_details, rm_detail_field)

View File

@@ -282,79 +282,6 @@ class TestSubcontractingController(FrappeTestCase):
frappe.db.set_single_value("Stock Settings", "use_serial_batch_fields", 1)
def test_return_non_consumed_batch_materials(self):
"""
- Set backflush based on Material Transfer.
- Create SCO for item Subcontracted Item SA2.
- Transfer the batched components from Stores to Supplier warehouse with serial nos.
- Transfer extra qty of component for the subcontracted item Subcontracted Item SA2.
- Create SCR for full qty against the SCO and change the qty of raw material.
- After that return the non consumed material back to the store from supplier's warehouse.
"""
frappe.db.set_single_value("Stock Settings", "use_serial_batch_fields", 0)
set_backflush_based_on("Material Transferred for Subcontract")
service_item = make_item("Subcontracted Service FG Item A", properties={"is_stock_item": 0}).name
fg_item = make_item(
"Subcontracted FG Item SA2", properties={"is_stock_item": 1, "is_sub_contracted_item": 1}
).name
rm_item = make_item(
"Subcontracted Batch RM Item SA2",
properties={
"is_stock_item": 1,
"create_new_batch": 1,
"has_batch_no": 1,
"batch_number_series": "BATCH-RM-IRM-.####",
},
).name
make_bom(item=fg_item, raw_materials=[rm_item], rate=100, currency="INR")
service_items = [
{
"warehouse": "_Test Warehouse - _TC",
"item_code": service_item,
"qty": 5,
"rate": 100,
"fg_item": fg_item,
"fg_item_qty": 5,
},
]
sco = get_subcontracting_order(service_items=service_items)
rm_items = get_rm_items(sco.supplied_items)
rm_items[0]["qty"] += 1
itemwise_details = make_stock_in_entry(rm_items=rm_items)
for item in rm_items:
item["sco_rm_detail"] = sco.items[0].name
make_stock_transfer_entry(
sco_no=sco.name,
rm_items=rm_items,
itemwise_details=copy.deepcopy(itemwise_details),
)
scr1 = make_subcontracting_receipt(sco.name)
scr1.save()
scr1.supplied_items[0].consumed_qty = 5
scr1.submit()
for key, value in get_supplied_items(scr1).items():
transferred_detais = itemwise_details.get(key)
self.assertEqual(value.qty, 5)
self.assertEqual(sorted(value.serial_no), sorted(transferred_detais.get("serial_no")[0:5]))
sco.load_from_db()
self.assertEqual(sco.supplied_items[0].consumed_qty, 5)
doc = get_materials_from_supplier(sco.name, [d.name for d in sco.supplied_items])
doc.save()
self.assertEqual(doc.items[0].qty, 1)
self.assertEqual(doc.items[0].s_warehouse, "_Test Warehouse 1 - _TC")
self.assertEqual(doc.items[0].t_warehouse, "_Test Warehouse - _TC")
self.assertTrue(doc.items[0].batch_no)
self.assertTrue(doc.items[0].use_serial_batch_fields)
frappe.db.set_single_value("Stock Settings", "use_serial_batch_fields", 1)
def test_return_non_consumed_materials(self):
"""
- Set backflush based on Material Transfer.

View File

@@ -0,0 +1,364 @@
// Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
frappe.provide("erpnext.tally_migration");
frappe.ui.form.on("Tally Migration", {
onload: function (frm) {
let reload_status = true;
frappe.realtime.on("tally_migration_progress_update", function (data) {
if (reload_status) {
frappe.model.with_doc(frm.doc.doctype, frm.doc.name, () => {
frm.refresh_header();
});
reload_status = false;
}
frm.dashboard.show_progress(data.title, (data.count / data.total) * 100, data.message);
let error_occurred = data.count === -1;
if (data.count == data.total || error_occurred) {
window.setTimeout(
(title) => {
frm.dashboard.hide_progress(title);
frm.reload_doc();
if (error_occurred) {
frappe.msgprint({
message: __("An error has occurred during {0}. Check {1} for more details", [
repl(
"<a href='/app/tally-migration/%(tally_document)s' class='variant-click'>%(tally_document)s</a>",
{
tally_document: frm.docname,
}
),
"<a href='/app/error-log' class='variant-click'>Error Log</a>",
]),
title: __("Tally Migration Error"),
indicator: "red",
});
}
},
2000,
data.title
);
}
});
},
refresh: function (frm) {
frm.trigger("show_logs_preview");
erpnext.tally_migration.failed_import_log = JSON.parse(frm.doc.failed_import_log);
erpnext.tally_migration.fixed_errors_log = JSON.parse(frm.doc.fixed_errors_log);
["default_round_off_account", "default_warehouse", "default_cost_center"].forEach((account) => {
frm.toggle_reqd(account, frm.doc.is_master_data_imported === 1);
frm.toggle_enable(account, frm.doc.is_day_book_data_processed != 1);
});
if (frm.doc.master_data && !frm.doc.is_master_data_imported) {
if (frm.doc.is_master_data_processed) {
if (frm.doc.status != "Importing Master Data") {
frm.events.add_button(frm, __("Import Master Data"), "import_master_data");
}
} else {
if (frm.doc.status != "Processing Master Data") {
frm.events.add_button(frm, __("Process Master Data"), "process_master_data");
}
}
}
if (frm.doc.day_book_data && !frm.doc.is_day_book_data_imported) {
if (frm.doc.is_day_book_data_processed) {
if (frm.doc.status != "Importing Day Book Data") {
frm.events.add_button(frm, __("Import Day Book Data"), "import_day_book_data");
}
} else {
if (frm.doc.status != "Processing Day Book Data") {
frm.events.add_button(frm, __("Process Day Book Data"), "process_day_book_data");
}
}
}
},
erpnext_company: function (frm) {
frappe.db.exists("Company", frm.doc.erpnext_company).then((exists) => {
if (exists) {
frappe.msgprint(
__(
"Company {0} already exists. Continuing will overwrite the Company and Chart of Accounts",
[frm.doc.erpnext_company]
)
);
}
});
},
add_button: function (frm, label, method) {
frm.add_custom_button(label, () => {
frm.call({
doc: frm.doc,
method: method,
freeze: true,
});
frm.reload_doc();
});
},
render_html_table(frm, shown_logs, hidden_logs, field) {
if (shown_logs && shown_logs.length > 0) {
frm.toggle_display(field, true);
} else {
frm.toggle_display(field, false);
return;
}
let rows = erpnext.tally_migration.get_html_rows(shown_logs, field);
let rows_head, table_caption;
let table_footer =
hidden_logs && hidden_logs.length > 0
? `<tr class="text-muted">
<td colspan="4">And ${hidden_logs.length} more others</td>
</tr>`
: "";
if (field === "fixed_error_log_preview") {
rows_head = `<th width="75%">${__("Meta Data")}</th>
<th width="10%">${__("Unresolve")}</th>`;
table_caption = "Resolved Issues";
} else {
rows_head = `<th width="75%">${__("Error Message")}</th>
<th width="10%">${__("Create")}</th>`;
table_caption = "Error Log";
}
frm.get_field(field).$wrapper.html(`
<table class="table table-bordered">
<caption>${table_caption}</caption>
<tr class="text-muted">
<th width="5%">${__("#")}</th>
<th width="10%">${__("DocType")}</th>
${rows_head}
</tr>
${rows}
${table_footer}
</table>
`);
},
show_error_summary(frm) {
let summary = erpnext.tally_migration.failed_import_log.reduce((summary, row) => {
if (row.doc) {
if (summary[row.doc.doctype]) {
summary[row.doc.doctype] += 1;
} else {
summary[row.doc.doctype] = 1;
}
}
return summary;
}, {});
console.table(summary);
},
show_logs_preview(frm) {
let empty = "[]";
let import_log = frm.doc.failed_import_log || empty;
let completed_log = frm.doc.fixed_errors_log || empty;
let render_section = !(import_log === completed_log && import_log === empty);
frm.toggle_display("import_log_section", render_section);
if (render_section) {
frm.trigger("show_error_summary");
frm.trigger("show_errored_import_log");
frm.trigger("show_fixed_errors_log");
}
},
show_errored_import_log(frm) {
let import_log = erpnext.tally_migration.failed_import_log;
let logs = import_log.slice(0, 20);
let hidden_logs = import_log.slice(20);
frm.events.render_html_table(frm, logs, hidden_logs, "failed_import_preview");
},
show_fixed_errors_log(frm) {
let completed_log = erpnext.tally_migration.fixed_errors_log;
let logs = completed_log.slice(0, 20);
let hidden_logs = completed_log.slice(20);
frm.events.render_html_table(frm, logs, hidden_logs, "fixed_error_log_preview");
},
});
erpnext.tally_migration.getError = (traceback) => {
/* Extracts the Error Message from the Python Traceback or Solved error */
let is_multiline = traceback.trim().indexOf("\n") != -1;
let message;
if (is_multiline) {
let exc_error_idx = traceback.trim().lastIndexOf("\n") + 1;
let error_line = traceback.substr(exc_error_idx);
let split_str_idx = error_line.indexOf(":") > 0 ? error_line.indexOf(":") + 1 : 0;
message = error_line.slice(split_str_idx).trim();
} else {
message = traceback;
}
return message;
};
erpnext.tally_migration.cleanDoc = (obj) => {
/* Strips all null and empty values of your JSON object */
let temp = obj;
$.each(temp, function (key, value) {
if (value === "" || value === null) {
delete obj[key];
} else if (Object.prototype.toString.call(value) === "[object Object]") {
erpnext.tally_migration.cleanDoc(value);
} else if ($.isArray(value)) {
$.each(value, function (k, v) {
erpnext.tally_migration.cleanDoc(v);
});
}
});
return temp;
};
erpnext.tally_migration.unresolve = (document) => {
/* Mark document migration as unresolved ie. move to failed error log */
let frm = cur_frm;
let failed_log = erpnext.tally_migration.failed_import_log;
let fixed_log = erpnext.tally_migration.fixed_errors_log;
let modified_fixed_log = fixed_log.filter((row) => {
if (!frappe.utils.deep_equal(erpnext.tally_migration.cleanDoc(row.doc), document)) {
return row;
}
});
failed_log.push({ doc: document, exc: `Marked unresolved on ${Date()}` });
frm.doc.failed_import_log = JSON.stringify(failed_log);
frm.doc.fixed_errors_log = JSON.stringify(modified_fixed_log);
frm.dirty();
frm.save();
};
erpnext.tally_migration.resolve = (document) => {
/* Mark document migration as resolved ie. move to fixed error log */
let frm = cur_frm;
let failed_log = erpnext.tally_migration.failed_import_log;
let fixed_log = erpnext.tally_migration.fixed_errors_log;
let modified_failed_log = failed_log.filter((row) => {
if (!frappe.utils.deep_equal(erpnext.tally_migration.cleanDoc(row.doc), document)) {
return row;
}
});
fixed_log.push({ doc: document, exc: `Solved on ${Date()}` });
frm.doc.failed_import_log = JSON.stringify(modified_failed_log);
frm.doc.fixed_errors_log = JSON.stringify(fixed_log);
frm.dirty();
frm.save();
};
erpnext.tally_migration.create_new_doc = (document) => {
/* Mark as resolved and create new document */
erpnext.tally_migration.resolve(document);
return frappe.call({
type: "POST",
method: "erpnext.erpnext_integrations.doctype.tally_migration.tally_migration.new_doc",
args: {
document,
},
freeze: true,
callback: function (r) {
if (!r.exc) {
frappe.model.sync(r.message);
frappe.get_doc(r.message.doctype, r.message.name).__run_link_triggers = true;
frappe.set_route("Form", r.message.doctype, r.message.name);
}
},
});
};
erpnext.tally_migration.get_html_rows = (logs, field) => {
let index = 0;
let rows = logs
.map(({ doc, exc }) => {
let id = frappe.dom.get_unique_id();
let traceback = exc;
let error_message = erpnext.tally_migration.getError(traceback);
index++;
let show_traceback = `
<button class="btn btn-default btn-xs m-3" type="button" data-toggle="collapse" data-target="#${id}-traceback" aria-expanded="false" aria-controls="${id}-traceback">
${__("Show Traceback")}
</button>
<div class="collapse margin-top" id="${id}-traceback">
<div class="well">
<pre style="font-size: smaller;">${traceback}</pre>
</div>
</div>`;
let show_doc = `
<button class='btn btn-default btn-xs m-3' type='button' data-toggle='collapse' data-target='#${id}-doc' aria-expanded='false' aria-controls='${id}-doc'>
${__("Show Document")}
</button>
<div class="collapse margin-top" id="${id}-doc">
<div class="well">
<pre style="font-size: smaller;">${JSON.stringify(erpnext.tally_migration.cleanDoc(doc), null, 1)}</pre>
</div>
</div>`;
let create_button = `
<button class='btn btn-default btn-xs m-3' type='button' onclick='erpnext.tally_migration.create_new_doc(${JSON.stringify(
doc
)})'>
${__("Create Document")}
</button>`;
let mark_as_unresolved = `
<button class='btn btn-default btn-xs m-3' type='button' onclick='erpnext.tally_migration.unresolve(${JSON.stringify(
doc
)})'>
${__("Mark as unresolved")}
</button>`;
if (field === "fixed_error_log_preview") {
return `<tr>
<td>${index}</td>
<td>
<div>${doc.doctype}</div>
</td>
<td>
<div>${error_message}</div>
<div>${show_doc}</div>
</td>
<td>
<div>${mark_as_unresolved}</div>
</td>
</tr>`;
} else {
return `<tr>
<td>${index}</td>
<td>
<div>${doc.doctype}</div>
</td>
<td>
<div>${error_message}</div>
<div>${show_traceback}</div>
<div>${show_doc}</div>
</td>
<td>
<div>${create_button}</div>
</td>
</tr>`;
}
})
.join("");
return rows;
};

View File

@@ -0,0 +1,279 @@
{
"actions": [],
"beta": 1,
"creation": "2019-02-01 14:27:09.485238",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"status",
"master_data",
"is_master_data_processed",
"is_master_data_imported",
"column_break_2",
"tally_creditors_account",
"tally_debtors_account",
"company_section",
"tally_company",
"default_uom",
"column_break_8",
"erpnext_company",
"processed_files_section",
"chart_of_accounts",
"parties",
"addresses",
"column_break_17",
"uoms",
"items",
"vouchers",
"accounts_section",
"default_warehouse",
"default_round_off_account",
"column_break_21",
"default_cost_center",
"day_book_section",
"day_book_data",
"column_break_27",
"is_day_book_data_processed",
"is_day_book_data_imported",
"import_log_section",
"failed_import_log",
"fixed_errors_log",
"failed_import_preview",
"fixed_error_log_preview"
],
"fields": [
{
"fieldname": "status",
"fieldtype": "Data",
"hidden": 1,
"label": "Status"
},
{
"description": "Data exported from Tally that consists of the Chart of Accounts, Customers, Suppliers, Addresses, Items and UOMs",
"fieldname": "master_data",
"fieldtype": "Attach",
"in_list_view": 1,
"label": "Master Data"
},
{
"default": "Sundry Creditors",
"description": "Creditors Account set in Tally",
"fieldname": "tally_creditors_account",
"fieldtype": "Data",
"label": "Tally Creditors Account",
"read_only_depends_on": "eval:doc.is_master_data_processed==1",
"reqd": 1
},
{
"fieldname": "column_break_2",
"fieldtype": "Column Break"
},
{
"default": "Sundry Debtors",
"description": "Debtors Account set in Tally",
"fieldname": "tally_debtors_account",
"fieldtype": "Data",
"label": "Tally Debtors Account",
"read_only_depends_on": "eval:doc.is_master_data_processed==1",
"reqd": 1
},
{
"depends_on": "is_master_data_processed",
"fieldname": "company_section",
"fieldtype": "Section Break"
},
{
"description": "Company Name as per Imported Tally Data",
"fieldname": "tally_company",
"fieldtype": "Data",
"label": "Tally Company",
"read_only": 1
},
{
"fieldname": "column_break_8",
"fieldtype": "Column Break"
},
{
"description": "Your Company set in ERPNext",
"fieldname": "erpnext_company",
"fieldtype": "Data",
"label": "ERPNext Company",
"read_only_depends_on": "eval:doc.is_master_data_processed==1"
},
{
"fieldname": "processed_files_section",
"fieldtype": "Section Break",
"hidden": 1,
"label": "Processed Files"
},
{
"fieldname": "chart_of_accounts",
"fieldtype": "Attach",
"label": "Chart of Accounts"
},
{
"fieldname": "parties",
"fieldtype": "Attach",
"label": "Parties"
},
{
"fieldname": "addresses",
"fieldtype": "Attach",
"label": "Addresses"
},
{
"fieldname": "column_break_17",
"fieldtype": "Column Break"
},
{
"fieldname": "uoms",
"fieldtype": "Attach",
"label": "UOMs"
},
{
"fieldname": "items",
"fieldtype": "Attach",
"label": "Items"
},
{
"fieldname": "vouchers",
"fieldtype": "Attach",
"label": "Vouchers"
},
{
"depends_on": "is_master_data_imported",
"description": "The accounts are set by the system automatically but do confirm these defaults",
"fieldname": "accounts_section",
"fieldtype": "Section Break",
"label": "Accounts"
},
{
"fieldname": "default_warehouse",
"fieldtype": "Link",
"label": "Default Warehouse",
"options": "Warehouse"
},
{
"fieldname": "column_break_21",
"fieldtype": "Column Break"
},
{
"fieldname": "default_cost_center",
"fieldtype": "Link",
"label": "Default Cost Center",
"options": "Cost Center"
},
{
"default": "0",
"fieldname": "is_master_data_processed",
"fieldtype": "Check",
"label": "Is Master Data Processed",
"read_only": 1
},
{
"default": "0",
"fieldname": "is_day_book_data_processed",
"fieldtype": "Check",
"label": "Is Day Book Data Processed",
"read_only": 1
},
{
"default": "0",
"fieldname": "is_day_book_data_imported",
"fieldtype": "Check",
"label": "Is Day Book Data Imported",
"read_only": 1
},
{
"default": "0",
"fieldname": "is_master_data_imported",
"fieldtype": "Check",
"label": "Is Master Data Imported",
"read_only": 1
},
{
"depends_on": "is_master_data_imported",
"fieldname": "day_book_section",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_27",
"fieldtype": "Column Break"
},
{
"description": "Day Book Data exported from Tally that consists of all historic transactions",
"fieldname": "day_book_data",
"fieldtype": "Attach",
"in_list_view": 1,
"label": "Day Book Data"
},
{
"default": "Unit",
"description": "UOM in case unspecified in imported data",
"fieldname": "default_uom",
"fieldtype": "Link",
"label": "Default UOM",
"options": "UOM",
"read_only_depends_on": "eval:doc.is_master_data_imported==1"
},
{
"default": "[]",
"fieldname": "failed_import_log",
"fieldtype": "Code",
"hidden": 1,
"options": "JSON"
},
{
"fieldname": "failed_import_preview",
"fieldtype": "HTML",
"label": "Failed Import Log"
},
{
"fieldname": "import_log_section",
"fieldtype": "Section Break",
"label": "Import Log"
},
{
"fieldname": "default_round_off_account",
"fieldtype": "Link",
"label": "Default Round Off Account",
"options": "Account"
},
{
"default": "[]",
"fieldname": "fixed_errors_log",
"fieldtype": "Code",
"hidden": 1,
"options": "JSON"
},
{
"fieldname": "fixed_error_log_preview",
"fieldtype": "HTML",
"label": "Fixed Error Log"
}
],
"links": [],
"modified": "2020-04-28 00:29:18.039826",
"modified_by": "Administrator",
"module": "ERPNext Integrations",
"name": "Tally Migration",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

View File

@@ -0,0 +1,768 @@
# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import json
import re
import sys
import traceback
import zipfile
from decimal import Decimal
import frappe
from bs4 import BeautifulSoup as bs
from frappe import _
from frappe.custom.doctype.custom_field.custom_field import (
create_custom_fields as _create_custom_fields,
)
from frappe.model.document import Document
from frappe.utils.data import format_datetime
from erpnext import encode_company_abbr
from erpnext.accounts.doctype.account.chart_of_accounts.chart_of_accounts import create_charts
from erpnext.accounts.doctype.chart_of_accounts_importer.chart_of_accounts_importer import (
unset_existing_data,
)
PRIMARY_ACCOUNT = "Primary"
VOUCHER_CHUNK_SIZE = 500
@frappe.whitelist()
def new_doc(document):
document = json.loads(document)
doctype = document.pop("doctype")
document.pop("name", None)
doc = frappe.new_doc(doctype)
doc.update(document)
return doc
class TallyMigration(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
addresses: DF.Attach | None
chart_of_accounts: DF.Attach | None
day_book_data: DF.Attach | None
default_cost_center: DF.Link | None
default_round_off_account: DF.Link | None
default_uom: DF.Link | None
default_warehouse: DF.Link | None
erpnext_company: DF.Data | None
failed_import_log: DF.Code | None
fixed_errors_log: DF.Code | None
is_day_book_data_imported: DF.Check
is_day_book_data_processed: DF.Check
is_master_data_imported: DF.Check
is_master_data_processed: DF.Check
items: DF.Attach | None
master_data: DF.Attach | None
parties: DF.Attach | None
status: DF.Data | None
tally_company: DF.Data | None
tally_creditors_account: DF.Data
tally_debtors_account: DF.Data
uoms: DF.Attach | None
vouchers: DF.Attach | None
# end: auto-generated types
def validate(self):
failed_import_log = json.loads(self.failed_import_log)
sorted_failed_import_log = sorted(failed_import_log, key=lambda row: row["doc"]["creation"])
self.failed_import_log = json.dumps(sorted_failed_import_log)
def autoname(self):
if not self.name:
self.name = "Tally Migration on " + format_datetime(self.creation)
def get_collection(self, data_file):
def sanitize(string):
return re.sub("&#4;", "", string)
def emptify(string):
string = re.sub(r"<\w+/>", "", string)
string = re.sub(r"<([\w.]+)>\s*<\/\1>", "", string)
string = re.sub(r"\r\n", "", string)
return string
master_file = frappe.get_doc("File", {"file_url": data_file})
master_file_path = master_file.get_full_path()
if zipfile.is_zipfile(master_file_path):
with zipfile.ZipFile(master_file_path) as zf:
encoded_content = zf.read(zf.namelist()[0])
try:
content = encoded_content.decode("utf-8-sig")
except UnicodeDecodeError:
content = encoded_content.decode("utf-16")
master = bs(sanitize(emptify(content)), "xml")
collection = master.BODY.IMPORTDATA.REQUESTDATA
return collection
def dump_processed_data(self, data):
for key, value in data.items():
f = frappe.get_doc(
{
"doctype": "File",
"file_name": key + ".json",
"attached_to_doctype": self.doctype,
"attached_to_name": self.name,
"content": json.dumps(value),
"is_private": True,
}
)
try:
f.insert(ignore_if_duplicate=True)
except frappe.DuplicateEntryError:
pass
setattr(self, key, f.file_url)
def set_account_defaults(self):
self.default_cost_center, self.default_round_off_account = frappe.db.get_value(
"Company", self.erpnext_company, ["cost_center", "round_off_account"]
)
self.default_warehouse = frappe.db.get_value("Stock Settings", "Stock Settings", "default_warehouse")
def _process_master_data(self):
def get_company_name(collection):
return collection.find_all("REMOTECMPINFO.LIST")[0].REMOTECMPNAME.string.strip()
def get_coa_customers_suppliers(collection):
root_type_map = {
"Application of Funds (Assets)": "Asset",
"Expenses": "Expense",
"Income": "Income",
"Source of Funds (Liabilities)": "Liability",
}
roots = set(root_type_map.keys())
accounts = list(get_groups(collection.find_all("GROUP"))) + list(
get_ledgers(collection.find_all("LEDGER"))
)
children, parents = get_children_and_parent_dict(accounts)
group_set = [acc[1] for acc in accounts if acc[2]]
children, customers, suppliers = remove_parties(parents, children, group_set)
try:
coa = traverse({}, children, roots, roots, group_set)
except RecursionError:
self.log(
_(
"Error occured while parsing Chart of Accounts: Please make sure that no two accounts have the same name"
)
)
for account in coa:
coa[account]["root_type"] = root_type_map[account]
return coa, customers, suppliers
def get_groups(accounts):
for account in accounts:
if account["NAME"] in (self.tally_creditors_account, self.tally_debtors_account):
yield get_parent(account), account["NAME"], 0
else:
yield get_parent(account), account["NAME"], 1
def get_ledgers(accounts):
for account in accounts:
# If Ledger doesn't have PARENT field then don't create Account
# For example "Profit & Loss A/c"
if account.PARENT:
yield account.PARENT.string.strip(), account["NAME"], 0
def get_parent(account):
if account.PARENT:
return account.PARENT.string.strip()
return {
("Yes", "No"): "Application of Funds (Assets)",
("Yes", "Yes"): "Expenses",
("No", "Yes"): "Income",
("No", "No"): "Source of Funds (Liabilities)",
}[(account.ISDEEMEDPOSITIVE.string.strip(), account.ISREVENUE.string.strip())]
def get_children_and_parent_dict(accounts):
children, parents = {}, {}
for parent, account, _is_group in accounts:
children.setdefault(parent, set()).add(account)
parents.setdefault(account, set()).add(parent)
parents[account].update(parents.get(parent, []))
return children, parents
def remove_parties(parents, children, group_set):
customers, suppliers = set(), set()
for account in parents:
found = False
if self.tally_creditors_account in parents[account]:
found = True
if account not in group_set:
suppliers.add(account)
if self.tally_debtors_account in parents[account]:
found = True
if account not in group_set:
customers.add(account)
if found:
children.pop(account, None)
return children, customers, suppliers
def traverse(tree, children, accounts, roots, group_set):
for account in accounts:
if account in group_set or account in roots:
if account in children:
tree[account] = traverse({}, children, children[account], roots, group_set)
else:
tree[account] = {"is_group": 1}
else:
tree[account] = {}
return tree
def get_parties_addresses(collection, customers, suppliers):
parties, addresses = [], []
for account in collection.find_all("LEDGER"):
party_type = None
links = []
if account.NAME.string.strip() in customers:
party_type = "Customer"
parties.append(
{
"doctype": party_type,
"customer_name": account.NAME.string.strip(),
"tax_id": account.INCOMETAXNUMBER.string.strip()
if account.INCOMETAXNUMBER
else None,
"customer_group": "All Customer Groups",
"territory": "All Territories",
"customer_type": "Individual",
}
)
links.append({"link_doctype": party_type, "link_name": account["NAME"]})
if account.NAME.string.strip() in suppliers:
party_type = "Supplier"
parties.append(
{
"doctype": party_type,
"supplier_name": account.NAME.string.strip(),
"pan": account.INCOMETAXNUMBER.string.strip()
if account.INCOMETAXNUMBER
else None,
"supplier_group": "All Supplier Groups",
"supplier_type": "Individual",
}
)
links.append({"link_doctype": party_type, "link_name": account["NAME"]})
if party_type:
address = "\n".join([a.string.strip() for a in account.find_all("ADDRESS")])
addresses.append(
{
"doctype": "Address",
"address_line1": address[:140].strip(),
"address_line2": address[140:].strip(),
"country": account.COUNTRYNAME.string.strip() if account.COUNTRYNAME else None,
"state": account.LEDSTATENAME.string.strip() if account.LEDSTATENAME else None,
"gst_state": account.LEDSTATENAME.string.strip()
if account.LEDSTATENAME
else None,
"pin_code": account.PINCODE.string.strip() if account.PINCODE else None,
"mobile": account.LEDGERPHONE.string.strip() if account.LEDGERPHONE else None,
"phone": account.LEDGERPHONE.string.strip() if account.LEDGERPHONE else None,
"gstin": account.PARTYGSTIN.string.strip() if account.PARTYGSTIN else None,
"links": links,
}
)
return parties, addresses
def get_stock_items_uoms(collection):
uoms = []
for uom in collection.find_all("UNIT"):
uoms.append({"doctype": "UOM", "uom_name": uom.NAME.string.strip()})
items = []
for item in collection.find_all("STOCKITEM"):
stock_uom = item.BASEUNITS.string.strip() if item.BASEUNITS else self.default_uom
items.append(
{
"doctype": "Item",
"item_code": item.NAME.string.strip(),
"stock_uom": stock_uom.strip(),
"is_stock_item": 0,
"item_group": "All Item Groups",
"item_defaults": [{"company": self.erpnext_company}],
}
)
return items, uoms
try:
self.publish("Process Master Data", _("Reading Uploaded File"), 1, 5)
collection = self.get_collection(self.master_data)
company = get_company_name(collection)
self.tally_company = company
self.erpnext_company = company
self.publish("Process Master Data", _("Processing Chart of Accounts and Parties"), 2, 5)
chart_of_accounts, customers, suppliers = get_coa_customers_suppliers(collection)
self.publish("Process Master Data", _("Processing Party Addresses"), 3, 5)
parties, addresses = get_parties_addresses(collection, customers, suppliers)
self.publish("Process Master Data", _("Processing Items and UOMs"), 4, 5)
items, uoms = get_stock_items_uoms(collection)
data = {
"chart_of_accounts": chart_of_accounts,
"parties": parties,
"addresses": addresses,
"items": items,
"uoms": uoms,
}
self.publish("Process Master Data", _("Done"), 5, 5)
self.dump_processed_data(data)
self.is_master_data_processed = 1
except Exception:
self.publish("Process Master Data", _("Process Failed"), -1, 5)
self.log()
finally:
self.set_status()
def publish(self, title, message, count, total):
frappe.publish_realtime(
"tally_migration_progress_update",
{"title": title, "message": message, "count": count, "total": total},
user=self.modified_by,
)
def _import_master_data(self):
def create_company_and_coa(coa_file_url):
coa_file = frappe.get_doc("File", {"file_url": coa_file_url})
frappe.local.flags.ignore_chart_of_accounts = True
try:
company = frappe.get_doc(
{
"doctype": "Company",
"company_name": self.erpnext_company,
"default_currency": "INR",
"enable_perpetual_inventory": 0,
}
).insert()
except frappe.DuplicateEntryError:
company = frappe.get_doc("Company", self.erpnext_company)
unset_existing_data(self.erpnext_company)
frappe.local.flags.ignore_chart_of_accounts = False
create_charts(company.name, custom_chart=json.loads(coa_file.get_content()))
company.create_default_warehouses()
def create_parties_and_addresses(parties_file_url, addresses_file_url):
parties_file = frappe.get_doc("File", {"file_url": parties_file_url})
for party in json.loads(parties_file.get_content()):
try:
party_doc = frappe.get_doc(party)
party_doc.insert()
except Exception:
self.log(party_doc)
addresses_file = frappe.get_doc("File", {"file_url": addresses_file_url})
for address in json.loads(addresses_file.get_content()):
try:
address_doc = frappe.get_doc(address)
address_doc.insert(ignore_mandatory=True)
except Exception:
self.log(address_doc)
def create_items_uoms(items_file_url, uoms_file_url):
uoms_file = frappe.get_doc("File", {"file_url": uoms_file_url})
for uom in json.loads(uoms_file.get_content()):
if not frappe.db.exists(uom):
try:
uom_doc = frappe.get_doc(uom)
uom_doc.insert()
except Exception:
self.log(uom_doc)
items_file = frappe.get_doc("File", {"file_url": items_file_url})
for item in json.loads(items_file.get_content()):
try:
item_doc = frappe.get_doc(item)
item_doc.insert()
except Exception:
self.log(item_doc)
try:
self.publish("Import Master Data", _("Creating Company and Importing Chart of Accounts"), 1, 4)
create_company_and_coa(self.chart_of_accounts)
self.publish("Import Master Data", _("Importing Parties and Addresses"), 2, 4)
create_parties_and_addresses(self.parties, self.addresses)
self.publish("Import Master Data", _("Importing Items and UOMs"), 3, 4)
create_items_uoms(self.items, self.uoms)
self.publish("Import Master Data", _("Done"), 4, 4)
self.set_account_defaults()
self.is_master_data_imported = 1
frappe.db.commit()
except Exception:
self.publish("Import Master Data", _("Process Failed"), -1, 5)
frappe.db.rollback()
self.log()
finally:
self.set_status()
def _process_day_book_data(self):
def get_vouchers(collection):
vouchers = []
for voucher in collection.find_all("VOUCHER"):
if voucher.ISCANCELLED.string.strip() == "Yes":
continue
inventory_entries = (
voucher.find_all("INVENTORYENTRIES.LIST")
+ voucher.find_all("ALLINVENTORYENTRIES.LIST")
+ voucher.find_all("INVENTORYENTRIESIN.LIST")
+ voucher.find_all("INVENTORYENTRIESOUT.LIST")
)
if (
voucher.VOUCHERTYPENAME.string.strip() not in ["Journal", "Receipt", "Payment", "Contra"]
and inventory_entries
):
function = voucher_to_invoice
else:
function = voucher_to_journal_entry
try:
processed_voucher = function(voucher)
if processed_voucher:
vouchers.append(processed_voucher)
frappe.db.commit()
except Exception:
frappe.db.rollback()
self.log(voucher)
return vouchers
def voucher_to_journal_entry(voucher):
accounts = []
ledger_entries = voucher.find_all("ALLLEDGERENTRIES.LIST") + voucher.find_all(
"LEDGERENTRIES.LIST"
)
for entry in ledger_entries:
account = {
"account": encode_company_abbr(entry.LEDGERNAME.string.strip(), self.erpnext_company),
"cost_center": self.default_cost_center,
}
if entry.ISPARTYLEDGER.string.strip() == "Yes":
party_details = get_party(entry.LEDGERNAME.string.strip())
if party_details:
party_type, party_account = party_details
account["party_type"] = party_type
account["account"] = party_account
account["party"] = entry.LEDGERNAME.string.strip()
amount = Decimal(entry.AMOUNT.string.strip())
if amount > 0:
account["credit_in_account_currency"] = str(abs(amount))
else:
account["debit_in_account_currency"] = str(abs(amount))
accounts.append(account)
journal_entry = {
"doctype": "Journal Entry",
"tally_guid": voucher.GUID.string.strip(),
"tally_voucher_no": voucher.VOUCHERNUMBER.string.strip() if voucher.VOUCHERNUMBER else "",
"posting_date": voucher.DATE.string.strip(),
"company": self.erpnext_company,
"accounts": accounts,
}
return journal_entry
def voucher_to_invoice(voucher):
if voucher.VOUCHERTYPENAME.string.strip() in ["Sales", "Credit Note"]:
doctype = "Sales Invoice"
party_field = "customer"
account_field = "debit_to"
account_name = encode_company_abbr(self.tally_debtors_account, self.erpnext_company)
price_list_field = "selling_price_list"
elif voucher.VOUCHERTYPENAME.string.strip() in ["Purchase", "Debit Note"]:
doctype = "Purchase Invoice"
party_field = "supplier"
account_field = "credit_to"
account_name = encode_company_abbr(self.tally_creditors_account, self.erpnext_company)
price_list_field = "buying_price_list"
else:
# Do not handle vouchers other than "Purchase", "Debit Note", "Sales" and "Credit Note"
# Do not handle Custom Vouchers either
return
invoice = {
"doctype": doctype,
party_field: voucher.PARTYNAME.string.strip(),
"tally_guid": voucher.GUID.string.strip(),
"tally_voucher_no": voucher.VOUCHERNUMBER.string.strip() if voucher.VOUCHERNUMBER else "",
"posting_date": voucher.DATE.string.strip(),
"due_date": voucher.DATE.string.strip(),
"items": get_voucher_items(voucher, doctype),
"taxes": get_voucher_taxes(voucher),
account_field: account_name,
price_list_field: "Tally Price List",
"set_posting_time": 1,
"disable_rounded_total": 1,
"company": self.erpnext_company,
}
return invoice
def get_voucher_items(voucher, doctype):
inventory_entries = (
voucher.find_all("INVENTORYENTRIES.LIST")
+ voucher.find_all("ALLINVENTORYENTRIES.LIST")
+ voucher.find_all("INVENTORYENTRIESIN.LIST")
+ voucher.find_all("INVENTORYENTRIESOUT.LIST")
)
if doctype == "Sales Invoice":
account_field = "income_account"
elif doctype == "Purchase Invoice":
account_field = "expense_account"
items = []
for entry in inventory_entries:
qty, uom = entry.ACTUALQTY.string.strip().split()
items.append(
{
"item_code": entry.STOCKITEMNAME.string.strip(),
"description": entry.STOCKITEMNAME.string.strip(),
"qty": qty.strip(),
"uom": uom.strip(),
"conversion_factor": 1,
"price_list_rate": entry.RATE.string.strip().split("/")[0],
"cost_center": self.default_cost_center,
"warehouse": self.default_warehouse,
account_field: encode_company_abbr(
entry.find_all("ACCOUNTINGALLOCATIONS.LIST")[0].LEDGERNAME.string.strip(),
self.erpnext_company,
),
}
)
return items
def get_voucher_taxes(voucher):
ledger_entries = voucher.find_all("ALLLEDGERENTRIES.LIST") + voucher.find_all(
"LEDGERENTRIES.LIST"
)
taxes = []
for entry in ledger_entries:
if entry.ISPARTYLEDGER.string.strip() == "No":
tax_account = encode_company_abbr(entry.LEDGERNAME.string.strip(), self.erpnext_company)
taxes.append(
{
"charge_type": "Actual",
"account_head": tax_account,
"description": tax_account,
"tax_amount": entry.AMOUNT.string.strip(),
"cost_center": self.default_cost_center,
}
)
return taxes
def get_party(party):
if frappe.db.exists({"doctype": "Supplier", "supplier_name": party}):
return "Supplier", encode_company_abbr(self.tally_creditors_account, self.erpnext_company)
elif frappe.db.exists({"doctype": "Customer", "customer_name": party}):
return "Customer", encode_company_abbr(self.tally_debtors_account, self.erpnext_company)
try:
self.publish("Process Day Book Data", _("Reading Uploaded File"), 1, 3)
collection = self.get_collection(self.day_book_data)
self.publish("Process Day Book Data", _("Processing Vouchers"), 2, 3)
vouchers = get_vouchers(collection)
self.publish("Process Day Book Data", _("Done"), 3, 3)
self.dump_processed_data({"vouchers": vouchers})
self.is_day_book_data_processed = 1
except Exception:
self.publish("Process Day Book Data", _("Process Failed"), -1, 5)
self.log()
finally:
self.set_status()
def _import_day_book_data(self):
def create_fiscal_years(vouchers):
from frappe.utils.data import add_years, getdate
earliest_date = getdate(min(voucher["posting_date"] for voucher in vouchers))
oldest_year = frappe.get_all(
"Fiscal Year", fields=["year_start_date", "year_end_date"], order_by="year_start_date"
)[0]
while earliest_date < oldest_year.year_start_date:
new_year = frappe.get_doc({"doctype": "Fiscal Year"})
new_year.year_start_date = add_years(oldest_year.year_start_date, -1)
new_year.year_end_date = add_years(oldest_year.year_end_date, -1)
if new_year.year_start_date.year == new_year.year_end_date.year:
new_year.year = new_year.year_start_date.year
else:
new_year.year = f"{new_year.year_start_date.year}-{new_year.year_end_date.year}"
new_year.save()
oldest_year = new_year
def create_custom_fields():
_create_custom_fields(
{
("Journal Entry", "Purchase Invoice", "Sales Invoice"): [
{
"fieldtype": "Data",
"fieldname": "tally_guid",
"read_only": 1,
"label": "Tally GUID",
},
{
"fieldtype": "Data",
"fieldname": "tally_voucher_no",
"read_only": 1,
"label": "Tally Voucher Number",
},
]
}
)
def create_price_list():
frappe.get_doc(
{
"doctype": "Price List",
"price_list_name": "Tally Price List",
"selling": 1,
"buying": 1,
"enabled": 1,
"currency": "INR",
}
).insert()
try:
frappe.db.set_value(
"Account",
encode_company_abbr(self.tally_creditors_account, self.erpnext_company),
"account_type",
"Payable",
)
frappe.db.set_value(
"Account",
encode_company_abbr(self.tally_debtors_account, self.erpnext_company),
"account_type",
"Receivable",
)
frappe.db.set_value(
"Company", self.erpnext_company, "round_off_account", self.default_round_off_account
)
vouchers_file = frappe.get_doc("File", {"file_url": self.vouchers})
vouchers = json.loads(vouchers_file.get_content())
create_fiscal_years(vouchers)
create_price_list()
create_custom_fields()
total = len(vouchers)
is_last = False
for index in range(0, total, VOUCHER_CHUNK_SIZE):
if index + VOUCHER_CHUNK_SIZE >= total:
is_last = True
frappe.enqueue_doc(
self.doctype,
self.name,
"_import_vouchers",
queue="long",
timeout=3600,
start=index + 1,
total=total,
is_last=is_last,
)
except Exception:
self.log()
finally:
self.set_status()
def _import_vouchers(self, start, total, is_last=False):
frappe.flags.in_migrate = True
vouchers_file = frappe.get_doc("File", {"file_url": self.vouchers})
vouchers = json.loads(vouchers_file.get_content())
chunk = vouchers[start : start + VOUCHER_CHUNK_SIZE]
for index, voucher in enumerate(chunk, start=start):
try:
voucher_doc = frappe.get_doc(voucher)
voucher_doc.insert()
voucher_doc.submit()
self.publish("Importing Vouchers", _("{} of {}").format(index, total), index, total)
frappe.db.commit()
except Exception:
frappe.db.rollback()
self.log(voucher_doc)
if is_last:
self.status = ""
self.is_day_book_data_imported = 1
self.save()
frappe.db.set_value("Price List", "Tally Price List", "enabled", 0)
frappe.flags.in_migrate = False
@frappe.whitelist()
def process_master_data(self):
self.set_status("Processing Master Data")
frappe.enqueue_doc(self.doctype, self.name, "_process_master_data", queue="long", timeout=3600)
@frappe.whitelist()
def import_master_data(self):
self.set_status("Importing Master Data")
frappe.enqueue_doc(self.doctype, self.name, "_import_master_data", queue="long", timeout=3600)
@frappe.whitelist()
def process_day_book_data(self):
self.set_status("Processing Day Book Data")
frappe.enqueue_doc(self.doctype, self.name, "_process_day_book_data", queue="long", timeout=3600)
@frappe.whitelist()
def import_day_book_data(self):
self.set_status("Importing Day Book Data")
frappe.enqueue_doc(self.doctype, self.name, "_import_day_book_data", queue="long", timeout=3600)
def log(self, data=None):
if isinstance(data, frappe.model.document.Document):
if sys.exc_info()[1].__class__ != frappe.DuplicateEntryError:
failed_import_log = json.loads(self.failed_import_log)
doc = data.as_dict()
failed_import_log.append({"doc": doc, "exc": traceback.format_exc()})
self.failed_import_log = json.dumps(failed_import_log, separators=(",", ":"))
self.save()
frappe.db.commit()
else:
data = data or self.status
message = "\n".join(
[
"Data:",
json.dumps(data, default=str, indent=4),
"--" * 50,
"\nException:",
traceback.format_exc(),
]
)
return frappe.log_error(title="Tally Migration Error", message=message)
def set_status(self, status=""):
self.status = status
self.save()

View File

@@ -0,0 +1,8 @@
# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import unittest
class TestTallyMigration(unittest.TestCase):
pass

View File

@@ -412,6 +412,7 @@ scheduler_events = {
"cron": {
"0/15 * * * *": [
"erpnext.manufacturing.doctype.bom_update_log.bom_update_log.resume_bom_cost_update_jobs",
"erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation.trigger_reconciliation_for_queued_docs",
],
"0/30 * * * *": [
"erpnext.utilities.doctype.video.video.update_youtube_data",

View File

@@ -309,9 +309,6 @@ class JobCard(Document):
return overlap
def get_time_logs(self, args, doctype, open_job_cards=None):
if get_datetime(args.from_time) >= get_datetime(args.to_time):
args.to_time = add_to_date(args.from_time, minutes=args.remaining_time_in_mins)
jc = frappe.qb.DocType("Job Card")
jctl = frappe.qb.DocType(doctype)
@@ -357,10 +354,8 @@ class JobCard(Document):
else:
query = query.where(jc.name.isin(open_job_cards))
if doctype == "Job Card Time Log":
query = query.where(jc.docstatus < 2)
else:
query = query.where((jc.docstatus == 0) & (jc.total_time_in_mins == 0))
if doctype != "Job Card Time Log":
query = query.where(jc.total_time_in_mins == 0)
time_logs = query.run(as_dict=True)
@@ -417,13 +412,7 @@ class JobCard(Document):
def schedule_time_logs(self, row):
row.remaining_time_in_mins = row.time_in_mins
while row.remaining_time_in_mins > 0:
args = frappe._dict(
{
"from_time": row.planned_start_time,
"to_time": row.planned_end_time,
"remaining_time_in_mins": row.remaining_time_in_mins,
}
)
args = frappe._dict({"from_time": row.planned_start_time, "to_time": row.planned_end_time})
self.validate_overlap_for_workstation(args, row)
self.check_workstation_time(row)

View File

@@ -522,7 +522,6 @@ class TestJobCard(FrappeTestCase):
production_item=item_code,
bom_no=bom_doc.name,
skip_transfer=1,
from_wip_warehouse=1,
wip_warehouse=warehouse,
source_warehouse=warehouse,
)

View File

@@ -207,7 +207,7 @@
"description": "In the case of 'Use Multi-Level BOM' in a work order, if the user wishes to add sub-assembly costs to Finished Goods items without using a job card as well the scrap items, then this option needs to be enable.",
"fieldname": "set_op_cost_and_scrape_from_sub_assemblies",
"fieldtype": "Check",
"label": "Set Operating Cost / Scrap Items From Sub-assemblies"
"label": "Set Operating Cost / Scrape Items From Sub-assemblies"
},
{
"default": "0",
@@ -249,7 +249,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2025-01-09 16:02:23.326763",
"modified": "2025-01-02 12:46:33.520853",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Manufacturing Settings",

View File

@@ -744,9 +744,9 @@ class TestProductionPlan(FrappeTestCase):
"""
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=2, basic_rate=100)
make_stock_entry(item_code="_Test Item", target="Work In Progress - _TC", qty=2, basic_rate=100)
make_stock_entry(
item_code="_Test Item Home Desktop 100", target="_Test Warehouse - _TC", qty=4, basic_rate=100
item_code="_Test Item Home Desktop 100", target="Work In Progress - _TC", qty=4, basic_rate=100
)
item = "_Test FG Item"
@@ -794,10 +794,10 @@ class TestProductionPlan(FrappeTestCase):
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
make_stock_entry(
item_code="Raw Material Item 1", target="_Test Warehouse - _TC", qty=2, basic_rate=100
item_code="Raw Material Item 1", target="Work In Progress - _TC", qty=2, basic_rate=100
)
make_stock_entry(
item_code="Raw Material Item 2", target="_Test Warehouse - _TC", qty=2, basic_rate=100
item_code="Raw Material Item 2", target="Work In Progress - _TC", qty=2, basic_rate=100
)
pln = create_production_plan(item_code="Test Production Item 1", skip_getting_mr_items=True)

View File

@@ -714,12 +714,7 @@ class TestWorkOrder(FrappeTestCase):
self.assertEqual(row.item_code, fg_item)
work_order = make_wo_order_test_record(
item=fg_item,
skip_transfer=True,
planned_start_date=now(),
qty=30,
do_not_save=True,
source_warehouse="_Test Warehouse - _TC",
item=fg_item, skip_transfer=True, planned_start_date=now(), qty=30, do_not_save=True
)
work_order.batch_size = 10
work_order.insert()
@@ -936,13 +931,11 @@ class TestWorkOrder(FrappeTestCase):
wip_warehouse=wip_warehouse,
qty=qty,
skip_transfer=1,
source_warehouse=wip_warehouse,
stock_uom=fg_item_non_whole.stock_uom,
)
se = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", qty))
se.get("items")[0].s_warehouse = "Stores - _TC"
se.get("items")[0].t_warehouse = wip_warehouse
se.insert()
se.submit()
@@ -1014,12 +1007,7 @@ class TestWorkOrder(FrappeTestCase):
bom.submit()
wo_order = make_wo_order_test_record(
item=item,
company=company,
planned_start_date=now(),
qty=20,
skip_transfer=1,
from_wip_warehouse=1,
item=item, company=company, planned_start_date=now(), qty=20, skip_transfer=1
)
job_card = frappe.db.get_value("Job Card", {"work_order": wo_order.name}, "name")
update_job_card(job_card)
@@ -1031,12 +1019,7 @@ class TestWorkOrder(FrappeTestCase):
# Partial Job Card 1 with qty 10
wo_order = make_wo_order_test_record(
item=item,
company=company,
planned_start_date=add_days(now(), 60),
qty=20,
skip_transfer=1,
from_wip_warehouse=1,
item=item, company=company, planned_start_date=add_days(now(), 60), qty=20, skip_transfer=1
)
job_card = frappe.db.get_value("Job Card", {"work_order": wo_order.name}, "name")
update_job_card(job_card, 10, 1)
@@ -2062,8 +2045,6 @@ class TestWorkOrder(FrappeTestCase):
bom_no=bom_doc.name,
qty=1,
skip_transfer=1,
from_wip_warehouse=1,
source_warehouse="_Test Warehouse - _TC",
)
job_cards = frappe.get_all("Job Card", filters={"work_order": wo.name})
@@ -2470,37 +2451,6 @@ class TestWorkOrder(FrappeTestCase):
frappe.db.set_single_value("Manufacturing Settings", "validate_components_quantities_per_bom", 0)
def test_wip_skip(self):
wo = make_wo_order_test_record(
item="_Test FG Item",
qty=10,
source_warehouse="_Test Warehouse - _TC",
wip_warehouse="Stores - _TC",
)
manufacture_entry = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 10))
self.assertEqual(manufacture_entry.items[0].s_warehouse, "Stores - _TC")
wo = make_wo_order_test_record(
item="_Test FG Item",
qty=10,
source_warehouse="_Test Warehouse - _TC",
wip_warehouse="Stores - _TC",
skip_transfer=1,
)
manufacture_entry = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 10))
self.assertEqual(manufacture_entry.items[0].s_warehouse, "_Test Warehouse - _TC")
wo = make_wo_order_test_record(
item="_Test FG Item",
qty=10,
source_warehouse="_Test Warehouse - _TC",
wip_warehouse="Stores - _TC",
skip_transfer=1,
from_wip_warehouse=1,
)
manufacture_entry = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 10))
self.assertEqual(manufacture_entry.items[0].s_warehouse, "Stores - _TC")
def make_operation(**kwargs):
kwargs = frappe._dict(kwargs)

View File

@@ -152,7 +152,6 @@ class WorkOrder(Document):
self.validate_sales_order()
self.set_default_warehouse()
self.validate_warehouse_belongs_to_company()
self.check_wip_warehouse_skip()
self.calculate_operating_cost()
self.validate_qty()
self.validate_transfer_against()
@@ -252,10 +251,6 @@ class WorkOrder(Document):
if not self.fg_warehouse:
self.fg_warehouse = frappe.db.get_single_value("Manufacturing Settings", "default_fg_warehouse")
def check_wip_warehouse_skip(self):
if self.skip_transfer and not self.from_wip_warehouse:
self.wip_warehouse = None
def validate_warehouse_belongs_to_company(self):
warehouses = [self.fg_warehouse, self.wip_warehouse]
for d in self.get("required_items"):
@@ -1426,11 +1421,7 @@ def make_stock_entry(work_order_id, purpose, qty=None, target_warehouse=None):
stock_entry.to_warehouse = wip_warehouse
stock_entry.project = work_order.project
else:
stock_entry.from_warehouse = (
work_order.source_warehouse
if work_order.skip_transfer and not work_order.from_wip_warehouse
else wip_warehouse
)
stock_entry.from_warehouse = wip_warehouse
stock_entry.to_warehouse = work_order.fg_warehouse
stock_entry.project = work_order.project

View File

@@ -50,11 +50,7 @@ def get_returned_materials(work_orders):
raw_materials = frappe.get_all(
"Stock Entry",
fields=[
"`tabStock Entry`.`work_order`",
"`tabStock Entry Detail`.`item_code`",
"`tabStock Entry Detail`.`qty`",
],
fields=["`tabStock Entry Detail`.`item_code`", "`tabStock Entry Detail`.`qty`"],
filters=[
["Stock Entry", "is_return", "=", 1],
["Stock Entry Detail", "docstatus", "=", 1],
@@ -63,14 +59,12 @@ def get_returned_materials(work_orders):
)
for d in raw_materials:
key = (d.work_order, d.item_code)
raw_materials_qty[key] += d.qty
raw_materials_qty[d.item_code] += d.qty
for row in work_orders:
row.returned_qty = 0.0
key = (row.parent, row.raw_material_item_code)
if raw_materials_qty.get(key):
row.returned_qty = raw_materials_qty.get(key)
if raw_materials_qty.get(row.raw_material_item_code):
row.returned_qty = raw_materials_qty.get(row.raw_material_item_code)
def get_fields():

View File

@@ -386,6 +386,3 @@ erpnext.patches.v14_0.update_stock_uom_in_work_order_item
erpnext.patches.v15_0.set_is_exchange_gain_loss_in_payment_entry_deductions
erpnext.patches.v15_0.enable_allow_existing_serial_no
erpnext.patches.v15_0.update_cc_in_process_statement_of_accounts
erpnext.patches.v15_0.update_asset_status_to_work_in_progress
erpnext.patches.v15_0.migrate_checkbox_to_select_for_reconciliation_effect
erpnext.patches.v15_0.sync_auto_reconcile_config

View File

@@ -1,18 +0,0 @@
import frappe
def execute():
"""
A New select field 'reconciliation_takes_effect_on' has been added to control Advance Payment Reconciliation dates.
Migrate old checkbox configuration to new select field on 'Company' and 'Payment Entry'
"""
companies = frappe.db.get_all("Company", fields=["name", "reconciliation_takes_effect_on"])
for x in companies:
new_value = (
"Advance Payment Date" if x.reconcile_on_advance_payment_date else "Oldest Of Invoice Or Advance"
)
frappe.db.set_value("Company", x.name, "reconciliation_takes_effect_on", new_value)
frappe.db.sql(
"""update `tabPayment Entry` set advance_reconciliation_takes_effect_on = if(reconcile_on_advance_payment_date = 0, 'Oldest Of Invoice Or Advance', 'Advance Payment Date')"""
)

View File

@@ -1,26 +0,0 @@
import frappe
from erpnext.accounts.utils import sync_auto_reconcile_config
def execute():
"""
Set default Cron Interval and Queue size
"""
frappe.db.set_single_value("Accounts Settings", "auto_reconciliation_job_trigger", 15)
frappe.db.set_single_value("Accounts Settings", "reconciliation_queue_size", 5)
# Create Scheduler Event record if it doesn't exist
method = "erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation.trigger_reconciliation_for_queued_docs"
if not frappe.db.get_all(
"Scheduler Event", {"scheduled_against": "Process Payment Reconciliation", "method": method}
):
frappe.get_doc(
{
"doctype": "Scheduler Event",
"scheduled_against": "Process Payment Reconciliation",
"method": method,
}
).save()
sync_auto_reconcile_config(15)

View File

@@ -1,11 +0,0 @@
import frappe
def execute():
Asset = frappe.qb.DocType("Asset")
query = (
frappe.qb.update(Asset)
.set(Asset.status, "Work In Progress")
.where((Asset.docstatus == 0) & (Asset.is_composite_asset == 1))
)
query.run()

View File

@@ -147,32 +147,29 @@ frappe.ui.form.on("Project", {
set_project_status_button: function (frm) {
frm.add_custom_button(
__("Set Project Status"),
() => frm.events.get_project_status_dialog(frm).show(),
() => {
let d = new frappe.ui.Dialog({
title: __("Set Project Status"),
fields: [
{
fieldname: "status",
fieldtype: "Select",
label: "Status",
reqd: 1,
options: "Completed\nCancelled",
},
],
primary_action: function () {
frm.events.set_status(frm, d.get_values().status);
d.hide();
},
primary_action_label: __("Set Project Status"),
}).show();
},
__("Actions")
);
},
get_project_status_dialog: function (frm) {
const dialog = new frappe.ui.Dialog({
title: __("Set Project Status"),
fields: [
{
fieldname: "status",
fieldtype: "Select",
label: "Status",
reqd: 1,
options: "Completed\nCancelled",
},
],
primary_action: function () {
frm.events.set_status(frm, dialog.get_values().status);
dialog.hide();
},
primary_action_label: __("Set Project Status"),
});
return dialog;
},
create_duplicate: function (frm) {
return new Promise((resolve) => {
frappe.prompt("Project Name", (data) => {
@@ -191,7 +188,7 @@ frappe.ui.form.on("Project", {
},
set_status: function (frm, status) {
frappe.confirm(__("Set Project and all Tasks to status {0}?", [__(status).bold()]), () => {
frappe.confirm(__("Set Project and all Tasks to status {0}?", [status.bold()]), () => {
frappe
.xcall("erpnext.projects.doctype.project.project.set_project_status", {
project: frm.doc.name,

View File

@@ -166,7 +166,7 @@ class Timesheet(Document):
if data.task and data.task not in tasks:
task = frappe.get_doc("Task", data.task)
task.update_time_and_costing()
task.save(ignore_permissions=True)
task.save()
tasks.append(data.task)
if data.project and data.project not in projects:
@@ -175,7 +175,7 @@ class Timesheet(Document):
for project in projects:
project_doc = frappe.get_doc("Project", project)
project_doc.update_project()
project_doc.save(ignore_permissions=True)
project_doc.save()
def validate_dates(self):
for data in self.time_logs:

View File

@@ -16,7 +16,7 @@ erpnext.accounts.bank_reconciliation.DataTableManager = class DataTableManager {
}
make_dt() {
const me = this;
var me = this;
frappe.call({
method: "erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_bank_transactions",
args: {
@@ -193,7 +193,6 @@ erpnext.accounts.bank_reconciliation.DataTableManager = class DataTableManager {
args: {
bank_account: this.bank_account,
till_date: this.bank_statement_to_date,
company: this.company,
},
callback: (response) => (this.cleared_balance = response.message),
});

View File

@@ -157,17 +157,10 @@ erpnext.buying = {
if(!frappe.meta.has_field(this.frm.doc.doctype, "billing_address")) return;
frappe.call({
method: "erpnext.setup.doctype.company.company.get_billing_shipping_address",
args: {
name: this.frm.doc.company,
billing_address:this.frm.doc.billing_address,
shipping_address: this.frm.doc.shipping_address
},
method: "erpnext.setup.doctype.company.company.get_default_company_address",
args: { name: this.frm.doc.company, existing_address:this.frm.doc.billing_address },
callback: (r) => {
this.frm.set_value("billing_address", r.message.primary_address || "");
if(!frappe.meta.has_field(this.frm.doc.doctype, "shipping_address")) return;
this.frm.set_value("shipping_address", r.message.shipping_address || "");
this.frm.set_value("billing_address", r.message || "");
},
});
}

View File

@@ -302,16 +302,12 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
return;
}
let show_qc_button = true;
if (["Sales Invoice", "Purchase Invoice"].includes(this.frm.doc.doctype)) {
show_qc_button = this.frm.doc.update_stock;
}
const me = this;
if (!this.frm.is_new() && this.frm.doc.docstatus === 0 && frappe.model.can_create("Quality Inspection") && show_qc_button) {
if (!this.frm.is_new() && this.frm.doc.docstatus === 0 && frappe.model.can_create("Quality Inspection")) {
this.frm.add_custom_button(__("Quality Inspection(s)"), () => {
me.make_quality_inspection();
}, __("Create"));
this.frm.page.set_inner_btn_group_as_primary(__('Create'));
}
const inspection_type = ["Purchase Receipt", "Purchase Invoice", "Subcontracting Receipt"].includes(this.frm.doc.doctype)
@@ -579,8 +575,6 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
child_doctype: item.doctype,
child_docname: item.name,
is_old_subcontracting_flow: me.frm.doc.is_old_subcontracting_flow,
use_serial_batch_fields: item.use_serial_batch_fields,
serial_and_batch_bundle: item.serial_and_batch_bundle,
}
},
@@ -980,6 +974,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
}
transaction_date() {
this.apply_pricing_rule()
if (this.frm.doc.transaction_date) {
this.frm.transaction_date = this.frm.doc.transaction_date;
frappe.ui.form.trigger(this.frm.doc.doctype, "currency");
@@ -988,6 +983,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
posting_date() {
var me = this;
me.apply_pricing_rule()
if (this.frm.doc.posting_date) {
this.frm.posting_date = this.frm.doc.posting_date;
@@ -1827,16 +1823,18 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
apply_rule_on_other_items(args) {
const me = this;
const fields = ["pricing_rules"];
const fields = ["discount_percentage", "pricing_rules", "discount_amount", "rate"];
for(var k in args) {
let data = args[k];
if (data && data.apply_rule_on_other_items && JSON.parse(data.apply_rule_on_other_items)) {
fields.push(frappe.scrub(data.pricing_rule_for))
me.frm.doc.items.forEach(d => {
if (JSON.parse(data.apply_rule_on_other_items).includes(d[data.apply_rule_on])) {
if (in_list(JSON.parse(data.apply_rule_on_other_items), d[data.apply_rule_on]) && d.item_code === data.item_code) {
for(var k in data) {
if (data.pricing_rule_for == "Discount Percentage" && data.apply_rule_on_other_items && k == "discount_amount") {
continue;
}
if (in_list(fields, k) && data[k] && (data.price_or_product_discount === 'Price' || k === 'pricing_rules')) {
frappe.model.set_value(d.doctype, d.name, k, data[k]);

View File

@@ -411,11 +411,7 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False):
2. If selections: Is Alternative Item/Has Alternative Item: Map if selected and adequate qty
3. If selections: Simple row: Map if adequate qty
"""
balance_qty = item.qty - ordered_items.get(item.item_code, 0.0)
if balance_qty <= 0:
return False
has_qty = balance_qty
has_qty = item.qty > 0
if not selected_rows:
return not item.is_alternative

View File

@@ -30,38 +30,6 @@ class TestQuotation(FrappeTestCase):
self.assertTrue(sales_order.get("payment_schedule"))
def test_do_not_add_ordered_items_in_new_sales_order(self):
from erpnext.selling.doctype.quotation.quotation import make_sales_order
from erpnext.stock.doctype.item.test_item import make_item
item = make_item("_Test Item for Quotation for SO", {"is_stock_item": 1})
quotation = make_quotation(qty=5, do_not_submit=True)
quotation.append(
"items",
{
"item_code": item.name,
"qty": 5,
"rate": 100,
"conversion_factor": 1,
"uom": item.stock_uom,
"warehouse": "_Test Warehouse - _TC",
"stock_uom": item.stock_uom,
},
)
quotation.submit()
sales_order = make_sales_order(quotation.name)
sales_order.delivery_date = nowdate()
self.assertEqual(len(sales_order.items), 2)
sales_order.remove(sales_order.items[1])
sales_order.submit()
sales_order = make_sales_order(quotation.name)
self.assertEqual(len(sales_order.items), 1)
self.assertEqual(sales_order.items[0].item_code, item.name)
self.assertEqual(sales_order.items[0].qty, 5.0)
def test_gross_profit(self):
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry

View File

@@ -1687,13 +1687,13 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase):
wo.submit()
make_stock_entry(
item_code="_Test Item",
target="_Test Warehouse - _TC",
target="Work In Progress - _TC",
qty=4,
basic_rate=100, # Stock RM
)
make_stock_entry(
item_code="_Test Item Home Desktop 100", # Stock RM
target="_Test Warehouse - _TC",
target="Work In Progress - _TC",
qty=4,
basic_rate=100,
)

View File

@@ -64,7 +64,7 @@ def search_by_term(search_term, warehouse, price_list):
}
if batch_no:
price_filters["batch_no"] = ["in", [batch_no, ""]]
price_filters["batch_no"] = batch_no
price = frappe.get_list(
doctype="Item Price",
@@ -74,25 +74,15 @@ def search_by_term(search_term, warehouse, price_list):
def __sort(p):
p_uom = p.get("uom")
p_batch = p.get("batch_no")
batch_no = item.get("batch_no")
if batch_no and p_batch and p_batch == batch_no:
if p_uom == item.get("uom"):
return 0
elif p_uom == item.get("stock_uom"):
return 1
else:
return 2
if p_uom == item.get("uom"):
return 3
return 0
elif p_uom == item.get("stock_uom"):
return 4
return 1
else:
return 5
return 2
# sort by fallback preference. always pick exact uom and batch number match if available
# sort by fallback preference. always pick exact uom match if available
price = sorted(price, key=__sort)
if len(price) > 0:

View File

@@ -210,21 +210,10 @@ erpnext.PointOfSale.ItemDetails = class {
make_auto_serial_selection_btn(item) {
if (item.has_serial_no || item.has_batch_no) {
if (item.has_serial_no && item.has_batch_no) {
this.$form_container.append(
`<div class="btn btn-sm btn-secondary auto-fetch-btn" style="margin-top: 6px">${__(
"Select Serial No / Batch No"
)}</div>`
);
} else {
const classname = item.has_serial_no ? ".serial_no-control" : ".batch_no-control";
const label = item.has_serial_no ? __("Select Serial No") : __("Select Batch No");
this.$form_container
.find(classname)
.append(
`<div class="btn btn-sm btn-secondary auto-fetch-btn" style="margin-top: 6px">${label}</div>`
);
}
const label = item.has_serial_no ? __("Select Serial No") : __("Select Batch No");
this.$form_container.append(
`<div class="btn btn-sm btn-secondary auto-fetch-btn">${label}</div>`
);
this.$form_container.find(".serial_no-control").find("textarea").css("height", "6rem");
}
}

View File

@@ -75,7 +75,6 @@
"advance_payments_section",
"book_advance_payments_in_separate_party_account",
"reconcile_on_advance_payment_date",
"reconciliation_takes_effect_on",
"column_break_fwcf",
"default_advance_received_account",
"default_advance_paid_account",
@@ -797,7 +796,6 @@
"description": "If <b>Enabled</b> - Reconciliation happens on the <b>Advance Payment posting date</b><br>\nIf <b>Disabled</b> - Reconciliation happens on oldest of 2 Dates: <b>Invoice Date</b> or the <b>Advance Payment posting date</b><br>\n",
"fieldname": "reconcile_on_advance_payment_date",
"fieldtype": "Check",
"hidden": 1,
"label": "Reconcile on Advance Payment Date"
},
{
@@ -843,13 +841,6 @@
{
"fieldname": "column_break_dcdl",
"fieldtype": "Column Break"
},
{
"default": "Oldest Of Invoice Or Advance",
"fieldname": "reconciliation_takes_effect_on",
"fieldtype": "Select",
"label": "Reconciliation Takes Effect On",
"options": "Advance Payment Date\nOldest Of Invoice Or Advance\nReconciliation Date"
}
],
"icon": "fa fa-building",
@@ -857,7 +848,7 @@
"image_field": "company_logo",
"is_tree": 1,
"links": [],
"modified": "2025-01-09 20:12:25.471544",
"modified": "2024-12-02 15:37:32.723176",
"modified_by": "Administrator",
"module": "Setup",
"name": "Company",

View File

@@ -87,9 +87,6 @@ class Company(NestedSet):
payment_terms: DF.Link | None
phone_no: DF.Data | None
reconcile_on_advance_payment_date: DF.Check
reconciliation_takes_effect_on: DF.Literal[
"Advance Payment Date", "Oldest Of Invoice Or Advance", "Reconciliation Date"
]
registration_details: DF.Code | None
rgt: DF.Int
round_off_account: DF.Link | None
@@ -917,14 +914,6 @@ def get_default_company_address(name, sort_key="is_primary_address", existing_ad
return None
@frappe.whitelist()
def get_billing_shipping_address(name, billing_address=None, shipping_address=None):
primary_address = get_default_company_address(name, "is_primary_address", billing_address)
shipping_address = get_default_company_address(name, "is_shipping_address", shipping_address)
return {"primary_address": primary_address, "shipping_address": shipping_address}
@frappe.whitelist()
def create_transaction_deletion_request(company):
from erpnext.setup.doctype.transaction_deletion_record.transaction_deletion_record import (

View File

@@ -2,7 +2,7 @@
# License: GNU General Public License v3. See license.txt
from collections import OrderedDict, defaultdict
from collections import defaultdict
import frappe
from frappe import _
@@ -449,14 +449,11 @@ def get_available_batches(kwargs):
get_auto_batch_nos,
)
batchwise_qty = OrderedDict()
batchwise_qty = defaultdict(float)
batches = get_auto_batch_nos(kwargs)
for batch in batches:
if batch.get("batch_no") not in batchwise_qty:
batchwise_qty[batch.get("batch_no")] = batch.get("qty")
else:
batchwise_qty[batch.get("batch_no")] += batch.get("qty")
batchwise_qty[batch.get("batch_no")] += batch.get("qty")
return batchwise_qty

View File

@@ -12,6 +12,7 @@ from frappe.utils import cint, flt
from erpnext.controllers.accounts_controller import get_taxes_and_charges, merge_taxes
from erpnext.controllers.selling_controller import SellingController
from erpnext.stock.doctype.serial_no.serial_no import get_delivery_note_serial_no
form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
@@ -979,6 +980,11 @@ def make_sales_invoice(source_name, target_doc=None, args=None):
def update_item(source_doc, target_doc, source_parent):
target_doc.qty = to_make_invoice_qty_map[source_doc.name]
if source_doc.serial_no and source_parent.per_billed > 0 and not source_parent.is_return:
target_doc.serial_no = get_delivery_note_serial_no(
source_doc.item_code, target_doc.qty, source_parent.name
)
def get_pending_qty(item_row):
pending_qty = item_row.qty - invoiced_qty_map.get(item_row.name, 0)

View File

@@ -2339,143 +2339,6 @@ class TestDeliveryNote(FrappeTestCase):
for d in bundle_data:
self.assertEqual(d.incoming_rate, serial_no_valuation[d.serial_no])
def test_auto_set_serial_batch_for_draft_dn(self):
frappe.db.set_single_value("Stock Settings", "auto_create_serial_and_batch_bundle_for_outward", 1)
frappe.db.set_single_value("Stock Settings", "pick_serial_and_batch_based_on", "FIFO")
batch_item = make_item(
"_Test Auto Set Serial Batch Draft DN",
properties={
"has_batch_no": 1,
"create_new_batch": 1,
"is_stock_item": 1,
"batch_number_series": "TAS-BASD-.#####",
},
)
serial_item = make_item(
"_Test Auto Set Serial Batch Draft DN Serial Item",
properties={"has_serial_no": 1, "is_stock_item": 1, "serial_no_series": "TAS-SASD-.#####"},
)
batch_serial_item = make_item(
"_Test Auto Set Serial Batch Draft DN Batch Serial Item",
properties={
"has_batch_no": 1,
"has_serial_no": 1,
"is_stock_item": 1,
"create_new_batch": 1,
"batch_number_series": "TAS-BSD-.#####",
"serial_no_series": "TAS-SSD-.#####",
},
)
for item in [batch_item, serial_item, batch_serial_item]:
make_stock_entry(item_code=item.name, target="_Test Warehouse - _TC", qty=5, basic_rate=100)
dn = create_delivery_note(
item_code=batch_item,
qty=5,
rate=500,
use_serial_batch_fields=1,
do_not_submit=True,
)
for item in [serial_item, batch_serial_item]:
dn.append(
"items",
{
"item_code": item.name,
"qty": 5,
"rate": 500,
"base_rate": 500,
"item_name": item.name,
"uom": "Nos",
"stock_uom": "Nos",
"conversion_factor": 1,
"warehouse": dn.items[0].warehouse,
"use_serial_batch_fields": 1,
},
)
dn.save()
for row in dn.items:
if row.item_code == batch_item.name:
self.assertTrue(row.batch_no)
if row.item_code == serial_item.name:
self.assertTrue(row.serial_no)
def test_delivery_note_return_for_batch_item_with_different_warehouse(self):
from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
batch_item = make_item(
"_Test Delivery Note Return Valuation WITH Batch Item",
properties={
"has_batch_no": 1,
"create_new_batch": 1,
"is_stock_item": 1,
"batch_number_series": "BRTN-DNN-BIW-.#####",
},
).name
batches = []
for qty, rate in {5: 300}.items():
se = make_stock_entry(
item_code=batch_item, target="_Test Warehouse - _TC", qty=qty, basic_rate=rate
)
batches.append(get_batch_from_bundle(se.items[0].serial_and_batch_bundle))
warehouse = create_warehouse("Sales Return Test Warehouse 1", company="_Test Company")
dn = create_delivery_note(
item_code=batch_item,
qty=5,
rate=1000,
use_serial_batch_fields=1,
batch_no=batches[0],
do_not_submit=True,
)
self.assertEqual(dn.items[0].warehouse, "_Test Warehouse - _TC")
dn.save()
dn.submit()
dn.reload()
batch_no_valuation = defaultdict(float)
for row in dn.items:
if row.serial_and_batch_bundle:
bundle_data = frappe.get_all(
"Serial and Batch Entry",
filters={"parent": row.serial_and_batch_bundle},
fields=["incoming_rate", "serial_no", "batch_no"],
)
for d in bundle_data:
if d.batch_no:
batch_no_valuation[d.batch_no] = d.incoming_rate
return_entry = make_sales_return(dn.name)
return_entry.items[0].warehouse = warehouse
return_entry.save()
return_entry.submit()
return_entry.reload()
for row in return_entry.items:
self.assertEqual(row.warehouse, warehouse)
bundle_data = frappe.get_all(
"Serial and Batch Entry",
filters={"parent": row.serial_and_batch_bundle},
fields=["incoming_rate", "batch_no"],
)
for d in bundle_data:
self.assertEqual(d.incoming_rate, batch_no_valuation[d.batch_no])
def create_delivery_note(**args):
dn = frappe.new_doc("Delivery Note")

View File

@@ -251,7 +251,7 @@ class SerialandBatchBundle(Document):
return
if return_against := self.get_return_against(parent=parent):
self.set_valuation_rate_for_return_entry(return_against, row, save)
self.set_valuation_rate_for_return_entry(return_against, save)
elif self.type_of_transaction == "Outward":
self.set_incoming_rate_for_outward_transaction(
row, save, allow_negative_stock=allow_negative_stock
@@ -259,7 +259,7 @@ class SerialandBatchBundle(Document):
else:
self.set_incoming_rate_for_inward_transaction(row, save)
def set_valuation_rate_for_return_entry(self, return_against, row, save=False):
def set_valuation_rate_for_return_entry(self, return_against, save=False):
if valuation_details := self.get_valuation_rate_for_return_entry(return_against):
for row in self.entries:
if valuation_details:
@@ -281,9 +281,6 @@ class SerialandBatchBundle(Document):
}
)
elif self.type_of_transaction == "Inward":
self.set_incoming_rate_for_inward_transaction(row, save)
def validate_returned_serial_batch_no(self, return_against, row, original_inv_details):
if row.serial_no and row.serial_no not in original_inv_details["serial_nos"]:
self.throw_error_message(
@@ -300,9 +297,6 @@ class SerialandBatchBundle(Document):
)
def get_valuation_rate_for_return_entry(self, return_against):
if not self.voucher_detail_no:
return {}
valuation_details = frappe._dict(
{
"serial_nos": defaultdict(float),
@@ -310,29 +304,6 @@ class SerialandBatchBundle(Document):
}
)
field = {
"Sales Invoice": "sales_invoice_item",
"Purchase Invoice": "purchase_invoice_item",
"Delivery Note": "dn_detail",
"Purchase Receipt": "purchase_receipt_item",
}.get(self.voucher_type)
return_against_voucher_detail_no = frappe.db.get_value(
self.child_table, self.voucher_detail_no, field
)
filters = [
["Serial and Batch Bundle", "voucher_no", "=", return_against],
["Serial and Batch Entry", "docstatus", "=", 1],
["Serial and Batch Bundle", "is_cancelled", "=", 0],
["Serial and Batch Bundle", "item_code", "=", self.item_code],
["Serial and Batch Bundle", "voucher_detail_no", "=", return_against_voucher_detail_no],
]
if self.voucher_type in ["Purchase Receipt", "Purchase Invoice"]:
# Added to handle rejected warehouse case
filters.append(["Serial and Batch Entry", "warehouse", "=", self.warehouse])
bundle_data = frappe.get_all(
"Serial and Batch Bundle",
fields=[
@@ -340,7 +311,13 @@ class SerialandBatchBundle(Document):
"`tabSerial and Batch Entry`.`batch_no`",
"`tabSerial and Batch Entry`.`incoming_rate`",
],
filters=filters,
filters=[
["Serial and Batch Bundle", "voucher_no", "=", return_against],
["Serial and Batch Entry", "docstatus", "=", 1],
["Serial and Batch Bundle", "is_cancelled", "=", 0],
["Serial and Batch Bundle", "item_code", "=", self.item_code],
["Serial and Batch Bundle", "warehouse", "=", self.warehouse],
],
order_by="`tabSerial and Batch Bundle`.`creation`, `tabSerial and Batch Entry`.`idx`",
)
@@ -1580,7 +1557,7 @@ def get_type_of_transaction(parent_doc, child_row):
elif parent_doc.get("doctype") == "Stock Reconciliation":
type_of_transaction = "Inward"
if parent_doc.get("is_return") and parent_doc.get("doctype") != "Stock Entry":
if parent_doc.get("is_return"):
type_of_transaction = "Inward"
if (
parent_doc.get("doctype") in ["Purchase Receipt", "Purchase Invoice"]

View File

@@ -169,6 +169,21 @@ def update_maintenance_status():
frappe.db.set_value("Serial No", doc.name, "maintenance_status", doc.maintenance_status)
def get_delivery_note_serial_no(item_code, qty, delivery_note):
serial_nos = ""
dn_serial_nos = frappe.db.sql_list(
f""" select name from `tabSerial No`
where item_code = %(item_code)s and delivery_document_no = %(delivery_note)s
and sales_invoice is null limit {cint(qty)}""",
{"item_code": item_code, "delivery_note": delivery_note},
)
if dn_serial_nos and len(dn_serial_nos) > 0:
serial_nos = "\n".join(dn_serial_nos)
return serial_nos
@frappe.whitelist()
def auto_fetch_serial_number(
qty: int,

View File

@@ -483,7 +483,7 @@ class StockEntry(StockController):
if self.process_loss_qty:
total += flt(self.process_loss_qty, precision)
if self.fg_completed_qty != flt(total, precision):
if self.fg_completed_qty != total:
frappe.throw(
_(
"The finished product {0} quantity {1} and For Quantity {2} cannot be different"
@@ -610,9 +610,7 @@ class StockEntry(StockController):
completed_qty = (
d.completed_qty + d.process_loss_qty + (allowance_percentage / 100 * d.completed_qty)
)
if flt(total_completed_qty, self.precision("fg_completed_qty")) > flt(
completed_qty, self.precision("fg_completed_qty")
):
if total_completed_qty > flt(completed_qty):
job_card = frappe.db.get_value("Job Card", {"operation_id": d.name}, "name")
if not job_card:
frappe.throw(

View File

@@ -115,13 +115,6 @@ def get_item_details(args, doc=None, for_validate=False, overwrite_warehouse=Tru
out.update(data)
if (
frappe.db.get_single_value("Stock Settings", "auto_create_serial_and_batch_bundle_for_outward")
and not args.get("serial_and_batch_bundle")
and (args.get("use_serial_batch_fields") or args.get("doctype") == "POS Invoice")
):
update_stock(args, out, doc)
if args.transaction_date and item.lead_time_days:
out.schedule_date = out.lead_time_date = add_days(args.transaction_date, item.lead_time_days)
@@ -175,92 +168,6 @@ def update_bin_details(args, out, doc):
out.update(bin_details)
def update_stock(ctx, out, doc=None):
from erpnext.stock.doctype.batch.batch import get_available_batches
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos_for_outward
if (
(
ctx.get("doctype") in ["Delivery Note", "POS Invoice"]
or (ctx.get("doctype") == "Sales Invoice" and ctx.get("update_stock"))
)
and out.warehouse
and out.stock_qty > 0
):
kwargs = frappe._dict(
{
"item_code": ctx.item_code,
"warehouse": ctx.warehouse,
"based_on": frappe.db.get_single_value("Stock Settings", "pick_serial_and_batch_based_on"),
}
)
if ctx.get("ignore_serial_nos"):
kwargs["ignore_serial_nos"] = ctx.get("ignore_serial_nos")
qty = out.stock_qty
batches = []
if out.has_batch_no and not ctx.get("batch_no"):
batches = get_available_batches(kwargs)
if doc:
filter_batches(batches, doc)
for batch_no, batch_qty in batches.items():
if batch_qty >= qty:
out.update({"batch_no": batch_no, "actual_batch_qty": qty})
break
else:
qty -= batch_qty
out.update({"batch_no": batch_no, "actual_batch_qty": batch_qty})
if out.has_serial_no and out.has_batch_no and has_incorrect_serial_nos(ctx, out):
kwargs["batches"] = [ctx.get("batch_no")] if ctx.get("batch_no") else [out.get("batch_no")]
serial_nos = get_serial_nos_for_outward(kwargs)
serial_nos = get_filtered_serial_nos(serial_nos, doc)
out["serial_no"] = "\n".join(serial_nos[: cint(out.stock_qty)])
elif out.has_serial_no and not ctx.get("serial_no"):
serial_nos = get_serial_nos_for_outward(kwargs)
serial_nos = get_filtered_serial_nos(serial_nos, doc)
out["serial_no"] = "\n".join(serial_nos[: cint(out.stock_qty)])
def has_incorrect_serial_nos(ctx, out):
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
if not ctx.get("serial_no"):
return True
serial_nos = get_serial_nos(ctx.get("serial_no"))
if len(serial_nos) != out.get("stock_qty"):
return True
return False
def filter_batches(batches, doc):
for row in doc.get("items"):
if row.get("batch_no") in batches:
batches[row.get("batch_no")] -= row.get("qty")
if batches[row.get("batch_no")] <= 0:
del batches[row.get("batch_no")]
def get_filtered_serial_nos(serial_nos, doc):
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
for row in doc.get("items"):
if row.get("serial_no"):
for serial_no in get_serial_nos(row.get("serial_no")):
if serial_no in serial_nos:
serial_nos.remove(serial_no)
return serial_nos
def process_args(args):
if isinstance(args, str):
args = json.loads(args)

View File

@@ -317,6 +317,7 @@ class StockBalanceReport:
.where((sle.docstatus < 2) & (sle.is_cancelled == 0))
.orderby(sle.posting_datetime)
.orderby(sle.creation)
.orderby(sle.actual_qty)
)
query = self.apply_inventory_dimensions_filters(query, sle)

View File

@@ -1039,7 +1039,7 @@ class update_entries_after:
def get_dynamic_incoming_outgoing_rate(self, sle):
# Get updated incoming/outgoing rate from transaction
if sle.recalculate_rate or self.has_landed_cost_based_on_pi(sle):
if sle.recalculate_rate:
rate = self.get_incoming_outgoing_rate_from_transaction(sle)
if flt(sle.actual_qty) >= 0:
@@ -1047,14 +1047,6 @@ class update_entries_after:
else:
sle.outgoing_rate = rate
def has_landed_cost_based_on_pi(self, sle):
if sle.voucher_type == "Purchase Receipt" and frappe.db.get_single_value(
"Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate"
):
return True
return False
def get_incoming_outgoing_rate_from_transaction(self, sle):
rate = 0
# Material Transfer, Repack, Manufacturing