mirror of
https://github.com/frappe/erpnext.git
synced 2026-07-07 23:42:28 +00:00
Compare commits
47 Commits
dembug
...
mergify/bp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
282a30144a | ||
|
|
c6c7d7832a | ||
|
|
edb254e43e | ||
|
|
61efb2bb39 | ||
|
|
856ec08484 | ||
|
|
31005c5984 | ||
|
|
14ce2337df | ||
|
|
e4d3235b9c | ||
|
|
5ccf4a1783 | ||
|
|
1ef9f7f8fd | ||
|
|
108a91d788 | ||
|
|
1f1c01d88e | ||
|
|
90339511aa | ||
|
|
a9b761f862 | ||
|
|
4ec824d71e | ||
|
|
bb9dd7b8cc | ||
|
|
b291835ccd | ||
|
|
2e930eb97b | ||
|
|
00dadc1a89 | ||
|
|
b4ceda6f2c | ||
|
|
7ea73d8265 | ||
|
|
9e760e54a5 | ||
|
|
81d8f257aa | ||
|
|
dbb572eec1 | ||
|
|
2b453219fc | ||
|
|
ca44a31420 | ||
|
|
4620025dcd | ||
|
|
5df9a8ab99 | ||
|
|
731822efac | ||
|
|
a24d7e8ecd | ||
|
|
729ce1dc50 | ||
|
|
31dd32dcdf | ||
|
|
6850019649 | ||
|
|
2788739c1e | ||
|
|
9f77793f16 | ||
|
|
a0f17f8e73 | ||
|
|
dc5bff9008 | ||
|
|
d7bf73cffa | ||
|
|
8de0fe78ea | ||
|
|
87f1f6e15c | ||
|
|
1f2d7da426 | ||
|
|
f996f71d16 | ||
|
|
839b79ffd0 | ||
|
|
2f80c4dee5 | ||
|
|
5dacfd5cda | ||
|
|
015fd4a05b | ||
|
|
22d38c2af4 |
@@ -10,6 +10,7 @@ frappe.treeview_settings["Account"] = {
|
||||
fieldtype: "Select",
|
||||
options: erpnext.utils.get_tree_options("company"),
|
||||
label: __("Company"),
|
||||
render_on_toolbar: true,
|
||||
default: erpnext.utils.get_tree_default("company"),
|
||||
on_change: function () {
|
||||
var me = frappe.treeview_settings["Account"].treeview;
|
||||
@@ -182,7 +183,9 @@ frappe.treeview_settings["Account"] = {
|
||||
function () {
|
||||
frappe.set_route("Tree", "Cost Center", { company: get_company() });
|
||||
},
|
||||
__("View")
|
||||
__("View"),
|
||||
"default",
|
||||
true
|
||||
);
|
||||
|
||||
treeview.page.add_inner_button(
|
||||
@@ -190,31 +193,12 @@ frappe.treeview_settings["Account"] = {
|
||||
function () {
|
||||
frappe.set_route("Form", "Opening Invoice Creation Tool", { company: get_company() });
|
||||
},
|
||||
__("View")
|
||||
__("View"),
|
||||
"default",
|
||||
true
|
||||
);
|
||||
|
||||
treeview.page.add_inner_button(
|
||||
__("Period Closing Voucher"),
|
||||
function () {
|
||||
frappe.set_route("List", "Period Closing Voucher", { company: get_company() });
|
||||
},
|
||||
__("View")
|
||||
);
|
||||
|
||||
treeview.page.add_inner_button(
|
||||
__("Journal Entry"),
|
||||
function () {
|
||||
frappe.new_doc("Journal Entry", { company: get_company() });
|
||||
},
|
||||
__("Create")
|
||||
);
|
||||
treeview.page.add_inner_button(
|
||||
__("Company"),
|
||||
function () {
|
||||
frappe.new_doc("Company");
|
||||
},
|
||||
__("Create")
|
||||
);
|
||||
treeview.page.add_divider_to_button_group(__("View"));
|
||||
|
||||
// financial statements
|
||||
for (let report of [
|
||||
@@ -231,7 +215,7 @@ frappe.treeview_settings["Account"] = {
|
||||
function () {
|
||||
frappe.set_route("query-report", report, { company: get_company() });
|
||||
},
|
||||
__("Financial Statements")
|
||||
__("View")
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -120,6 +120,7 @@ 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);
|
||||
@@ -135,6 +136,7 @@ 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;
|
||||
|
||||
@@ -79,10 +79,17 @@ def get_bank_transactions(bank_account, from_date=None, to_date=None):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_account_balance(bank_account, till_date):
|
||||
def get_account_balance(bank_account, till_date, company):
|
||||
# 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})
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"account": account,
|
||||
"report_date": till_date,
|
||||
"include_pos_transactions": 1,
|
||||
"company": company,
|
||||
}
|
||||
)
|
||||
data = get_entries(filters)
|
||||
|
||||
balance_as_per_system = get_balance_on(filters["account"], filters["report_date"])
|
||||
@@ -94,11 +101,7 @@ def get_account_balance(bank_account, till_date):
|
||||
|
||||
amounts_not_reflected_in_system = get_amounts_not_reflected_in_system(filters)
|
||||
|
||||
bank_bal = (
|
||||
flt(balance_as_per_system) - flt(total_debit) + flt(total_credit) + amounts_not_reflected_in_system
|
||||
)
|
||||
|
||||
return bank_bal
|
||||
return flt(balance_as_per_system) - flt(total_debit) + flt(total_credit) + amounts_not_reflected_in_system
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"hide_unavailable_items",
|
||||
"auto_add_item_to_cart",
|
||||
"validate_stock_on_save",
|
||||
"print_receipt_on_order_complete",
|
||||
"column_break_16",
|
||||
"update_stock",
|
||||
"ignore_pricing_rule",
|
||||
@@ -374,24 +375,30 @@
|
||||
},
|
||||
{
|
||||
"fieldname": "utm_campaign",
|
||||
"print_hide": 1,
|
||||
"fieldtype": "Link",
|
||||
"label": "Campaign",
|
||||
"options": "UTM Campaign"
|
||||
"options": "UTM Campaign",
|
||||
"print_hide": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "utm_source",
|
||||
"print_hide": 1,
|
||||
"fieldtype": "Link",
|
||||
"label": "Source",
|
||||
"options": "UTM Source"
|
||||
"options": "UTM Source",
|
||||
"print_hide": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "utm_medium",
|
||||
"print_hide": 1,
|
||||
"fieldtype": "Link",
|
||||
"label": "Medium",
|
||||
"options": "UTM Campaign"
|
||||
"options": "UTM Campaign",
|
||||
"print_hide": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "print_receipt_on_order_complete",
|
||||
"fieldtype": "Check",
|
||||
"label": "Print Receipt on Order Complete"
|
||||
}
|
||||
],
|
||||
"icon": "icon-cog",
|
||||
@@ -419,7 +426,7 @@
|
||||
"link_fieldname": "pos_profile"
|
||||
}
|
||||
],
|
||||
"modified": "2024-06-28 10:51:48.543766",
|
||||
"modified": "2025-01-01 11:07:03.161950",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "POS Profile",
|
||||
@@ -448,4 +455,4 @@
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ class POSProfile(Document):
|
||||
letter_head: DF.Link | None
|
||||
payments: DF.Table[POSPaymentMethod]
|
||||
print_format: DF.Link | None
|
||||
print_receipt_on_order_complete: DF.Check
|
||||
select_print_heading: DF.Link | None
|
||||
selling_price_list: DF.Link | None
|
||||
tax_category: DF.Link | None
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"autoname": "format:ACC-PPR-{#####}",
|
||||
"beta": 1,
|
||||
"creation": "2023-03-30 21:28:39.793927",
|
||||
"default_view": "List",
|
||||
"doctype": "DocType",
|
||||
@@ -158,7 +157,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-08-27 14:48:56.715320",
|
||||
"modified": "2025-01-08 08:22:14.798085",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Process Payment Reconciliation",
|
||||
@@ -192,4 +191,4 @@
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"title_field": "company"
|
||||
}
|
||||
}
|
||||
@@ -212,7 +212,7 @@ def trigger_reconciliation_for_queued_docs():
|
||||
unique_filters = set()
|
||||
queue_size = 5
|
||||
|
||||
fields = ["company", "party_type", "party", "receivable_payable_account"]
|
||||
fields = ["company", "party_type", "party", "receivable_payable_account", "default_advance_account"]
|
||||
|
||||
def get_filters_as_tuple(fields, doc):
|
||||
filters = ()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"autoname": "format:PPR-LOG-{##}",
|
||||
"beta": 1,
|
||||
"creation": "2023-03-13 15:00:09.149681",
|
||||
"default_view": "List",
|
||||
"doctype": "DocType",
|
||||
@@ -110,7 +109,7 @@
|
||||
"in_create": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:10:18.769659",
|
||||
"modified": "2025-01-08 08:22:19.104975",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Process Payment Reconciliation Log",
|
||||
|
||||
@@ -993,47 +993,51 @@ frappe.ui.form.on("Sales Invoice", {
|
||||
|
||||
refresh: function (frm) {
|
||||
if (frm.doc.docstatus === 0 && !frm.doc.is_return) {
|
||||
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,
|
||||
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();
|
||||
},
|
||||
{
|
||||
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();
|
||||
});
|
||||
primary_action_label: __("Get Timesheets"),
|
||||
});
|
||||
d.show();
|
||||
},
|
||||
__("Get Items From")
|
||||
);
|
||||
}
|
||||
|
||||
if (frm.doc.is_debit_note) {
|
||||
|
||||
@@ -323,9 +323,7 @@ class SalesInvoice(SellingController):
|
||||
self.set_against_income_account()
|
||||
self.validate_time_sheets_are_submitted()
|
||||
self.validate_multiple_billing("Delivery Note", "dn_detail", "amount")
|
||||
if not self.is_return:
|
||||
self.validate_serial_numbers()
|
||||
else:
|
||||
if self.is_return:
|
||||
self.timesheets = []
|
||||
self.update_packing_list()
|
||||
self.set_billing_hours_and_amount()
|
||||
@@ -364,7 +362,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", "Decapitalized") or (
|
||||
elif asset.status in ("Scrapped", "Cancelled", "Capitalized") or (
|
||||
asset.status == "Sold" and not self.is_return
|
||||
):
|
||||
frappe.throw(
|
||||
@@ -1703,14 +1701,6 @@ 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
|
||||
"""
|
||||
for item in self.items:
|
||||
item.set_serial_no_against_delivery_note()
|
||||
item.validate_serial_against_delivery_note()
|
||||
|
||||
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:
|
||||
|
||||
@@ -8,7 +8,7 @@ from frappe.model.document import Document
|
||||
from frappe.utils.data import cint
|
||||
|
||||
from erpnext.assets.doctype.asset.depreciation import get_disposal_account_and_cost_center
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_delivery_note_serial_no, get_serial_nos
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
|
||||
|
||||
|
||||
class SalesInvoiceItem(Document):
|
||||
@@ -128,39 +128,3 @@ class SalesInvoiceItem(Document):
|
||||
self.income_account = disposal_account
|
||||
if not self.cost_center:
|
||||
self.cost_center = depreciation_cost_center
|
||||
|
||||
def set_serial_no_against_delivery_note(self):
|
||||
"""Set serial no based on delivery note."""
|
||||
if self.serial_no and self.delivery_note and self.qty != len(get_serial_nos(self.serial_no)):
|
||||
self.serial_no = get_delivery_note_serial_no(self.item_code, self.qty, self.delivery_note)
|
||||
|
||||
def validate_serial_against_delivery_note(self):
|
||||
"""Ensure the serial numbers in this Sales Invoice Item are same as in the linked Delivery Note."""
|
||||
if not self.delivery_note or not self.dn_detail:
|
||||
return
|
||||
|
||||
serial_nos = frappe.db.get_value("Delivery Note Item", self.dn_detail, "serial_no") or ""
|
||||
dn_serial_nos = set(get_serial_nos(serial_nos))
|
||||
|
||||
serial_nos = self.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", self.delivery_note)
|
||||
msg = (
|
||||
_("Row #{0}: The following serial numbers are not present in Delivery Note {1}:").format(
|
||||
self.idx, dn_link
|
||||
)
|
||||
+ " "
|
||||
+ ", ".join(frappe.bold(d) for d in serial_no_diff)
|
||||
)
|
||||
|
||||
frappe.throw(msg=msg, title=_("Serial Nos Mismatch"))
|
||||
|
||||
if self.serial_no and cint(self.qty) != len(si_serial_nos):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Row #{0}: {1} serial numbers are required for Item {2}. You have provided {3} serial numbers."
|
||||
).format(self.idx, self.qty, self.item_code, len(si_serial_nos))
|
||||
)
|
||||
|
||||
@@ -621,34 +621,41 @@ def get_due_date_from_template(template_name, posting_date, bill_date):
|
||||
return due_date
|
||||
|
||||
|
||||
def validate_due_date(posting_date, due_date, bill_date=None, template_name=None):
|
||||
def validate_due_date(posting_date, due_date, bill_date=None, template_name=None, doctype=None):
|
||||
if getdate(due_date) < getdate(posting_date):
|
||||
frappe.throw(_("Due Date cannot be before Posting / Supplier Invoice Date"))
|
||||
doctype_date = "Date"
|
||||
if doctype == "Purchase Invoice":
|
||||
doctype_date = "Supplier Invoice Date"
|
||||
|
||||
if doctype == "Sales Invoice":
|
||||
doctype_date = "Posting Date"
|
||||
|
||||
frappe.throw(_("Due Date cannot be before {0}").format(doctype_date))
|
||||
else:
|
||||
if not template_name:
|
||||
return
|
||||
validate_due_date_with_template(posting_date, due_date, bill_date, template_name)
|
||||
|
||||
default_due_date = get_due_date_from_template(template_name, posting_date, bill_date).strftime(
|
||||
"%Y-%m-%d"
|
||||
|
||||
def validate_due_date_with_template(posting_date, due_date, bill_date, template_name):
|
||||
if not template_name:
|
||||
return
|
||||
|
||||
default_due_date = format(get_due_date_from_template(template_name, posting_date, bill_date))
|
||||
|
||||
if not default_due_date:
|
||||
return
|
||||
|
||||
if default_due_date != posting_date and getdate(due_date) > getdate(default_due_date):
|
||||
is_credit_controller = (
|
||||
frappe.db.get_single_value("Accounts Settings", "credit_controller") in frappe.get_roles()
|
||||
)
|
||||
|
||||
if not default_due_date:
|
||||
return
|
||||
|
||||
if default_due_date != posting_date and getdate(due_date) > getdate(default_due_date):
|
||||
is_credit_controller = (
|
||||
frappe.db.get_single_value("Accounts Settings", "credit_controller") in frappe.get_roles()
|
||||
if is_credit_controller:
|
||||
msgprint(
|
||||
_("Note: Due Date exceeds allowed customer credit days by {0} day(s)").format(
|
||||
date_diff(due_date, default_due_date)
|
||||
)
|
||||
)
|
||||
if is_credit_controller:
|
||||
msgprint(
|
||||
_("Note: Due / Reference Date exceeds allowed customer credit days by {0} day(s)").format(
|
||||
date_diff(due_date, default_due_date)
|
||||
)
|
||||
)
|
||||
else:
|
||||
frappe.throw(
|
||||
_("Due / Reference Date cannot be after {0}").format(formatdate(default_due_date))
|
||||
)
|
||||
else:
|
||||
frappe.throw(_("Due Date cannot be after {0}").format(formatdate(default_due_date)))
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -681,7 +688,7 @@ def set_taxes(
|
||||
):
|
||||
from erpnext.accounts.doctype.tax_rule.tax_rule import get_party_details, get_tax_template
|
||||
|
||||
args = {party_type.lower(): party, "company": company}
|
||||
args = {frappe.scrub(party_type): party, "company": company}
|
||||
|
||||
if tax_category:
|
||||
args["tax_category"] = tax_category
|
||||
@@ -701,10 +708,10 @@ def set_taxes(
|
||||
else:
|
||||
args.update(get_party_details(party, party_type))
|
||||
|
||||
if party_type in ("Customer", "Lead", "Prospect"):
|
||||
if party_type in ("Customer", "Lead", "Prospect", "CRM Deal"):
|
||||
args.update({"tax_type": "Sales"})
|
||||
|
||||
if party_type in ["Lead", "Prospect"]:
|
||||
if party_type in ["Lead", "Prospect", "CRM Deal"]:
|
||||
args["customer"] = None
|
||||
del args[frappe.scrub(party_type)]
|
||||
else:
|
||||
|
||||
@@ -353,7 +353,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\nDecapitalized",
|
||||
"options": "Draft\nSubmitted\nPartially Depreciated\nFully Depreciated\nSold\nScrapped\nIn Maintenance\nOut of Order\nIssue\nReceipt\nCapitalized\nWork In Progress",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
@@ -592,7 +592,7 @@
|
||||
"link_fieldname": "target_asset"
|
||||
}
|
||||
],
|
||||
"modified": "2024-11-29 14:25:56.436124",
|
||||
"modified": "2024-12-26 14:23:20.968882",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Assets",
|
||||
"name": "Asset",
|
||||
|
||||
@@ -111,7 +111,7 @@ class Asset(AccountsController):
|
||||
"Issue",
|
||||
"Receipt",
|
||||
"Capitalized",
|
||||
"Decapitalized",
|
||||
"Work In Progress",
|
||||
]
|
||||
supplier: DF.Link | None
|
||||
total_asset_cost: DF.Currency
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
frappe.listview_settings["Asset"] = {
|
||||
add_fields: ["status"],
|
||||
add_fields: ["status", "docstatus"],
|
||||
has_indicator_for_draft: 1,
|
||||
get_indicator: function (doc) {
|
||||
if (doc.status === "Fully Depreciated") {
|
||||
return [__("Fully Depreciated"), "green", "status,=,Fully Depreciated"];
|
||||
@@ -7,8 +8,10 @@ frappe.listview_settings["Asset"] = {
|
||||
return [__("Partially Depreciated"), "grey", "status,=,Partially Depreciated"];
|
||||
} else if (doc.status === "Sold") {
|
||||
return [__("Sold"), "green", "status,=,Sold"];
|
||||
} else if (["Capitalized", "Decapitalized"].includes(doc.status)) {
|
||||
return [__(doc.status), "grey", "status,=," + doc.status];
|
||||
} 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 (doc.status === "Scrapped") {
|
||||
return [__("Scrapped"), "grey", "status,=,Scrapped"];
|
||||
} else if (doc.status === "In Maintenance") {
|
||||
@@ -21,7 +24,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") {
|
||||
} else if (doc.status === "Draft" || doc.docstatus === 0) {
|
||||
return [__("Draft"), "red", "status,=,Draft"];
|
||||
}
|
||||
},
|
||||
|
||||
@@ -436,7 +436,7 @@ def scrap_asset(asset_name, scrap_date=None):
|
||||
|
||||
if asset.docstatus != 1:
|
||||
frappe.throw(_("Asset {0} must be submitted").format(asset.name))
|
||||
elif asset.status in ("Cancelled", "Sold", "Scrapped", "Capitalized", "Decapitalized"):
|
||||
elif asset.status in ("Cancelled", "Sold", "Scrapped", "Capitalized"):
|
||||
frappe.throw(_("Asset {0} cannot be scrapped, as it is already {1}").format(asset.name, asset.status))
|
||||
|
||||
today_date = getdate(today())
|
||||
|
||||
@@ -1752,6 +1752,10 @@ 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)
|
||||
|
||||
@@ -36,11 +36,7 @@ erpnext.assets.AssetCapitalization = class AssetCapitalization extends erpnext.s
|
||||
me.setup_warehouse_query();
|
||||
|
||||
me.frm.set_query("target_item_code", function () {
|
||||
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 });
|
||||
}
|
||||
return erpnext.queries.item({ is_stock_item: 0, is_fixed_asset: 1 });
|
||||
});
|
||||
|
||||
me.frm.set_query("target_asset", function () {
|
||||
@@ -51,7 +47,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", "Decapitalized"]],
|
||||
status: ["not in", ["Draft", "Scrapped", "Sold", "Capitalized"]],
|
||||
docstatus: 1,
|
||||
};
|
||||
|
||||
|
||||
@@ -8,30 +8,26 @@
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"title",
|
||||
"company",
|
||||
"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_item_name",
|
||||
"target_asset",
|
||||
"target_asset_name",
|
||||
"target_warehouse",
|
||||
"target_qty",
|
||||
"target_stock_uom",
|
||||
"target_batch_no",
|
||||
"target_serial_no",
|
||||
"column_break_5",
|
||||
"finance_book",
|
||||
"target_asset_location",
|
||||
"column_break_9",
|
||||
"company",
|
||||
"posting_date",
|
||||
"posting_time",
|
||||
"set_posting_time",
|
||||
"finance_book",
|
||||
"target_batch_no",
|
||||
"target_serial_no",
|
||||
"amended_from",
|
||||
"target_is_fixed_asset",
|
||||
"target_has_batch_no",
|
||||
"target_has_serial_no",
|
||||
"section_break_16",
|
||||
"stock_items",
|
||||
"stock_items_total",
|
||||
@@ -58,12 +54,12 @@
|
||||
"label": "Title"
|
||||
},
|
||||
{
|
||||
"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')",
|
||||
"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'",
|
||||
"fieldname": "target_item_code",
|
||||
"fieldtype": "Link",
|
||||
"in_standard_filter": 1,
|
||||
"label": "Target Item Code",
|
||||
"mandatory_depends_on": "eval:(doc.entry_type=='Capitalization' && doc.capitalization_method=='Create a new composite asset') || doc.entry_type=='Decapitalization'",
|
||||
"mandatory_depends_on": "eval:doc.capitalization_method=='Create a new composite asset'",
|
||||
"options": "Item"
|
||||
},
|
||||
{
|
||||
@@ -84,22 +80,18 @@
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"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')",
|
||||
"depends_on": "eval:(doc.target_asset && !doc.__islocal) || 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.entry_type=='Capitalization' && doc.capitalization_method=='Choose a WIP composite asset'",
|
||||
"mandatory_depends_on": "eval:doc.capitalization_method=='Choose a WIP composite asset'",
|
||||
"no_copy": 1,
|
||||
"options": "Asset",
|
||||
"read_only_depends_on": "eval:(doc.entry_type=='Decapitalization') || (doc.entry_type=='Capitalization' && doc.capitalization_method=='Create a new composite asset')"
|
||||
"read_only_depends_on": "eval:doc.capitalization_method=='Create a new 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')",
|
||||
"depends_on": "eval:(doc.target_asset_name && !doc.__islocal) || (doc.target_asset && doc.capitalization_method=='Choose a WIP composite asset')",
|
||||
"fetch_from": "target_asset.asset_name",
|
||||
"fieldname": "target_asset_name",
|
||||
"fieldtype": "Data",
|
||||
@@ -162,7 +154,7 @@
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.entry_type=='Capitalization' && (doc.docstatus == 0 || (doc.stock_items && doc.stock_items.length))",
|
||||
"depends_on": "eval:doc.docstatus == 0 || (doc.stock_items && doc.stock_items.length)",
|
||||
"fieldname": "section_break_16",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Consumed Stock Items"
|
||||
@@ -173,14 +165,6 @@
|
||||
"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",
|
||||
@@ -190,20 +174,9 @@
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"depends_on": "eval:doc.entry_type=='Decapitalization'",
|
||||
"fieldname": "target_qty",
|
||||
"fieldtype": "Float",
|
||||
"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
|
||||
"label": "Target Qty"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
@@ -241,16 +214,6 @@
|
||||
"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",
|
||||
@@ -272,7 +235,7 @@
|
||||
"options": "Finance Book"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.entry_type=='Capitalization' && (doc.docstatus == 0 || (doc.service_items && doc.service_items.length))",
|
||||
"depends_on": "eval:doc.docstatus == 0 || (doc.service_items && doc.service_items.length)",
|
||||
"fieldname": "service_expenses_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Service Expenses"
|
||||
@@ -337,26 +300,24 @@
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.entry_type=='Capitalization' && doc.capitalization_method=='Create a new composite asset'",
|
||||
"depends_on": "eval:doc.capitalization_method=='Create a new composite asset'",
|
||||
"fieldname": "target_asset_location",
|
||||
"fieldtype": "Link",
|
||||
"label": "Target Asset Location",
|
||||
"mandatory_depends_on": "eval:doc.entry_type=='Capitalization' && doc.capitalization_method=='Create a new composite asset'",
|
||||
"mandatory_depends_on": "eval: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": "2024-03-27 13:06:33.080441",
|
||||
"modified": "2025-01-08 13:14:33.008458",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Assets",
|
||||
"name": "Asset Capitalization",
|
||||
|
||||
@@ -42,7 +42,6 @@ force_fields = [
|
||||
"target_is_fixed_asset",
|
||||
"target_has_serial_no",
|
||||
"target_has_batch_no",
|
||||
"target_stock_uom",
|
||||
"stock_uom",
|
||||
"fixed_asset_account",
|
||||
"valuation_rate",
|
||||
@@ -74,7 +73,6 @@ 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
|
||||
@@ -97,8 +95,6 @@ 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
|
||||
@@ -191,31 +187,18 @@ 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 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:
|
||||
if 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):
|
||||
@@ -232,7 +215,7 @@ class AssetCapitalization(StockController):
|
||||
)
|
||||
)
|
||||
|
||||
if target_asset.status in ("Scrapped", "Sold", "Capitalized", "Decapitalized"):
|
||||
if target_asset.status in ("Scrapped", "Sold", "Capitalized"):
|
||||
frappe.throw(
|
||||
_("Target Asset {0} cannot be {1}").format(target_asset.name, target_asset.status)
|
||||
)
|
||||
@@ -274,7 +257,7 @@ class AssetCapitalization(StockController):
|
||||
|
||||
asset = self.get_asset_for_validation(d.asset)
|
||||
|
||||
if asset.status in ("Draft", "Scrapped", "Sold", "Capitalized", "Decapitalized"):
|
||||
if asset.status in ("Draft", "Scrapped", "Sold", "Capitalized"):
|
||||
frappe.throw(
|
||||
_("Row #{0}: Consumed Asset {1} cannot be {2}").format(
|
||||
d.idx, asset.name, asset.status
|
||||
@@ -315,9 +298,6 @@ 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")
|
||||
):
|
||||
@@ -421,18 +401,6 @@ 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()
|
||||
@@ -475,21 +443,18 @@ class AssetCapitalization(StockController):
|
||||
return gl_entries
|
||||
|
||||
def get_target_account(self):
|
||||
if self.target_is_fixed_asset:
|
||||
from erpnext.assets.doctype.asset.asset import is_cwip_accounting_enabled
|
||||
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
|
||||
else:
|
||||
return 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.warehouse_account[self.target_warehouse]["account"]
|
||||
return self.target_fixed_asset_account
|
||||
|
||||
def get_gl_entries_for_consumed_stock_items(self, gl_entries, target_account, target_against, precision):
|
||||
# Consumed Stock Items
|
||||
@@ -590,33 +555,9 @@ 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.entry_type != "Capitalization"
|
||||
or self.capitalization_method != "Create a new composite asset"
|
||||
):
|
||||
if self.capitalization_method != "Create a new composite asset":
|
||||
return
|
||||
|
||||
total_target_asset_value = flt(self.total_value, self.precision("total_value"))
|
||||
@@ -639,6 +580,7 @@ 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,
|
||||
@@ -654,17 +596,15 @@ class AssetCapitalization(StockController):
|
||||
)
|
||||
|
||||
def update_target_asset(self):
|
||||
if (
|
||||
self.entry_type != "Capitalization"
|
||||
or self.capitalization_method != "Choose a WIP composite asset"
|
||||
):
|
||||
if 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.gross_purchase_amount += total_target_asset_value
|
||||
asset_doc.purchase_amount += total_target_asset_value
|
||||
asset_doc.set_status("Work In Progress")
|
||||
asset_doc.flags.ignore_validate = True
|
||||
asset_doc.save()
|
||||
|
||||
@@ -699,14 +639,6 @@ 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(
|
||||
@@ -728,16 +660,12 @@ 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
|
||||
|
||||
@@ -61,7 +61,6 @@ class TestAssetCapitalization(IntegrationTestCase):
|
||||
|
||||
# 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",
|
||||
@@ -76,7 +75,6 @@ class TestAssetCapitalization(IntegrationTestCase):
|
||||
)
|
||||
|
||||
# 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)
|
||||
@@ -96,6 +94,7 @@ class TestAssetCapitalization(IntegrationTestCase):
|
||||
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")
|
||||
@@ -151,7 +150,6 @@ class TestAssetCapitalization(IntegrationTestCase):
|
||||
|
||||
# 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",
|
||||
@@ -166,7 +164,6 @@ class TestAssetCapitalization(IntegrationTestCase):
|
||||
)
|
||||
|
||||
# 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)
|
||||
@@ -243,7 +240,6 @@ class TestAssetCapitalization(IntegrationTestCase):
|
||||
|
||||
# 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",
|
||||
@@ -255,7 +251,6 @@ class TestAssetCapitalization(IntegrationTestCase):
|
||||
)
|
||||
|
||||
# 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)
|
||||
|
||||
@@ -270,6 +265,7 @@ class TestAssetCapitalization(IntegrationTestCase):
|
||||
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 = {
|
||||
@@ -295,110 +291,6 @@ class TestAssetCapitalization(IntegrationTestCase):
|
||||
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
|
||||
@@ -418,7 +310,6 @@ class TestAssetCapitalization(IntegrationTestCase):
|
||||
|
||||
# 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",
|
||||
@@ -466,13 +357,11 @@ 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"),
|
||||
@@ -480,7 +369,6 @@ 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,
|
||||
|
||||
@@ -13,6 +13,7 @@ from frappe.utils import (
|
||||
flt,
|
||||
get_first_day,
|
||||
get_last_day,
|
||||
get_link_to_form,
|
||||
getdate,
|
||||
is_last_day_of_the_month,
|
||||
month_diff,
|
||||
@@ -1062,7 +1063,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(
|
||||
asset_doc.name, row.finance_book
|
||||
get_link_to_form("Asset", asset_doc.name), row.finance_book
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1108,7 +1109,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(
|
||||
asset_doc.name, row.finance_book
|
||||
get_link_to_form("Asset", asset_doc.name), row.finance_book
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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 or decapitalized
|
||||
# In Store assets are those that are not sold or scrapped or capitalized
|
||||
operand = "not in"
|
||||
if status not in "In Location":
|
||||
operand = "in"
|
||||
|
||||
conditions["status"] = (operand, ["Sold", "Scrapped", "Capitalized", "Decapitalized"])
|
||||
conditions["status"] = (operand, ["Sold", "Scrapped", "Capitalized"])
|
||||
|
||||
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", "Decapitalized"]))
|
||||
query = query.where(asset.status.notin(["Sold", "Scrapped", "Capitalized"]))
|
||||
else:
|
||||
query = query.where(asset.status.isin(["Sold", "Scrapped", "Capitalized", "Decapitalized"]))
|
||||
query = query.where(asset.status.isin(["Sold", "Scrapped", "Capitalized"]))
|
||||
if finance_book:
|
||||
query = query.where((gle.finance_book.isin([cstr(finance_book), ""])) | (gle.finance_book.isnull()))
|
||||
else:
|
||||
|
||||
@@ -469,6 +469,9 @@ class PurchaseOrder(BuyingController):
|
||||
if self.is_against_so():
|
||||
self.update_status_updater()
|
||||
|
||||
if self.is_against_pp():
|
||||
self.update_status_updater_if_from_pp()
|
||||
|
||||
self.update_prevdoc_status()
|
||||
if not self.is_subcontracted or self.is_old_subcontracting_flow:
|
||||
self.update_requested_qty()
|
||||
@@ -550,6 +553,20 @@ class PurchaseOrder(BuyingController):
|
||||
}
|
||||
)
|
||||
|
||||
def update_status_updater_if_from_pp(self):
|
||||
self.status_updater.append(
|
||||
{
|
||||
"source_dt": "Purchase Order Item",
|
||||
"target_dt": "Production Plan Sub Assembly Item",
|
||||
"join_field": "production_plan_sub_assembly_item",
|
||||
"target_field": "received_qty",
|
||||
"target_parent_dt": "Production Plan",
|
||||
"target_parent_field": "",
|
||||
"target_ref_field": "qty",
|
||||
"source_field": "fg_item_qty",
|
||||
}
|
||||
)
|
||||
|
||||
def update_delivered_qty_in_sales_order(self):
|
||||
"""Update delivered qty in Sales Order for drop ship"""
|
||||
sales_orders_to_update = []
|
||||
@@ -570,6 +587,9 @@ class PurchaseOrder(BuyingController):
|
||||
def is_against_so(self):
|
||||
return any(d.sales_order for d in self.items if d.sales_order)
|
||||
|
||||
def is_against_pp(self):
|
||||
return any(d.production_plan for d in self.items if d.production_plan)
|
||||
|
||||
def set_received_qty_for_drop_ship_items(self):
|
||||
for item in self.items:
|
||||
if item.delivered_by_supplier == 1:
|
||||
|
||||
@@ -671,21 +671,15 @@ class AccountsController(TransactionBase):
|
||||
if frappe.flags.in_import and getdate(self.due_date) < getdate(posting_date):
|
||||
self.due_date = posting_date
|
||||
|
||||
elif self.doctype == "Sales Invoice":
|
||||
if not self.due_date:
|
||||
frappe.throw(_("Due Date is mandatory"))
|
||||
elif self.doctype in ["Sales Invoice", "Purchase Invoice"]:
|
||||
bill_date = self.bill_date if self.doctype == "Purchase Invoice" else None
|
||||
|
||||
validate_due_date(
|
||||
posting_date,
|
||||
self.due_date,
|
||||
self.payment_terms_template,
|
||||
)
|
||||
elif self.doctype == "Purchase Invoice":
|
||||
validate_due_date(
|
||||
posting_date,
|
||||
self.due_date,
|
||||
self.bill_date,
|
||||
self.payment_terms_template,
|
||||
posting_date=posting_date,
|
||||
due_date=self.due_date,
|
||||
bill_date=bill_date,
|
||||
template_name=self.payment_terms_template,
|
||||
doctype=self.doctype,
|
||||
)
|
||||
|
||||
def set_price_list_currency(self, buying_or_selling):
|
||||
|
||||
@@ -205,19 +205,19 @@ class StatusUpdater(Document):
|
||||
Get the status of the document.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing the status. This allows callers to receive
|
||||
a dictionary for efficient bulk updates, for example when `per_billed`
|
||||
and other status fields also need to be updated.
|
||||
dict: A dictionary containing the status. This allows callers to receive
|
||||
a dictionary for efficient bulk updates, for example when `per_billed`
|
||||
and other status fields also need to be updated.
|
||||
|
||||
Note:
|
||||
Can be overriden on a doctype to implement more localized status updater logic.
|
||||
Can be overriden on a doctype to implement more localized status updater logic.
|
||||
|
||||
Example:
|
||||
{
|
||||
"status": "Draft",
|
||||
"per_billed": 50,
|
||||
"billing_status": "Partly Billed"
|
||||
}
|
||||
{
|
||||
"status": "Draft",
|
||||
"per_billed": 50,
|
||||
"billing_status": "Partly Billed"
|
||||
}
|
||||
"""
|
||||
if self.doctype not in status_map:
|
||||
return {"status": self.status}
|
||||
@@ -279,9 +279,20 @@ class StatusUpdater(Document):
|
||||
if d.doctype == args["source_dt"] and d.get(args["join_field"]):
|
||||
args["name"] = d.get(args["join_field"])
|
||||
|
||||
is_from_pp = (
|
||||
hasattr(d, "production_plan_sub_assembly_item")
|
||||
and frappe.db.get_value(
|
||||
"Production Plan Sub Assembly Item",
|
||||
d.production_plan_sub_assembly_item,
|
||||
"type_of_manufacturing",
|
||||
)
|
||||
== "Subcontract"
|
||||
)
|
||||
args["item_code"] = "production_item" if is_from_pp else "item_code"
|
||||
|
||||
# get all qty where qty > target_field
|
||||
item = frappe.db.sql(
|
||||
"""select item_code, `{target_ref_field}`,
|
||||
"""select `{item_code}` as item_code, `{target_ref_field}`,
|
||||
`{target_field}`, parenttype, parent from `tab{target_dt}`
|
||||
where `{target_ref_field}` < `{target_field}`
|
||||
and name=%s and docstatus=1""".format(**args),
|
||||
|
||||
@@ -1263,6 +1263,7 @@ 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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1303,10 +1304,13 @@ 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, value.qty, rm_details, rm_detail_field, batch_no)
|
||||
add_items_in_ste(ste_doc, value, qty, rm_details, rm_detail_field, batch_no)
|
||||
else:
|
||||
add_items_in_ste(ste_doc, value, value.qty, rm_details, rm_detail_field)
|
||||
|
||||
|
||||
@@ -282,6 +282,79 @@ class TestSubcontractingController(IntegrationTestCase):
|
||||
|
||||
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.
|
||||
|
||||
@@ -1,364 +0,0 @@
|
||||
// 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;
|
||||
};
|
||||
@@ -1,280 +0,0 @@
|
||||
{
|
||||
"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": "2024-03-27 13:10:51.146772",
|
||||
"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": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
@@ -1,768 +0,0 @@
|
||||
# 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("", "", 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_single_value("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 occurred 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()
|
||||
@@ -1,9 +0,0 @@
|
||||
# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
import unittest
|
||||
|
||||
from frappe.tests import IntegrationTestCase
|
||||
|
||||
|
||||
class TestTallyMigration(IntegrationTestCase):
|
||||
pass
|
||||
@@ -436,6 +436,11 @@ scheduler_events = {
|
||||
"erpnext.crm.doctype.opportunity.opportunity.auto_close_opportunity",
|
||||
"erpnext.controllers.accounts_controller.update_invoice_status",
|
||||
"erpnext.accounts.doctype.fiscal_year.fiscal_year.auto_create_fiscal_year",
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
"erpnext.hr.doctype.employee.employee_reminders.send_work_anniversary_reminders",
|
||||
"erpnext.hr.doctype.employee.employee_reminders.send_birthday_reminders",
|
||||
>>>>>>> 24b2a31581 (feat: Employee reminders (#25735))
|
||||
"erpnext.projects.doctype.task.task.set_tasks_as_overdue",
|
||||
"erpnext.stock.doctype.serial_no.serial_no.update_maintenance_status",
|
||||
"erpnext.buying.doctype.supplier_scorecard.supplier_scorecard.refresh_scorecards",
|
||||
@@ -466,6 +471,12 @@ scheduler_events = {
|
||||
"erpnext.crm.utils.open_leads_opportunities_based_on_todays_event",
|
||||
"erpnext.assets.doctype.asset.depreciation.post_depreciation_entries",
|
||||
],
|
||||
"weekly": [
|
||||
"erpnext.hr.doctype.employee.employee_reminders.send_reminders_in_advance_weekly"
|
||||
],
|
||||
"monthly": [
|
||||
"erpnext.hr.doctype.employee.employee_reminders.send_reminders_in_advance_monthly"
|
||||
],
|
||||
"monthly_long": [
|
||||
"erpnext.accounts.deferred_revenue.process_deferred_accounting",
|
||||
"erpnext.accounts.utils.auto_create_exchange_rate_revaluation_monthly",
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
from __future__ import unicode_literals
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import date_diff, add_days, getdate, cint, format_date
|
||||
from frappe.model.document import Document
|
||||
from erpnext.hr.utils import validate_dates, validate_overlap, get_leave_period, validate_active_employee, \
|
||||
create_additional_leave_ledger_entry, get_holiday_dates_for_employee
|
||||
|
||||
class CompensatoryLeaveRequest(Document):
|
||||
|
||||
def validate(self):
|
||||
validate_active_employee(self.employee)
|
||||
validate_dates(self, self.work_from_date, self.work_end_date)
|
||||
if self.half_day:
|
||||
if not self.half_day_date:
|
||||
frappe.throw(_("Half Day Date is mandatory"))
|
||||
if not getdate(self.work_from_date)<=getdate(self.half_day_date)<=getdate(self.work_end_date):
|
||||
frappe.throw(_("Half Day Date should be in between Work From Date and Work End Date"))
|
||||
validate_overlap(self, self.work_from_date, self.work_end_date)
|
||||
self.validate_holidays()
|
||||
self.validate_attendance()
|
||||
if not self.leave_type:
|
||||
frappe.throw(_("Leave Type is madatory"))
|
||||
|
||||
def validate_attendance(self):
|
||||
attendance = frappe.get_all('Attendance',
|
||||
filters={
|
||||
'attendance_date': ['between', (self.work_from_date, self.work_end_date)],
|
||||
'status': 'Present',
|
||||
'docstatus': 1,
|
||||
'employee': self.employee
|
||||
}, fields=['attendance_date', 'status'])
|
||||
|
||||
if len(attendance) < date_diff(self.work_end_date, self.work_from_date) + 1:
|
||||
frappe.throw(_("You are not present all day(s) between compensatory leave request days"))
|
||||
|
||||
def validate_holidays(self):
|
||||
holidays = get_holiday_dates_for_employee(self.employee, self.work_from_date, self.work_end_date)
|
||||
if len(holidays) < date_diff(self.work_end_date, self.work_from_date) + 1:
|
||||
if date_diff(self.work_end_date, self.work_from_date):
|
||||
msg = _("The days between {0} to {1} are not valid holidays.").format(frappe.bold(format_date(self.work_from_date)), frappe.bold(format_date(self.work_end_date)))
|
||||
else:
|
||||
msg = _("{0} is not a holiday.").format(frappe.bold(format_date(self.work_from_date)))
|
||||
|
||||
frappe.throw(msg)
|
||||
|
||||
def on_submit(self):
|
||||
company = frappe.db.get_value("Employee", self.employee, "company")
|
||||
date_difference = date_diff(self.work_end_date, self.work_from_date) + 1
|
||||
if self.half_day:
|
||||
date_difference -= 0.5
|
||||
leave_period = get_leave_period(self.work_from_date, self.work_end_date, company)
|
||||
if leave_period:
|
||||
leave_allocation = self.get_existing_allocation_for_period(leave_period)
|
||||
if leave_allocation:
|
||||
leave_allocation.new_leaves_allocated += date_difference
|
||||
leave_allocation.validate()
|
||||
leave_allocation.db_set("new_leaves_allocated", leave_allocation.total_leaves_allocated)
|
||||
leave_allocation.db_set("total_leaves_allocated", leave_allocation.total_leaves_allocated)
|
||||
|
||||
# generate additional ledger entry for the new compensatory leaves off
|
||||
create_additional_leave_ledger_entry(leave_allocation, date_difference, add_days(self.work_end_date, 1))
|
||||
|
||||
else:
|
||||
leave_allocation = self.create_leave_allocation(leave_period, date_difference)
|
||||
self.db_set("leave_allocation", leave_allocation.name)
|
||||
else:
|
||||
frappe.throw(_("There is no leave period in between {0} and {1}").format(format_date(self.work_from_date), format_date(self.work_end_date)))
|
||||
|
||||
def on_cancel(self):
|
||||
if self.leave_allocation:
|
||||
date_difference = date_diff(self.work_end_date, self.work_from_date) + 1
|
||||
if self.half_day:
|
||||
date_difference -= 0.5
|
||||
leave_allocation = frappe.get_doc("Leave Allocation", self.leave_allocation)
|
||||
if leave_allocation:
|
||||
leave_allocation.new_leaves_allocated -= date_difference
|
||||
if leave_allocation.new_leaves_allocated - date_difference <= 0:
|
||||
leave_allocation.new_leaves_allocated = 0
|
||||
leave_allocation.validate()
|
||||
leave_allocation.db_set("new_leaves_allocated", leave_allocation.total_leaves_allocated)
|
||||
leave_allocation.db_set("total_leaves_allocated", leave_allocation.total_leaves_allocated)
|
||||
|
||||
# create reverse entry on cancelation
|
||||
create_additional_leave_ledger_entry(leave_allocation, date_difference * -1, add_days(self.work_end_date, 1))
|
||||
|
||||
def get_existing_allocation_for_period(self, leave_period):
|
||||
leave_allocation = frappe.db.sql("""
|
||||
select name
|
||||
from `tabLeave Allocation`
|
||||
where employee=%(employee)s and leave_type=%(leave_type)s
|
||||
and docstatus=1
|
||||
and (from_date between %(from_date)s and %(to_date)s
|
||||
or to_date between %(from_date)s and %(to_date)s
|
||||
or (from_date < %(from_date)s and to_date > %(to_date)s))
|
||||
""", {
|
||||
"from_date": leave_period[0].from_date,
|
||||
"to_date": leave_period[0].to_date,
|
||||
"employee": self.employee,
|
||||
"leave_type": self.leave_type
|
||||
}, as_dict=1)
|
||||
|
||||
if leave_allocation:
|
||||
return frappe.get_doc("Leave Allocation", leave_allocation[0].name)
|
||||
else:
|
||||
return False
|
||||
|
||||
def create_leave_allocation(self, leave_period, date_difference):
|
||||
is_carry_forward = frappe.db.get_value("Leave Type", self.leave_type, "is_carry_forward")
|
||||
allocation = frappe.get_doc(dict(
|
||||
doctype="Leave Allocation",
|
||||
employee=self.employee,
|
||||
employee_name=self.employee_name,
|
||||
leave_type=self.leave_type,
|
||||
from_date=add_days(self.work_end_date, 1),
|
||||
to_date=leave_period[0].to_date,
|
||||
carry_forward=cint(is_carry_forward),
|
||||
new_leaves_allocated=date_difference,
|
||||
total_leaves_allocated=date_difference,
|
||||
description=self.reason
|
||||
))
|
||||
allocation.insert(ignore_permissions=True)
|
||||
allocation.submit()
|
||||
return allocation
|
||||
456
erpnext/hr/doctype/employee/employee.py
Executable file
456
erpnext/hr/doctype/employee/employee.py
Executable file
@@ -0,0 +1,456 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
import frappe
|
||||
|
||||
from frappe.utils import getdate, validate_email_address, today, add_years, cstr
|
||||
from frappe.model.naming import set_name_by_naming_series
|
||||
from frappe import throw, _, scrub
|
||||
from frappe.permissions import add_user_permission, remove_user_permission, \
|
||||
set_user_permission_if_allowed, has_permission, get_doc_permissions
|
||||
from erpnext.utilities.transaction_base import delete_events
|
||||
from frappe.utils.nestedset import NestedSet
|
||||
|
||||
class EmployeeUserDisabledError(frappe.ValidationError):
|
||||
pass
|
||||
class InactiveEmployeeStatusError(frappe.ValidationError):
|
||||
pass
|
||||
|
||||
class Employee(NestedSet):
|
||||
nsm_parent_field = 'reports_to'
|
||||
|
||||
def autoname(self):
|
||||
naming_method = frappe.db.get_value("HR Settings", None, "emp_created_by")
|
||||
if not naming_method:
|
||||
throw(_("Please setup Employee Naming System in Human Resource > HR Settings"))
|
||||
else:
|
||||
if naming_method == 'Naming Series':
|
||||
set_name_by_naming_series(self)
|
||||
elif naming_method == 'Employee Number':
|
||||
self.name = self.employee_number
|
||||
elif naming_method == 'Full Name':
|
||||
self.set_employee_name()
|
||||
self.name = self.employee_name
|
||||
|
||||
self.employee = self.name
|
||||
|
||||
def validate(self):
|
||||
from erpnext.controllers.status_updater import validate_status
|
||||
validate_status(self.status, ["Active", "Inactive", "Suspended", "Left"])
|
||||
|
||||
self.employee = self.name
|
||||
self.set_employee_name()
|
||||
self.validate_date()
|
||||
self.validate_email()
|
||||
self.validate_status()
|
||||
self.validate_reports_to()
|
||||
self.validate_preferred_email()
|
||||
if self.job_applicant:
|
||||
self.validate_onboarding_process()
|
||||
|
||||
if self.user_id:
|
||||
self.validate_user_details()
|
||||
else:
|
||||
existing_user_id = frappe.db.get_value("Employee", self.name, "user_id")
|
||||
if existing_user_id:
|
||||
remove_user_permission(
|
||||
"Employee", self.name, existing_user_id)
|
||||
|
||||
def after_rename(self, old, new, merge):
|
||||
self.db_set("employee", new)
|
||||
|
||||
def set_employee_name(self):
|
||||
self.employee_name = ' '.join(filter(lambda x: x, [self.first_name, self.middle_name, self.last_name]))
|
||||
|
||||
def validate_user_details(self):
|
||||
data = frappe.db.get_value('User',
|
||||
self.user_id, ['enabled', 'user_image'], as_dict=1)
|
||||
if data.get("user_image") and self.image == '':
|
||||
self.image = data.get("user_image")
|
||||
self.validate_for_enabled_user_id(data.get("enabled", 0))
|
||||
self.validate_duplicate_user_id()
|
||||
|
||||
def update_nsm_model(self):
|
||||
frappe.utils.nestedset.update_nsm(self)
|
||||
|
||||
def on_update(self):
|
||||
self.update_nsm_model()
|
||||
if self.user_id:
|
||||
self.update_user()
|
||||
self.update_user_permissions()
|
||||
self.reset_employee_emails_cache()
|
||||
self.update_approver_role()
|
||||
|
||||
def update_user_permissions(self):
|
||||
if not self.create_user_permission: return
|
||||
if not has_permission('User Permission', ptype='write', raise_exception=False): return
|
||||
|
||||
employee_user_permission_exists = frappe.db.exists('User Permission', {
|
||||
'allow': 'Employee',
|
||||
'for_value': self.name,
|
||||
'user': self.user_id
|
||||
})
|
||||
|
||||
if employee_user_permission_exists: return
|
||||
|
||||
employee_user_permission_exists = frappe.db.exists('User Permission', {
|
||||
'allow': 'Employee',
|
||||
'for_value': self.name,
|
||||
'user': self.user_id
|
||||
})
|
||||
|
||||
if employee_user_permission_exists: return
|
||||
|
||||
add_user_permission("Employee", self.name, self.user_id)
|
||||
set_user_permission_if_allowed("Company", self.company, self.user_id)
|
||||
|
||||
def update_user(self):
|
||||
# add employee role if missing
|
||||
user = frappe.get_doc("User", self.user_id)
|
||||
user.flags.ignore_permissions = True
|
||||
|
||||
if "Employee" not in user.get("roles"):
|
||||
user.append_roles("Employee")
|
||||
|
||||
# copy details like Fullname, DOB and Image to User
|
||||
if self.employee_name and not (user.first_name and user.last_name):
|
||||
employee_name = self.employee_name.split(" ")
|
||||
if len(employee_name) >= 3:
|
||||
user.last_name = " ".join(employee_name[2:])
|
||||
user.middle_name = employee_name[1]
|
||||
elif len(employee_name) == 2:
|
||||
user.last_name = employee_name[1]
|
||||
|
||||
user.first_name = employee_name[0]
|
||||
|
||||
if self.date_of_birth:
|
||||
user.birth_date = self.date_of_birth
|
||||
|
||||
if self.gender:
|
||||
user.gender = self.gender
|
||||
|
||||
if self.image:
|
||||
if not user.user_image:
|
||||
user.user_image = self.image
|
||||
try:
|
||||
frappe.get_doc({
|
||||
"doctype": "File",
|
||||
"file_url": self.image,
|
||||
"attached_to_doctype": "User",
|
||||
"attached_to_name": self.user_id
|
||||
}).insert()
|
||||
except frappe.DuplicateEntryError:
|
||||
# already exists
|
||||
pass
|
||||
|
||||
user.save()
|
||||
|
||||
def update_approver_role(self):
|
||||
if self.leave_approver:
|
||||
user = frappe.get_doc("User", self.leave_approver)
|
||||
user.flags.ignore_permissions = True
|
||||
user.add_roles("Leave Approver")
|
||||
|
||||
if self.expense_approver:
|
||||
user = frappe.get_doc("User", self.expense_approver)
|
||||
user.flags.ignore_permissions = True
|
||||
user.add_roles("Expense Approver")
|
||||
|
||||
def validate_date(self):
|
||||
if self.date_of_birth and getdate(self.date_of_birth) > getdate(today()):
|
||||
throw(_("Date of Birth cannot be greater than today."))
|
||||
|
||||
if self.date_of_birth and self.date_of_joining and getdate(self.date_of_birth) >= getdate(self.date_of_joining):
|
||||
throw(_("Date of Joining must be greater than Date of Birth"))
|
||||
|
||||
elif self.date_of_retirement and self.date_of_joining and (getdate(self.date_of_retirement) <= getdate(self.date_of_joining)):
|
||||
throw(_("Date Of Retirement must be greater than Date of Joining"))
|
||||
|
||||
elif self.relieving_date and self.date_of_joining and (getdate(self.relieving_date) < getdate(self.date_of_joining)):
|
||||
throw(_("Relieving Date must be greater than or equal to Date of Joining"))
|
||||
|
||||
elif self.contract_end_date and self.date_of_joining and (getdate(self.contract_end_date) <= getdate(self.date_of_joining)):
|
||||
throw(_("Contract End Date must be greater than Date of Joining"))
|
||||
|
||||
def validate_email(self):
|
||||
if self.company_email:
|
||||
validate_email_address(self.company_email, True)
|
||||
if self.personal_email:
|
||||
validate_email_address(self.personal_email, True)
|
||||
|
||||
def set_preferred_email(self):
|
||||
preferred_email_field = frappe.scrub(self.prefered_contact_email)
|
||||
if preferred_email_field:
|
||||
preferred_email = self.get(preferred_email_field)
|
||||
self.prefered_email = preferred_email
|
||||
|
||||
def validate_status(self):
|
||||
if self.status == 'Left':
|
||||
reports_to = frappe.db.get_all('Employee',
|
||||
filters={'reports_to': self.name, 'status': "Active"},
|
||||
fields=['name','employee_name']
|
||||
)
|
||||
if reports_to:
|
||||
link_to_employees = [frappe.utils.get_link_to_form('Employee', employee.name, label=employee.employee_name) for employee in reports_to]
|
||||
message = _("The following employees are currently still reporting to {0}:").format(frappe.bold(self.employee_name))
|
||||
message += "<br><br><ul><li>" + "</li><li>".join(link_to_employees)
|
||||
message += "</li></ul><br>"
|
||||
message += _("Please make sure the employees above report to another Active employee.")
|
||||
throw(message, InactiveEmployeeStatusError, _("Cannot Relieve Employee"))
|
||||
if not self.relieving_date:
|
||||
throw(_("Please enter relieving date."))
|
||||
|
||||
def validate_for_enabled_user_id(self, enabled):
|
||||
if not self.status == 'Active':
|
||||
return
|
||||
|
||||
if enabled is None:
|
||||
frappe.throw(_("User {0} does not exist").format(self.user_id))
|
||||
if enabled == 0:
|
||||
frappe.throw(_("User {0} is disabled").format(self.user_id), EmployeeUserDisabledError)
|
||||
|
||||
def validate_duplicate_user_id(self):
|
||||
employee = frappe.db.sql_list("""select name from `tabEmployee` where
|
||||
user_id=%s and status='Active' and name!=%s""", (self.user_id, self.name))
|
||||
if employee:
|
||||
throw(_("User {0} is already assigned to Employee {1}").format(
|
||||
self.user_id, employee[0]), frappe.DuplicateEntryError)
|
||||
|
||||
def validate_reports_to(self):
|
||||
if self.reports_to == self.name:
|
||||
throw(_("Employee cannot report to himself."))
|
||||
|
||||
def on_trash(self):
|
||||
self.update_nsm_model()
|
||||
delete_events(self.doctype, self.name)
|
||||
if frappe.db.exists("Employee Transfer", {'new_employee_id': self.name, 'docstatus': 1}):
|
||||
emp_transfer = frappe.get_doc("Employee Transfer", {'new_employee_id': self.name, 'docstatus': 1})
|
||||
emp_transfer.db_set("new_employee_id", '')
|
||||
|
||||
def validate_preferred_email(self):
|
||||
if self.prefered_contact_email and not self.get(scrub(self.prefered_contact_email)):
|
||||
frappe.msgprint(_("Please enter {0}").format(self.prefered_contact_email))
|
||||
|
||||
def validate_onboarding_process(self):
|
||||
employee_onboarding = frappe.get_all("Employee Onboarding",
|
||||
filters={"job_applicant": self.job_applicant, "docstatus": 1, "boarding_status": ("!=", "Completed")})
|
||||
if employee_onboarding:
|
||||
doc = frappe.get_doc("Employee Onboarding", employee_onboarding[0].name)
|
||||
doc.validate_employee_creation()
|
||||
doc.db_set("employee", self.name)
|
||||
|
||||
def reset_employee_emails_cache(self):
|
||||
prev_doc = self.get_doc_before_save() or {}
|
||||
cell_number = cstr(self.get('cell_number'))
|
||||
prev_number = cstr(prev_doc.get('cell_number'))
|
||||
if (cell_number != prev_number or
|
||||
self.get('user_id') != prev_doc.get('user_id')):
|
||||
frappe.cache().hdel('employees_with_number', cell_number)
|
||||
frappe.cache().hdel('employees_with_number', prev_number)
|
||||
|
||||
def get_timeline_data(doctype, name):
|
||||
'''Return timeline for attendance'''
|
||||
return dict(frappe.db.sql('''select unix_timestamp(attendance_date), count(*)
|
||||
from `tabAttendance` where employee=%s
|
||||
and attendance_date > date_sub(curdate(), interval 1 year)
|
||||
and status in ('Present', 'Half Day')
|
||||
group by attendance_date''', name))
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_retirement_date(date_of_birth=None):
|
||||
ret = {}
|
||||
if date_of_birth:
|
||||
try:
|
||||
retirement_age = int(frappe.db.get_single_value("HR Settings", "retirement_age") or 60)
|
||||
dt = add_years(getdate(date_of_birth),retirement_age)
|
||||
ret = {'date_of_retirement': dt.strftime('%Y-%m-%d')}
|
||||
except ValueError:
|
||||
# invalid date
|
||||
ret = {}
|
||||
|
||||
return ret
|
||||
|
||||
def validate_employee_role(doc, method):
|
||||
# called via User hook
|
||||
if "Employee" in [d.role for d in doc.get("roles")]:
|
||||
if not frappe.db.get_value("Employee", {"user_id": doc.name}):
|
||||
frappe.msgprint(_("Please set User ID field in an Employee record to set Employee Role"))
|
||||
doc.get("roles").remove(doc.get("roles", {"role": "Employee"})[0])
|
||||
|
||||
def update_user_permissions(doc, method):
|
||||
# called via User hook
|
||||
if "Employee" in [d.role for d in doc.get("roles")]:
|
||||
if not has_permission('User Permission', ptype='write', raise_exception=False): return
|
||||
employee = frappe.get_doc("Employee", {"user_id": doc.name})
|
||||
employee.update_user_permissions()
|
||||
|
||||
def get_employee_email(employee_doc):
|
||||
return employee_doc.get("user_id") or employee_doc.get("personal_email") or employee_doc.get("company_email")
|
||||
|
||||
def get_holiday_list_for_employee(employee, raise_exception=True):
|
||||
if employee:
|
||||
holiday_list, company = frappe.db.get_value("Employee", employee, ["holiday_list", "company"])
|
||||
else:
|
||||
holiday_list=''
|
||||
company=frappe.db.get_value("Global Defaults", None, "default_company")
|
||||
|
||||
if not holiday_list:
|
||||
holiday_list = frappe.get_cached_value('Company', company, "default_holiday_list")
|
||||
|
||||
if not holiday_list and raise_exception:
|
||||
frappe.throw(_('Please set a default Holiday List for Employee {0} or Company {1}').format(employee, company))
|
||||
|
||||
return holiday_list
|
||||
|
||||
def is_holiday(employee, date=None, raise_exception=True, only_non_weekly=False, with_description=False):
|
||||
'''
|
||||
Returns True if given Employee has an holiday on the given date
|
||||
:param employee: Employee `name`
|
||||
:param date: Date to check. Will check for today if None
|
||||
:param raise_exception: Raise an exception if no holiday list found, default is True
|
||||
:param only_non_weekly: Check only non-weekly holidays, default is False
|
||||
'''
|
||||
|
||||
holiday_list = get_holiday_list_for_employee(employee, raise_exception)
|
||||
if not date:
|
||||
date = today()
|
||||
|
||||
if not holiday_list:
|
||||
return False
|
||||
|
||||
filters = {
|
||||
'parent': holiday_list,
|
||||
'holiday_date': date
|
||||
}
|
||||
if only_non_weekly:
|
||||
filters['weekly_off'] = False
|
||||
|
||||
holidays = frappe.get_all(
|
||||
'Holiday',
|
||||
fields=['description'],
|
||||
filters=filters,
|
||||
pluck='description'
|
||||
)
|
||||
|
||||
if with_description:
|
||||
return len(holidays) > 0, holidays
|
||||
|
||||
return len(holidays) > 0
|
||||
|
||||
@frappe.whitelist()
|
||||
def deactivate_sales_person(status = None, employee = None):
|
||||
if status == "Left":
|
||||
sales_person = frappe.db.get_value("Sales Person", {"Employee": employee})
|
||||
if sales_person:
|
||||
frappe.db.set_value("Sales Person", sales_person, "enabled", 0)
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_user(employee, user = None, email=None):
|
||||
emp = frappe.get_doc("Employee", employee)
|
||||
|
||||
employee_name = emp.employee_name.split(" ")
|
||||
middle_name = last_name = ""
|
||||
|
||||
if len(employee_name) >= 3:
|
||||
last_name = " ".join(employee_name[2:])
|
||||
middle_name = employee_name[1]
|
||||
elif len(employee_name) == 2:
|
||||
last_name = employee_name[1]
|
||||
|
||||
first_name = employee_name[0]
|
||||
|
||||
if email:
|
||||
emp.prefered_email = email
|
||||
|
||||
user = frappe.new_doc("User")
|
||||
user.update({
|
||||
"name": emp.employee_name,
|
||||
"email": emp.prefered_email,
|
||||
"enabled": 1,
|
||||
"first_name": first_name,
|
||||
"middle_name": middle_name,
|
||||
"last_name": last_name,
|
||||
"gender": emp.gender,
|
||||
"birth_date": emp.date_of_birth,
|
||||
"phone": emp.cell_number,
|
||||
"bio": emp.bio
|
||||
})
|
||||
user.insert()
|
||||
return user.name
|
||||
|
||||
def get_all_employee_emails(company):
|
||||
'''Returns list of employee emails either based on user_id or company_email'''
|
||||
employee_list = frappe.get_all('Employee',
|
||||
fields=['name','employee_name'],
|
||||
filters={
|
||||
'status': 'Active',
|
||||
'company': company
|
||||
}
|
||||
)
|
||||
employee_emails = []
|
||||
for employee in employee_list:
|
||||
if not employee:
|
||||
continue
|
||||
user, company_email, personal_email = frappe.db.get_value('Employee',
|
||||
employee, ['user_id', 'company_email', 'personal_email'])
|
||||
email = user or company_email or personal_email
|
||||
if email:
|
||||
employee_emails.append(email)
|
||||
return employee_emails
|
||||
|
||||
def get_employee_emails(employee_list):
|
||||
'''Returns list of employee emails either based on user_id or company_email'''
|
||||
employee_emails = []
|
||||
for employee in employee_list:
|
||||
if not employee:
|
||||
continue
|
||||
user, company_email, personal_email = frappe.db.get_value('Employee', employee,
|
||||
['user_id', 'company_email', 'personal_email'])
|
||||
email = user or company_email or personal_email
|
||||
if email:
|
||||
employee_emails.append(email)
|
||||
return employee_emails
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_children(doctype, parent=None, company=None, is_root=False, is_tree=False):
|
||||
|
||||
filters = [['status', '=', 'Active']]
|
||||
if company and company != 'All Companies':
|
||||
filters.append(['company', '=', company])
|
||||
|
||||
fields = ['name as value', 'employee_name as title']
|
||||
|
||||
if is_root:
|
||||
parent = ''
|
||||
if parent and company and parent!=company:
|
||||
filters.append(['reports_to', '=', parent])
|
||||
else:
|
||||
filters.append(['reports_to', '=', ''])
|
||||
|
||||
employees = frappe.get_list(doctype, fields=fields,
|
||||
filters=filters, order_by='name')
|
||||
|
||||
for employee in employees:
|
||||
is_expandable = frappe.get_all(doctype, filters=[
|
||||
['reports_to', '=', employee.get('value')]
|
||||
])
|
||||
employee.expandable = 1 if is_expandable else 0
|
||||
|
||||
return employees
|
||||
|
||||
def on_doctype_update():
|
||||
frappe.db.add_index("Employee", ["lft", "rgt"])
|
||||
|
||||
def has_user_permission_for_employee(user_name, employee_name):
|
||||
return frappe.db.exists({
|
||||
'doctype': 'User Permission',
|
||||
'user': user_name,
|
||||
'allow': 'Employee',
|
||||
'for_value': employee_name
|
||||
})
|
||||
|
||||
def has_upload_permission(doc, ptype='read', user=None):
|
||||
if not user:
|
||||
user = frappe.session.user
|
||||
if get_doc_permissions(doc, user=user, ptype=ptype).get(ptype):
|
||||
return True
|
||||
return doc.user_id == user
|
||||
82
erpnext/hr/doctype/employee/test_employee.py
Normal file
82
erpnext/hr/doctype/employee/test_employee.py
Normal file
@@ -0,0 +1,82 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import frappe
|
||||
import erpnext
|
||||
import unittest
|
||||
import frappe.utils
|
||||
from erpnext.hr.doctype.employee.employee import InactiveEmployeeStatusError
|
||||
|
||||
test_records = frappe.get_test_records('Employee')
|
||||
|
||||
class TestEmployee(unittest.TestCase):
|
||||
def test_employee_status_left(self):
|
||||
employee1 = make_employee("test_employee_1@company.com")
|
||||
employee2 = make_employee("test_employee_2@company.com")
|
||||
employee1_doc = frappe.get_doc("Employee", employee1)
|
||||
employee2_doc = frappe.get_doc("Employee", employee2)
|
||||
employee2_doc.reload()
|
||||
employee2_doc.reports_to = employee1_doc.name
|
||||
employee2_doc.save()
|
||||
employee1_doc.reload()
|
||||
employee1_doc.status = 'Left'
|
||||
self.assertRaises(InactiveEmployeeStatusError, employee1_doc.save)
|
||||
|
||||
def test_employee_status_inactive(self):
|
||||
from erpnext.payroll.doctype.salary_structure.test_salary_structure import make_salary_structure
|
||||
from erpnext.payroll.doctype.salary_structure.salary_structure import make_salary_slip
|
||||
from erpnext.payroll.doctype.salary_slip.test_salary_slip import make_holiday_list
|
||||
|
||||
employee = make_employee("test_employee_status@company.com")
|
||||
employee_doc = frappe.get_doc("Employee", employee)
|
||||
employee_doc.status = "Inactive"
|
||||
employee_doc.save()
|
||||
employee_doc.reload()
|
||||
|
||||
make_holiday_list()
|
||||
frappe.db.set_value("Company", erpnext.get_default_company(), "default_holiday_list", "Salary Slip Test Holiday List")
|
||||
|
||||
frappe.db.sql("""delete from `tabSalary Structure` where name='Test Inactive Employee Salary Slip'""")
|
||||
salary_structure = make_salary_structure("Test Inactive Employee Salary Slip", "Monthly",
|
||||
employee=employee_doc.name, company=employee_doc.company)
|
||||
salary_slip = make_salary_slip(salary_structure.name, employee=employee_doc.name)
|
||||
|
||||
self.assertRaises(InactiveEmployeeStatusError, salary_slip.save)
|
||||
|
||||
def tearDown(self):
|
||||
frappe.db.rollback()
|
||||
|
||||
def make_employee(user, company=None, **kwargs):
|
||||
if not frappe.db.get_value("User", user):
|
||||
frappe.get_doc({
|
||||
"doctype": "User",
|
||||
"email": user,
|
||||
"first_name": user,
|
||||
"new_password": "password",
|
||||
"roles": [{"doctype": "Has Role", "role": "Employee"}]
|
||||
}).insert()
|
||||
|
||||
if not frappe.db.get_value("Employee", {"user_id": user}):
|
||||
employee = frappe.get_doc({
|
||||
"doctype": "Employee",
|
||||
"naming_series": "EMP-",
|
||||
"first_name": user,
|
||||
"company": company or erpnext.get_default_company(),
|
||||
"user_id": user,
|
||||
"date_of_birth": "1990-05-08",
|
||||
"date_of_joining": "2013-01-01",
|
||||
"department": frappe.get_all("Department", fields="name")[0].name,
|
||||
"gender": "Female",
|
||||
"company_email": user,
|
||||
"prefered_contact_email": "Company Email",
|
||||
"prefered_email": user,
|
||||
"status": "Active",
|
||||
"employment_type": "Intern"
|
||||
})
|
||||
if kwargs:
|
||||
employee.update(kwargs)
|
||||
employee.insert()
|
||||
return employee.name
|
||||
else:
|
||||
frappe.db.set_value("Employee", {"employee_name":user}, "status", "Active")
|
||||
return frappe.get_value("Employee", {"employee_name":user}, "name")
|
||||
209
erpnext/hr/doctype/hr_settings/hr_settings.json
Normal file
209
erpnext/hr/doctype/hr_settings/hr_settings.json
Normal file
@@ -0,0 +1,209 @@
|
||||
{
|
||||
"actions": [],
|
||||
"creation": "2013-08-02 13:45:23",
|
||||
"doctype": "DocType",
|
||||
"document_type": "Other",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"employee_settings",
|
||||
"retirement_age",
|
||||
"emp_created_by",
|
||||
"column_break_4",
|
||||
"standard_working_hours",
|
||||
"expense_approver_mandatory_in_expense_claim",
|
||||
"reminders_section",
|
||||
"send_birthday_reminders",
|
||||
"column_break_9",
|
||||
"send_work_anniversary_reminders",
|
||||
"column_break_11",
|
||||
"send_holiday_reminders",
|
||||
"frequency",
|
||||
"leave_settings",
|
||||
"send_leave_notification",
|
||||
"leave_approval_notification_template",
|
||||
"leave_status_notification_template",
|
||||
"role_allowed_to_create_backdated_leave_application",
|
||||
"column_break_18",
|
||||
"leave_approver_mandatory_in_leave_application",
|
||||
"show_leaves_of_all_department_members_in_calendar",
|
||||
"auto_leave_encashment",
|
||||
"restrict_backdated_leave_application",
|
||||
"hiring_settings",
|
||||
"check_vacancies"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "employee_settings",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Employee Settings"
|
||||
},
|
||||
{
|
||||
"description": "Enter retirement age in years",
|
||||
"fieldname": "retirement_age",
|
||||
"fieldtype": "Data",
|
||||
"label": "Retirement Age"
|
||||
},
|
||||
{
|
||||
"default": "Naming Series",
|
||||
"description": "Employee records are created using the selected field",
|
||||
"fieldname": "emp_created_by",
|
||||
"fieldtype": "Select",
|
||||
"label": "Employee Records to be created by",
|
||||
"options": "Naming Series\nEmployee Number\nFull Name"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_4",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"fieldname": "expense_approver_mandatory_in_expense_claim",
|
||||
"fieldtype": "Check",
|
||||
"label": "Expense Approver Mandatory In Expense Claim"
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"fieldname": "leave_settings",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Leave Settings"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval: doc.send_leave_notification == 1",
|
||||
"fieldname": "leave_approval_notification_template",
|
||||
"fieldtype": "Link",
|
||||
"label": "Leave Approval Notification Template",
|
||||
"mandatory_depends_on": "eval: doc.send_leave_notification == 1",
|
||||
"options": "Email Template"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval: doc.send_leave_notification == 1",
|
||||
"fieldname": "leave_status_notification_template",
|
||||
"fieldtype": "Link",
|
||||
"label": "Leave Status Notification Template",
|
||||
"mandatory_depends_on": "eval: doc.send_leave_notification == 1",
|
||||
"options": "Email Template"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_18",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"fieldname": "leave_approver_mandatory_in_leave_application",
|
||||
"fieldtype": "Check",
|
||||
"label": "Leave Approver Mandatory In Leave Application"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "show_leaves_of_all_department_members_in_calendar",
|
||||
"fieldtype": "Check",
|
||||
"label": "Show Leaves Of All Department Members In Calendar"
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"fieldname": "hiring_settings",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Hiring Settings"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "check_vacancies",
|
||||
"fieldtype": "Check",
|
||||
"label": "Check Vacancies On Job Offer Creation"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "auto_leave_encashment",
|
||||
"fieldtype": "Check",
|
||||
"label": "Auto Leave Encashment"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "restrict_backdated_leave_application",
|
||||
"fieldtype": "Check",
|
||||
"label": "Restrict Backdated Leave Application"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.restrict_backdated_leave_application == 1",
|
||||
"fieldname": "role_allowed_to_create_backdated_leave_application",
|
||||
"fieldtype": "Link",
|
||||
"label": "Role Allowed to Create Backdated Leave Application",
|
||||
"options": "Role"
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"fieldname": "send_leave_notification",
|
||||
"fieldtype": "Check",
|
||||
"label": "Send Leave Notification"
|
||||
},
|
||||
{
|
||||
"fieldname": "standard_working_hours",
|
||||
"fieldtype": "Int",
|
||||
"label": "Standard Working Hours"
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"fieldname": "reminders_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Reminders"
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"fieldname": "send_holiday_reminders",
|
||||
"fieldtype": "Check",
|
||||
"label": "Holidays"
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"fieldname": "send_work_anniversary_reminders",
|
||||
"fieldtype": "Check",
|
||||
"label": "Work Anniversaries "
|
||||
},
|
||||
{
|
||||
"default": "Weekly",
|
||||
"depends_on": "eval:doc.send_holiday_reminders",
|
||||
"fieldname": "frequency",
|
||||
"fieldtype": "Select",
|
||||
"label": "Set the frequency for holiday reminders",
|
||||
"options": "Weekly\nMonthly"
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"fieldname": "send_birthday_reminders",
|
||||
"fieldtype": "Check",
|
||||
"label": "Birthdays"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_9",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_11",
|
||||
"fieldtype": "Column Break"
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-cog",
|
||||
"idx": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2021-08-24 14:54:12.834162",
|
||||
"modified_by": "Administrator",
|
||||
"module": "HR",
|
||||
"name": "HR Settings",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"email": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"sort_field": "modified",
|
||||
"sort_order": "ASC",
|
||||
"track_changes": 1
|
||||
}
|
||||
79
erpnext/hr/doctype/hr_settings/hr_settings.py
Normal file
79
erpnext/hr/doctype/hr_settings/hr_settings.py
Normal file
@@ -0,0 +1,79 @@
|
||||
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import format_date
|
||||
|
||||
# Wether to proceed with frequency change
|
||||
PROCEED_WITH_FREQUENCY_CHANGE = False
|
||||
|
||||
class HRSettings(Document):
|
||||
def validate(self):
|
||||
self.set_naming_series()
|
||||
|
||||
# Based on proceed flag
|
||||
global PROCEED_WITH_FREQUENCY_CHANGE
|
||||
if not PROCEED_WITH_FREQUENCY_CHANGE:
|
||||
self.validate_frequency_change()
|
||||
PROCEED_WITH_FREQUENCY_CHANGE = False
|
||||
|
||||
def set_naming_series(self):
|
||||
from erpnext.setup.doctype.naming_series.naming_series import set_by_naming_series
|
||||
set_by_naming_series("Employee", "employee_number",
|
||||
self.get("emp_created_by")=="Naming Series", hide_name_field=True)
|
||||
|
||||
def validate_frequency_change(self):
|
||||
weekly_job, monthly_job = None, None
|
||||
|
||||
try:
|
||||
weekly_job = frappe.get_doc(
|
||||
'Scheduled Job Type',
|
||||
'employee_reminders.send_reminders_in_advance_weekly'
|
||||
)
|
||||
|
||||
monthly_job = frappe.get_doc(
|
||||
'Scheduled Job Type',
|
||||
'employee_reminders.send_reminders_in_advance_monthly'
|
||||
)
|
||||
except frappe.DoesNotExistError:
|
||||
return
|
||||
|
||||
next_weekly_trigger = weekly_job.get_next_execution()
|
||||
next_monthly_trigger = monthly_job.get_next_execution()
|
||||
|
||||
if self.freq_changed_from_monthly_to_weekly():
|
||||
if next_monthly_trigger < next_weekly_trigger:
|
||||
self.show_freq_change_warning(next_monthly_trigger, next_weekly_trigger)
|
||||
|
||||
elif self.freq_changed_from_weekly_to_monthly():
|
||||
if next_monthly_trigger > next_weekly_trigger:
|
||||
self.show_freq_change_warning(next_weekly_trigger, next_monthly_trigger)
|
||||
|
||||
def freq_changed_from_weekly_to_monthly(self):
|
||||
return self.has_value_changed("frequency") and self.frequency == "Monthly"
|
||||
|
||||
def freq_changed_from_monthly_to_weekly(self):
|
||||
return self.has_value_changed("frequency") and self.frequency == "Weekly"
|
||||
|
||||
def show_freq_change_warning(self, from_date, to_date):
|
||||
from_date = frappe.bold(format_date(from_date))
|
||||
to_date = frappe.bold(format_date(to_date))
|
||||
frappe.msgprint(
|
||||
msg=frappe._('Employees will miss holiday reminders from {} until {}. <br> Do you want to proceed with this change?').format(from_date, to_date),
|
||||
title='Confirm change in Frequency',
|
||||
primary_action={
|
||||
'label': frappe._('Yes, Proceed'),
|
||||
'client_action': 'erpnext.proceed_save_with_reminders_frequency_change'
|
||||
},
|
||||
raise_exception=frappe.ValidationError
|
||||
)
|
||||
|
||||
@frappe.whitelist()
|
||||
def set_proceed_with_frequency_change():
|
||||
'''Enables proceed with frequency change'''
|
||||
global PROCEED_WITH_FREQUENCY_CHANGE
|
||||
PROCEED_WITH_FREQUENCY_CHANGE = True
|
||||
204
erpnext/hr/doctype/upload_attendance/upload_attendance.py
Normal file
204
erpnext/hr/doctype/upload_attendance/upload_attendance.py
Normal file
@@ -0,0 +1,204 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
# For license information, please see license.txt
|
||||
|
||||
from __future__ import unicode_literals
|
||||
import frappe
|
||||
from frappe.utils import cstr, add_days, date_diff, getdate
|
||||
from frappe import _
|
||||
from frappe.utils.csvutils import UnicodeWriter
|
||||
from frappe.model.document import Document
|
||||
from erpnext.hr.doctype.employee.employee import get_holiday_list_for_employee
|
||||
from erpnext.hr.utils import get_holiday_dates_for_employee
|
||||
|
||||
class UploadAttendance(Document):
|
||||
pass
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_template():
|
||||
if not frappe.has_permission("Attendance", "create"):
|
||||
raise frappe.PermissionError
|
||||
|
||||
args = frappe.local.form_dict
|
||||
|
||||
if getdate(args.from_date) > getdate(args.to_date):
|
||||
frappe.throw(_("To Date should be greater than From Date"))
|
||||
|
||||
w = UnicodeWriter()
|
||||
w = add_header(w)
|
||||
|
||||
try:
|
||||
w = add_data(w, args)
|
||||
except Exception as e:
|
||||
frappe.clear_messages()
|
||||
frappe.respond_as_web_page("Holiday List Missing", html=e)
|
||||
return
|
||||
|
||||
# write out response as a type csv
|
||||
frappe.response['result'] = cstr(w.getvalue())
|
||||
frappe.response['type'] = 'csv'
|
||||
frappe.response['doctype'] = "Attendance"
|
||||
|
||||
def add_header(w):
|
||||
status = ", ".join((frappe.get_meta("Attendance").get_field("status").options or "").strip().split("\n"))
|
||||
w.writerow(["Notes:"])
|
||||
w.writerow(["Please do not change the template headings"])
|
||||
w.writerow(["Status should be one of these values: " + status])
|
||||
w.writerow(["If you are overwriting existing attendance records, 'ID' column mandatory"])
|
||||
w.writerow(["ID", "Employee", "Employee Name", "Date", "Status", "Leave Type",
|
||||
"Company", "Naming Series"])
|
||||
return w
|
||||
|
||||
def add_data(w, args):
|
||||
data = get_data(args)
|
||||
writedata(w, data)
|
||||
return w
|
||||
|
||||
def get_data(args):
|
||||
dates = get_dates(args)
|
||||
employees = get_active_employees()
|
||||
holidays = get_holidays_for_employees([employee.name for employee in employees], args["from_date"], args["to_date"])
|
||||
existing_attendance_records = get_existing_attendance_records(args)
|
||||
data = []
|
||||
for date in dates:
|
||||
for employee in employees:
|
||||
if getdate(date) < getdate(employee.date_of_joining):
|
||||
continue
|
||||
if employee.relieving_date:
|
||||
if getdate(date) > getdate(employee.relieving_date):
|
||||
continue
|
||||
existing_attendance = {}
|
||||
if existing_attendance_records \
|
||||
and tuple([getdate(date), employee.name]) in existing_attendance_records \
|
||||
and getdate(employee.date_of_joining) <= getdate(date) \
|
||||
and getdate(employee.relieving_date) >= getdate(date):
|
||||
existing_attendance = existing_attendance_records[tuple([getdate(date), employee.name])]
|
||||
|
||||
employee_holiday_list = get_holiday_list_for_employee(employee.name)
|
||||
|
||||
row = [
|
||||
existing_attendance and existing_attendance.name or "",
|
||||
employee.name, employee.employee_name, date,
|
||||
existing_attendance and existing_attendance.status or "",
|
||||
existing_attendance and existing_attendance.leave_type or "", employee.company,
|
||||
existing_attendance and existing_attendance.naming_series or get_naming_series(),
|
||||
]
|
||||
if date in holidays[employee_holiday_list]:
|
||||
row[4] = "Holiday"
|
||||
data.append(row)
|
||||
|
||||
return data
|
||||
|
||||
def get_holidays_for_employees(employees, from_date, to_date):
|
||||
holidays = {}
|
||||
for employee in employees:
|
||||
holiday_list = get_holiday_list_for_employee(employee)
|
||||
holiday = get_holiday_dates_for_employee(employee, getdate(from_date), getdate(to_date))
|
||||
if holiday_list not in holidays:
|
||||
holidays[holiday_list] = holiday
|
||||
|
||||
return holidays
|
||||
|
||||
def writedata(w, data):
|
||||
for row in data:
|
||||
w.writerow(row)
|
||||
|
||||
def get_dates(args):
|
||||
"""get list of dates in between from date and to date"""
|
||||
no_of_days = date_diff(add_days(args["to_date"], 1), args["from_date"])
|
||||
dates = [add_days(args["from_date"], i) for i in range(0, no_of_days)]
|
||||
return dates
|
||||
|
||||
def get_active_employees():
|
||||
employees = frappe.db.get_all('Employee',
|
||||
fields=['name', 'employee_name', 'date_of_joining', 'company', 'relieving_date'],
|
||||
filters={
|
||||
'docstatus': ['<', 2],
|
||||
'status': 'Active'
|
||||
}
|
||||
)
|
||||
return employees
|
||||
|
||||
def get_existing_attendance_records(args):
|
||||
attendance = frappe.db.sql("""select name, attendance_date, employee, status, leave_type, naming_series
|
||||
from `tabAttendance` where attendance_date between %s and %s and docstatus < 2""",
|
||||
(args["from_date"], args["to_date"]), as_dict=1)
|
||||
|
||||
existing_attendance = {}
|
||||
for att in attendance:
|
||||
existing_attendance[tuple([att.attendance_date, att.employee])] = att
|
||||
|
||||
return existing_attendance
|
||||
|
||||
def get_naming_series():
|
||||
series = frappe.get_meta("Attendance").get_field("naming_series").options.strip().split("\n")
|
||||
if not series:
|
||||
frappe.throw(_("Please setup numbering series for Attendance via Setup > Numbering Series"))
|
||||
return series[0]
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def upload():
|
||||
if not frappe.has_permission("Attendance", "create"):
|
||||
raise frappe.PermissionError
|
||||
|
||||
from frappe.utils.csvutils import read_csv_content
|
||||
rows = read_csv_content(frappe.local.uploaded_file)
|
||||
if not rows:
|
||||
frappe.throw(_("Please select a csv file"))
|
||||
frappe.enqueue(import_attendances, rows=rows, now=True if len(rows) < 200 else False)
|
||||
|
||||
def import_attendances(rows):
|
||||
|
||||
def remove_holidays(rows):
|
||||
rows = [ row for row in rows if row[4] != "Holiday"]
|
||||
return rows
|
||||
|
||||
from frappe.modules import scrub
|
||||
|
||||
rows = list(filter(lambda x: x and any(x), rows))
|
||||
columns = [scrub(f) for f in rows[4]]
|
||||
columns[0] = "name"
|
||||
columns[3] = "attendance_date"
|
||||
rows = rows[5:]
|
||||
ret = []
|
||||
error = False
|
||||
|
||||
rows = remove_holidays(rows)
|
||||
|
||||
from frappe.utils.csvutils import check_record, import_doc
|
||||
|
||||
for i, row in enumerate(rows):
|
||||
if not row: continue
|
||||
row_idx = i + 5
|
||||
d = frappe._dict(zip(columns, row))
|
||||
|
||||
d["doctype"] = "Attendance"
|
||||
if d.name:
|
||||
d["docstatus"] = frappe.db.get_value("Attendance", d.name, "docstatus")
|
||||
|
||||
try:
|
||||
check_record(d)
|
||||
ret.append(import_doc(d, "Attendance", 1, row_idx, submit=True))
|
||||
frappe.publish_realtime('import_attendance', dict(
|
||||
progress=i,
|
||||
total=len(rows)
|
||||
))
|
||||
except AttributeError:
|
||||
pass
|
||||
except Exception as e:
|
||||
error = True
|
||||
ret.append('Error for row (#%d) %s : %s' % (row_idx,
|
||||
len(row)>1 and row[1] or "", cstr(e)))
|
||||
frappe.errprint(frappe.get_traceback())
|
||||
|
||||
if error:
|
||||
frappe.db.rollback()
|
||||
else:
|
||||
frappe.db.commit()
|
||||
|
||||
frappe.publish_realtime('import_attendance', dict(
|
||||
messages=ret,
|
||||
error=error
|
||||
))
|
||||
552
erpnext/hr/utils.py
Normal file
552
erpnext/hr/utils.py
Normal file
@@ -0,0 +1,552 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import erpnext
|
||||
import frappe
|
||||
from erpnext.hr.doctype.employee.employee import get_holiday_list_for_employee, InactiveEmployeeStatusError
|
||||
from frappe import _
|
||||
from frappe.desk.form import assign_to
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import (add_days, cstr, flt, format_datetime, formatdate,
|
||||
get_datetime, getdate, nowdate, today, unique, get_link_to_form)
|
||||
|
||||
class DuplicateDeclarationError(frappe.ValidationError): pass
|
||||
|
||||
|
||||
class EmployeeBoardingController(Document):
|
||||
'''
|
||||
Create the project and the task for the boarding process
|
||||
Assign to the concerned person and roles as per the onboarding/separation template
|
||||
'''
|
||||
def validate(self):
|
||||
validate_active_employee(self.employee)
|
||||
# remove the task if linked before submitting the form
|
||||
if self.amended_from:
|
||||
for activity in self.activities:
|
||||
activity.task = ''
|
||||
|
||||
def on_submit(self):
|
||||
# create the project for the given employee onboarding
|
||||
project_name = _(self.doctype) + " : "
|
||||
if self.doctype == "Employee Onboarding":
|
||||
project_name += self.job_applicant
|
||||
else:
|
||||
project_name += self.employee
|
||||
|
||||
project = frappe.get_doc({
|
||||
"doctype": "Project",
|
||||
"project_name": project_name,
|
||||
"expected_start_date": self.date_of_joining if self.doctype == "Employee Onboarding" else self.resignation_letter_date,
|
||||
"department": self.department,
|
||||
"company": self.company
|
||||
}).insert(ignore_permissions=True, ignore_mandatory=True)
|
||||
|
||||
self.db_set("project", project.name)
|
||||
self.db_set("boarding_status", "Pending")
|
||||
self.reload()
|
||||
self.create_task_and_notify_user()
|
||||
|
||||
def create_task_and_notify_user(self):
|
||||
# create the task for the given project and assign to the concerned person
|
||||
for activity in self.activities:
|
||||
if activity.task:
|
||||
continue
|
||||
|
||||
task = frappe.get_doc({
|
||||
"doctype": "Task",
|
||||
"project": self.project,
|
||||
"subject": activity.activity_name + " : " + self.employee_name,
|
||||
"description": activity.description,
|
||||
"department": self.department,
|
||||
"company": self.company,
|
||||
"task_weight": activity.task_weight
|
||||
}).insert(ignore_permissions=True)
|
||||
activity.db_set("task", task.name)
|
||||
|
||||
users = [activity.user] if activity.user else []
|
||||
if activity.role:
|
||||
user_list = frappe.db.sql_list('''
|
||||
SELECT
|
||||
DISTINCT(has_role.parent)
|
||||
FROM
|
||||
`tabHas Role` has_role
|
||||
LEFT JOIN `tabUser` user
|
||||
ON has_role.parent = user.name
|
||||
WHERE
|
||||
has_role.parenttype = 'User'
|
||||
AND user.enabled = 1
|
||||
AND has_role.role = %s
|
||||
''', activity.role)
|
||||
users = unique(users + user_list)
|
||||
|
||||
if "Administrator" in users:
|
||||
users.remove("Administrator")
|
||||
|
||||
# assign the task the users
|
||||
if users:
|
||||
self.assign_task_to_users(task, users)
|
||||
|
||||
def assign_task_to_users(self, task, users):
|
||||
for user in users:
|
||||
args = {
|
||||
'assign_to': [user],
|
||||
'doctype': task.doctype,
|
||||
'name': task.name,
|
||||
'description': task.description or task.subject,
|
||||
'notify': self.notify_users_by_email
|
||||
}
|
||||
assign_to.add(args)
|
||||
|
||||
def on_cancel(self):
|
||||
# delete task project
|
||||
for task in frappe.get_all("Task", filters={"project": self.project}):
|
||||
frappe.delete_doc("Task", task.name, force=1)
|
||||
frappe.delete_doc("Project", self.project, force=1)
|
||||
self.db_set('project', '')
|
||||
for activity in self.activities:
|
||||
activity.db_set("task", "")
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_onboarding_details(parent, parenttype):
|
||||
return frappe.get_all("Employee Boarding Activity",
|
||||
fields=["activity_name", "role", "user", "required_for_employee_creation", "description", "task_weight"],
|
||||
filters={"parent": parent, "parenttype": parenttype},
|
||||
order_by= "idx")
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_boarding_status(project):
|
||||
status = 'Pending'
|
||||
if project:
|
||||
doc = frappe.get_doc('Project', project)
|
||||
if flt(doc.percent_complete) > 0.0 and flt(doc.percent_complete) < 100.0:
|
||||
status = 'In Process'
|
||||
elif flt(doc.percent_complete) == 100.0:
|
||||
status = 'Completed'
|
||||
return status
|
||||
|
||||
def set_employee_name(doc):
|
||||
if doc.employee and not doc.employee_name:
|
||||
doc.employee_name = frappe.db.get_value("Employee", doc.employee, "employee_name")
|
||||
|
||||
def update_employee(employee, details, date=None, cancel=False):
|
||||
internal_work_history = {}
|
||||
for item in details:
|
||||
fieldtype = frappe.get_meta("Employee").get_field(item.fieldname).fieldtype
|
||||
new_data = item.new if not cancel else item.current
|
||||
if fieldtype == "Date" and new_data:
|
||||
new_data = getdate(new_data)
|
||||
elif fieldtype =="Datetime" and new_data:
|
||||
new_data = get_datetime(new_data)
|
||||
setattr(employee, item.fieldname, new_data)
|
||||
if item.fieldname in ["department", "designation", "branch"]:
|
||||
internal_work_history[item.fieldname] = item.new
|
||||
if internal_work_history and not cancel:
|
||||
internal_work_history["from_date"] = date
|
||||
employee.append("internal_work_history", internal_work_history)
|
||||
return employee
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_employee_fields_label():
|
||||
fields = []
|
||||
for df in frappe.get_meta("Employee").get("fields"):
|
||||
if df.fieldname in ["salutation", "user_id", "employee_number", "employment_type",
|
||||
"holiday_list", "branch", "department", "designation", "grade",
|
||||
"notice_number_of_days", "reports_to", "leave_policy", "company_email"]:
|
||||
fields.append({"value": df.fieldname, "label": df.label})
|
||||
return fields
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_employee_field_property(employee, fieldname):
|
||||
if employee and fieldname:
|
||||
field = frappe.get_meta("Employee").get_field(fieldname)
|
||||
value = frappe.db.get_value("Employee", employee, fieldname)
|
||||
options = field.options
|
||||
if field.fieldtype == "Date":
|
||||
value = formatdate(value)
|
||||
elif field.fieldtype == "Datetime":
|
||||
value = format_datetime(value)
|
||||
return {
|
||||
"value" : value,
|
||||
"datatype" : field.fieldtype,
|
||||
"label" : field.label,
|
||||
"options" : options
|
||||
}
|
||||
else:
|
||||
return False
|
||||
|
||||
def validate_dates(doc, from_date, to_date):
|
||||
date_of_joining, relieving_date = frappe.db.get_value("Employee", doc.employee, ["date_of_joining", "relieving_date"])
|
||||
if getdate(from_date) > getdate(to_date):
|
||||
frappe.throw(_("To date can not be less than from date"))
|
||||
elif getdate(from_date) > getdate(nowdate()):
|
||||
frappe.throw(_("Future dates not allowed"))
|
||||
elif date_of_joining and getdate(from_date) < getdate(date_of_joining):
|
||||
frappe.throw(_("From date can not be less than employee's joining date"))
|
||||
elif relieving_date and getdate(to_date) > getdate(relieving_date):
|
||||
frappe.throw(_("To date can not greater than employee's relieving date"))
|
||||
|
||||
def validate_overlap(doc, from_date, to_date, company = None):
|
||||
query = """
|
||||
select name
|
||||
from `tab{0}`
|
||||
where name != %(name)s
|
||||
"""
|
||||
query += get_doc_condition(doc.doctype)
|
||||
|
||||
if not doc.name:
|
||||
# hack! if name is null, it could cause problems with !=
|
||||
doc.name = "New "+doc.doctype
|
||||
|
||||
overlap_doc = frappe.db.sql(query.format(doc.doctype),{
|
||||
"employee": doc.get("employee"),
|
||||
"from_date": from_date,
|
||||
"to_date": to_date,
|
||||
"name": doc.name,
|
||||
"company": company
|
||||
}, as_dict = 1)
|
||||
|
||||
if overlap_doc:
|
||||
if doc.get("employee"):
|
||||
exists_for = doc.employee
|
||||
if company:
|
||||
exists_for = company
|
||||
throw_overlap_error(doc, exists_for, overlap_doc[0].name, from_date, to_date)
|
||||
|
||||
def get_doc_condition(doctype):
|
||||
if doctype == "Compensatory Leave Request":
|
||||
return "and employee = %(employee)s and docstatus < 2 \
|
||||
and (work_from_date between %(from_date)s and %(to_date)s \
|
||||
or work_end_date between %(from_date)s and %(to_date)s \
|
||||
or (work_from_date < %(from_date)s and work_end_date > %(to_date)s))"
|
||||
elif doctype == "Leave Period":
|
||||
return "and company = %(company)s and (from_date between %(from_date)s and %(to_date)s \
|
||||
or to_date between %(from_date)s and %(to_date)s \
|
||||
or (from_date < %(from_date)s and to_date > %(to_date)s))"
|
||||
|
||||
def throw_overlap_error(doc, exists_for, overlap_doc, from_date, to_date):
|
||||
msg = _("A {0} exists between {1} and {2} (").format(doc.doctype,
|
||||
formatdate(from_date), formatdate(to_date)) \
|
||||
+ """ <b><a href="/app/Form/{0}/{1}">{1}</a></b>""".format(doc.doctype, overlap_doc) \
|
||||
+ _(") for {0}").format(exists_for)
|
||||
frappe.throw(msg)
|
||||
|
||||
def validate_duplicate_exemption_for_payroll_period(doctype, docname, payroll_period, employee):
|
||||
existing_record = frappe.db.exists(doctype, {
|
||||
"payroll_period": payroll_period,
|
||||
"employee": employee,
|
||||
'docstatus': ['<', 2],
|
||||
'name': ['!=', docname]
|
||||
})
|
||||
if existing_record:
|
||||
frappe.throw(_("{0} already exists for employee {1} and period {2}")
|
||||
.format(doctype, employee, payroll_period), DuplicateDeclarationError)
|
||||
|
||||
def validate_tax_declaration(declarations):
|
||||
subcategories = []
|
||||
for d in declarations:
|
||||
if d.exemption_sub_category in subcategories:
|
||||
frappe.throw(_("More than one selection for {0} not allowed").format(d.exemption_sub_category))
|
||||
subcategories.append(d.exemption_sub_category)
|
||||
|
||||
def get_total_exemption_amount(declarations):
|
||||
exemptions = frappe._dict()
|
||||
for d in declarations:
|
||||
exemptions.setdefault(d.exemption_category, frappe._dict())
|
||||
category_max_amount = exemptions.get(d.exemption_category).max_amount
|
||||
if not category_max_amount:
|
||||
category_max_amount = frappe.db.get_value("Employee Tax Exemption Category", d.exemption_category, "max_amount")
|
||||
exemptions.get(d.exemption_category).max_amount = category_max_amount
|
||||
sub_category_exemption_amount = d.max_amount \
|
||||
if (d.max_amount and flt(d.amount) > flt(d.max_amount)) else d.amount
|
||||
|
||||
exemptions.get(d.exemption_category).setdefault("total_exemption_amount", 0.0)
|
||||
exemptions.get(d.exemption_category).total_exemption_amount += flt(sub_category_exemption_amount)
|
||||
|
||||
if category_max_amount and exemptions.get(d.exemption_category).total_exemption_amount > category_max_amount:
|
||||
exemptions.get(d.exemption_category).total_exemption_amount = category_max_amount
|
||||
|
||||
total_exemption_amount = sum([flt(d.total_exemption_amount) for d in exemptions.values()])
|
||||
return total_exemption_amount
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_leave_period(from_date, to_date, company):
|
||||
leave_period = frappe.db.sql("""
|
||||
select name, from_date, to_date
|
||||
from `tabLeave Period`
|
||||
where company=%(company)s and is_active=1
|
||||
and (from_date between %(from_date)s and %(to_date)s
|
||||
or to_date between %(from_date)s and %(to_date)s
|
||||
or (from_date < %(from_date)s and to_date > %(to_date)s))
|
||||
""", {
|
||||
"from_date": from_date,
|
||||
"to_date": to_date,
|
||||
"company": company
|
||||
}, as_dict=1)
|
||||
|
||||
if leave_period:
|
||||
return leave_period
|
||||
|
||||
def generate_leave_encashment():
|
||||
''' Generates a draft leave encashment on allocation expiry '''
|
||||
from erpnext.hr.doctype.leave_encashment.leave_encashment import create_leave_encashment
|
||||
|
||||
if frappe.db.get_single_value('HR Settings', 'auto_leave_encashment'):
|
||||
leave_type = frappe.get_all('Leave Type', filters={'allow_encashment': 1}, fields=['name'])
|
||||
leave_type=[l['name'] for l in leave_type]
|
||||
|
||||
leave_allocation = frappe.get_all("Leave Allocation", filters={
|
||||
'to_date': add_days(today(), -1),
|
||||
'leave_type': ('in', leave_type)
|
||||
}, fields=['employee', 'leave_period', 'leave_type', 'to_date', 'total_leaves_allocated', 'new_leaves_allocated'])
|
||||
|
||||
create_leave_encashment(leave_allocation=leave_allocation)
|
||||
|
||||
def allocate_earned_leaves():
|
||||
'''Allocate earned leaves to Employees'''
|
||||
e_leave_types = get_earned_leaves()
|
||||
today = getdate()
|
||||
|
||||
for e_leave_type in e_leave_types:
|
||||
|
||||
leave_allocations = get_leave_allocations(today, e_leave_type.name)
|
||||
|
||||
for allocation in leave_allocations:
|
||||
|
||||
if not allocation.leave_policy_assignment and not allocation.leave_policy:
|
||||
continue
|
||||
|
||||
leave_policy = allocation.leave_policy if allocation.leave_policy else frappe.db.get_value(
|
||||
"Leave Policy Assignment", allocation.leave_policy_assignment, ["leave_policy"])
|
||||
|
||||
annual_allocation = frappe.db.get_value("Leave Policy Detail", filters={
|
||||
'parent': leave_policy,
|
||||
'leave_type': e_leave_type.name
|
||||
}, fieldname=['annual_allocation'])
|
||||
|
||||
from_date=allocation.from_date
|
||||
|
||||
if e_leave_type.based_on_date_of_joining_date:
|
||||
from_date = frappe.db.get_value("Employee", allocation.employee, "date_of_joining")
|
||||
|
||||
if check_effective_date(from_date, today, e_leave_type.earned_leave_frequency, e_leave_type.based_on_date_of_joining_date):
|
||||
update_previous_leave_allocation(allocation, annual_allocation, e_leave_type)
|
||||
|
||||
def update_previous_leave_allocation(allocation, annual_allocation, e_leave_type):
|
||||
earned_leaves = get_monthly_earned_leave(annual_allocation, e_leave_type.earned_leave_frequency, e_leave_type.rounding)
|
||||
|
||||
allocation = frappe.get_doc('Leave Allocation', allocation.name)
|
||||
new_allocation = flt(allocation.total_leaves_allocated) + flt(earned_leaves)
|
||||
|
||||
if new_allocation > e_leave_type.max_leaves_allowed and e_leave_type.max_leaves_allowed > 0:
|
||||
new_allocation = e_leave_type.max_leaves_allowed
|
||||
|
||||
if new_allocation != allocation.total_leaves_allocated:
|
||||
allocation.db_set("total_leaves_allocated", new_allocation, update_modified=False)
|
||||
today_date = today()
|
||||
create_additional_leave_ledger_entry(allocation, earned_leaves, today_date)
|
||||
|
||||
def get_monthly_earned_leave(annual_leaves, frequency, rounding):
|
||||
earned_leaves = 0.0
|
||||
divide_by_frequency = {"Yearly": 1, "Half-Yearly": 6, "Quarterly": 4, "Monthly": 12}
|
||||
if annual_leaves:
|
||||
earned_leaves = flt(annual_leaves) / divide_by_frequency[frequency]
|
||||
if rounding:
|
||||
if rounding == "0.25":
|
||||
earned_leaves = round(earned_leaves * 4) / 4
|
||||
elif rounding == "0.5":
|
||||
earned_leaves = round(earned_leaves * 2) / 2
|
||||
else:
|
||||
earned_leaves = round(earned_leaves)
|
||||
|
||||
return earned_leaves
|
||||
|
||||
|
||||
def get_leave_allocations(date, leave_type):
|
||||
return frappe.db.sql("""select name, employee, from_date, to_date, leave_policy_assignment, leave_policy
|
||||
from `tabLeave Allocation`
|
||||
where
|
||||
%s between from_date and to_date and docstatus=1
|
||||
and leave_type=%s""",
|
||||
(date, leave_type), as_dict=1)
|
||||
|
||||
|
||||
def get_earned_leaves():
|
||||
return frappe.get_all("Leave Type",
|
||||
fields=["name", "max_leaves_allowed", "earned_leave_frequency", "rounding", "based_on_date_of_joining"],
|
||||
filters={'is_earned_leave' : 1})
|
||||
|
||||
def create_additional_leave_ledger_entry(allocation, leaves, date):
|
||||
''' Create leave ledger entry for leave types '''
|
||||
allocation.new_leaves_allocated = leaves
|
||||
allocation.from_date = date
|
||||
allocation.unused_leaves = 0
|
||||
allocation.create_leave_ledger_entry()
|
||||
|
||||
def check_effective_date(from_date, to_date, frequency, based_on_date_of_joining_date):
|
||||
import calendar
|
||||
from dateutil import relativedelta
|
||||
|
||||
from_date = get_datetime(from_date)
|
||||
to_date = get_datetime(to_date)
|
||||
rd = relativedelta.relativedelta(to_date, from_date)
|
||||
#last day of month
|
||||
last_day = calendar.monthrange(to_date.year, to_date.month)[1]
|
||||
|
||||
if (from_date.day == to_date.day and based_on_date_of_joining_date) or (not based_on_date_of_joining_date and to_date.day == last_day):
|
||||
if frequency == "Monthly":
|
||||
return True
|
||||
elif frequency == "Quarterly" and rd.months % 3:
|
||||
return True
|
||||
elif frequency == "Half-Yearly" and rd.months % 6:
|
||||
return True
|
||||
elif frequency == "Yearly" and rd.months % 12:
|
||||
return True
|
||||
|
||||
if frappe.flags.in_test:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_salary_assignment(employee, date):
|
||||
assignment = frappe.db.sql("""
|
||||
select * from `tabSalary Structure Assignment`
|
||||
where employee=%(employee)s
|
||||
and docstatus = 1
|
||||
and %(on_date)s >= from_date order by from_date desc limit 1""", {
|
||||
'employee': employee,
|
||||
'on_date': date,
|
||||
}, as_dict=1)
|
||||
return assignment[0] if assignment else None
|
||||
|
||||
def get_sal_slip_total_benefit_given(employee, payroll_period, component=False):
|
||||
total_given_benefit_amount = 0
|
||||
query = """
|
||||
select sum(sd.amount) as 'total_amount'
|
||||
from `tabSalary Slip` ss, `tabSalary Detail` sd
|
||||
where ss.employee=%(employee)s
|
||||
and ss.docstatus = 1 and ss.name = sd.parent
|
||||
and sd.is_flexible_benefit = 1 and sd.parentfield = "earnings"
|
||||
and sd.parenttype = "Salary Slip"
|
||||
and (ss.start_date between %(start_date)s and %(end_date)s
|
||||
or ss.end_date between %(start_date)s and %(end_date)s
|
||||
or (ss.start_date < %(start_date)s and ss.end_date > %(end_date)s))
|
||||
"""
|
||||
|
||||
if component:
|
||||
query += "and sd.salary_component = %(component)s"
|
||||
|
||||
sum_of_given_benefit = frappe.db.sql(query, {
|
||||
'employee': employee,
|
||||
'start_date': payroll_period.start_date,
|
||||
'end_date': payroll_period.end_date,
|
||||
'component': component
|
||||
}, as_dict=True)
|
||||
|
||||
if sum_of_given_benefit and flt(sum_of_given_benefit[0].total_amount) > 0:
|
||||
total_given_benefit_amount = sum_of_given_benefit[0].total_amount
|
||||
return total_given_benefit_amount
|
||||
|
||||
def get_holiday_dates_for_employee(employee, start_date, end_date):
|
||||
"""return a list of holiday dates for the given employee between start_date and end_date"""
|
||||
# return only date
|
||||
holidays = get_holidays_for_employee(employee, start_date, end_date)
|
||||
|
||||
return [cstr(h.holiday_date) for h in holidays]
|
||||
|
||||
|
||||
def get_holidays_for_employee(employee, start_date, end_date, raise_exception=True, only_non_weekly=False):
|
||||
"""Get Holidays for a given employee
|
||||
|
||||
`employee` (str)
|
||||
`start_date` (str or datetime)
|
||||
`end_date` (str or datetime)
|
||||
`raise_exception` (bool)
|
||||
`only_non_weekly` (bool)
|
||||
|
||||
return: list of dicts with `holiday_date` and `description`
|
||||
"""
|
||||
holiday_list = get_holiday_list_for_employee(employee, raise_exception=raise_exception)
|
||||
|
||||
if not holiday_list:
|
||||
return []
|
||||
|
||||
filters = {
|
||||
'parent': holiday_list,
|
||||
'holiday_date': ('between', [start_date, end_date])
|
||||
}
|
||||
|
||||
if only_non_weekly:
|
||||
filters['weekly_off'] = False
|
||||
|
||||
holidays = frappe.get_all(
|
||||
'Holiday',
|
||||
fields=['description', 'holiday_date'],
|
||||
filters=filters
|
||||
)
|
||||
|
||||
return holidays
|
||||
|
||||
@erpnext.allow_regional
|
||||
def calculate_annual_eligible_hra_exemption(doc):
|
||||
# Don't delete this method, used for localization
|
||||
# Indian HRA Exemption Calculation
|
||||
return {}
|
||||
|
||||
@erpnext.allow_regional
|
||||
def calculate_hra_exemption_for_period(doc):
|
||||
# Don't delete this method, used for localization
|
||||
# Indian HRA Exemption Calculation
|
||||
return {}
|
||||
|
||||
def get_previous_claimed_amount(employee, payroll_period, non_pro_rata=False, component=False):
|
||||
total_claimed_amount = 0
|
||||
query = """
|
||||
select sum(claimed_amount) as 'total_amount'
|
||||
from `tabEmployee Benefit Claim`
|
||||
where employee=%(employee)s
|
||||
and docstatus = 1
|
||||
and (claim_date between %(start_date)s and %(end_date)s)
|
||||
"""
|
||||
if non_pro_rata:
|
||||
query += "and pay_against_benefit_claim = 1"
|
||||
if component:
|
||||
query += "and earning_component = %(component)s"
|
||||
|
||||
sum_of_claimed_amount = frappe.db.sql(query, {
|
||||
'employee': employee,
|
||||
'start_date': payroll_period.start_date,
|
||||
'end_date': payroll_period.end_date,
|
||||
'component': component
|
||||
}, as_dict=True)
|
||||
if sum_of_claimed_amount and flt(sum_of_claimed_amount[0].total_amount) > 0:
|
||||
total_claimed_amount = sum_of_claimed_amount[0].total_amount
|
||||
return total_claimed_amount
|
||||
|
||||
def share_doc_with_approver(doc, user):
|
||||
# if approver does not have permissions, share
|
||||
if not frappe.has_permission(doc=doc, ptype="submit", user=user):
|
||||
frappe.share.add(doc.doctype, doc.name, user, submit=1,
|
||||
flags={"ignore_share_permission": True})
|
||||
|
||||
frappe.msgprint(_("Shared with the user {0} with {1} access").format(
|
||||
user, frappe.bold("submit"), alert=True))
|
||||
|
||||
# remove shared doc if approver changes
|
||||
doc_before_save = doc.get_doc_before_save()
|
||||
if doc_before_save:
|
||||
approvers = {
|
||||
"Leave Application": "leave_approver",
|
||||
"Expense Claim": "expense_approver",
|
||||
"Shift Request": "approver"
|
||||
}
|
||||
|
||||
approver = approvers.get(doc.doctype)
|
||||
if doc_before_save.get(approver) != doc.get(approver):
|
||||
frappe.share.remove(doc.doctype, doc.name, doc_before_save.get(approver))
|
||||
|
||||
def validate_active_employee(employee):
|
||||
if frappe.db.get_value("Employee", employee, "status") == "Inactive":
|
||||
frappe.throw(_("Transactions cannot be created for an Inactive Employee {0}.").format(
|
||||
get_link_to_form("Employee", employee)), InactiveEmployeeStatusError)
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: frappe\n"
|
||||
"Report-Msgid-Bugs-To: info@erpnext.com\n"
|
||||
"POT-Creation-Date: 2025-01-05 09:35+0000\n"
|
||||
"PO-Revision-Date: 2025-01-05 15:05\n"
|
||||
"PO-Revision-Date: 2025-01-07 16:18\n"
|
||||
"Last-Translator: info@erpnext.com\n"
|
||||
"Language-Team: Spanish\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
@@ -3542,7 +3542,7 @@ msgstr "Contra la cuenta"
|
||||
#: erpnext/selling/doctype/quotation_item/quotation_item.json
|
||||
#: erpnext/selling/doctype/sales_order_item/sales_order_item.json
|
||||
msgid "Against Blanket Order"
|
||||
msgstr "Contra la orden general"
|
||||
msgstr "Contra el pedido abierto"
|
||||
|
||||
#: erpnext/accounts/doctype/sales_invoice/sales_invoice.py:965
|
||||
msgid "Against Customer Order {0}"
|
||||
@@ -24881,7 +24881,7 @@ msgstr "Código de barras inválido. No hay ningún elemento adjunto a este cód
|
||||
|
||||
#: erpnext/public/js/controllers/transaction.js:2578
|
||||
msgid "Invalid Blanket Order for the selected Customer and Item"
|
||||
msgstr "Pedido de manta inválido para el cliente y el artículo seleccionado"
|
||||
msgstr "Pedido abierto inválido para el cliente y el artículo seleccionado"
|
||||
|
||||
#: erpnext/quality_management/doctype/quality_procedure/quality_procedure.py:72
|
||||
msgid "Invalid Child Procedure"
|
||||
@@ -40174,7 +40174,7 @@ msgstr ""
|
||||
#: erpnext/selling/doctype/sales_order/sales_order_dashboard.py:26
|
||||
#: erpnext/stock/doctype/item/item.json
|
||||
msgid "Purchasing"
|
||||
msgstr "Adquisitivo"
|
||||
msgstr "Compras"
|
||||
|
||||
#. Option for the 'Color' (Select) field in DocType 'Supplier Scorecard Scoring
|
||||
#. Standing'
|
||||
|
||||
@@ -344,6 +344,9 @@ 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)
|
||||
|
||||
@@ -389,8 +392,10 @@ class JobCard(Document):
|
||||
else:
|
||||
query = query.where(jc.name.isin(open_job_cards))
|
||||
|
||||
if doctype != "Job Card Time Log":
|
||||
query = query.where(jc.total_time_in_mins == 0)
|
||||
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))
|
||||
|
||||
time_logs = query.run(as_dict=True)
|
||||
|
||||
@@ -447,7 +452,13 @@ 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})
|
||||
args = frappe._dict(
|
||||
{
|
||||
"from_time": row.planned_start_time,
|
||||
"to_time": row.planned_end_time,
|
||||
"remaining_time_in_mins": row.remaining_time_in_mins,
|
||||
}
|
||||
)
|
||||
|
||||
self.validate_overlap_for_workstation(args, row)
|
||||
self.check_workstation_time(row)
|
||||
|
||||
@@ -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 / Scrape Items From Sub-assemblies"
|
||||
"label": "Set Operating Cost / Scrap Items From Sub-assemblies"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
@@ -249,7 +249,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2025-01-02 12:46:33.520853",
|
||||
"modified": "2025-01-09 16:02:23.326763",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Manufacturing Settings",
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "Type",
|
||||
"options": "\nPurchase\nMaterial Transfer\nMaterial Issue\nManufacture\nCustomer Provided"
|
||||
"options": "\nPurchase\nMaterial Transfer\nMaterial Issue\nManufacture\nSubcontracting\nCustomer Provided"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_4",
|
||||
@@ -115,9 +115,12 @@
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"default": "0",
|
||||
"fieldname": "requested_qty",
|
||||
"fieldtype": "Float",
|
||||
"label": "Requested Qty",
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
@@ -202,7 +205,7 @@
|
||||
],
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:10:05.436575",
|
||||
"modified": "2024-12-30 18:06:22.288340",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Material Request Plan Item",
|
||||
|
||||
@@ -21,7 +21,13 @@ class MaterialRequestPlanItem(Document):
|
||||
item_code: DF.Link
|
||||
item_name: DF.Data | None
|
||||
material_request_type: DF.Literal[
|
||||
"", "Purchase", "Material Transfer", "Material Issue", "Manufacture", "Customer Provided"
|
||||
"",
|
||||
"Purchase",
|
||||
"Material Transfer",
|
||||
"Material Issue",
|
||||
"Manufacture",
|
||||
"Subcontracting",
|
||||
"Customer Provided",
|
||||
]
|
||||
min_order_qty: DF.Float
|
||||
ordered_qty: DF.Float
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import copy
|
||||
import json
|
||||
from collections import defaultdict
|
||||
|
||||
import frappe
|
||||
from frappe import _, msgprint
|
||||
@@ -722,6 +723,9 @@ class ProductionPlan(Document):
|
||||
if not wo_list:
|
||||
frappe.msgprint(_("No Work Orders were created"))
|
||||
|
||||
if not po_list:
|
||||
frappe.msgprint(_("No Purchase Orders were created"))
|
||||
|
||||
def make_work_order_for_finished_goods(self, wo_list, default_warehouses):
|
||||
items_data = self.get_production_items()
|
||||
|
||||
@@ -781,6 +785,21 @@ class ProductionPlan(Document):
|
||||
if not subcontracted_po:
|
||||
return
|
||||
|
||||
def calculate_sub_assembly_items():
|
||||
items_to_remove = defaultdict(list)
|
||||
for supplier, items in subcontracted_po.items():
|
||||
for item in items:
|
||||
if item.qty == item.received_qty:
|
||||
items_to_remove[supplier].append(item)
|
||||
elif item.received_qty:
|
||||
item.qty -= item.received_qty
|
||||
|
||||
subcontracted_po[supplier] = [item for item in items if item not in items_to_remove[supplier]]
|
||||
|
||||
return {key: value for key, value in subcontracted_po.items() if value}
|
||||
|
||||
subcontracted_po = calculate_sub_assembly_items()
|
||||
|
||||
for supplier, po_list in subcontracted_po.items():
|
||||
po = frappe.new_doc("Purchase Order")
|
||||
po.company = self.company
|
||||
@@ -847,13 +866,31 @@ class ProductionPlan(Document):
|
||||
except OverProductionError:
|
||||
pass
|
||||
|
||||
def validate_mr_subcontracted(self):
|
||||
for row in self.mr_items:
|
||||
if row.material_request_type == "Subcontracting":
|
||||
if not frappe.db.get_value("Item", row.item_code, "is_sub_contracted_item"):
|
||||
frappe.throw(
|
||||
_("Item {0} is not a subcontracted item").format(row.item_code),
|
||||
title=_("Invalid Item"),
|
||||
)
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_material_request(self):
|
||||
self.validate_mr_subcontracted()
|
||||
|
||||
"""Create Material Requests grouped by Sales Order and Material Request Type"""
|
||||
material_request_list = []
|
||||
material_request_map = {}
|
||||
|
||||
if all([item.requested_qty == item.quantity for item in self.mr_items]):
|
||||
msgprint(_("All items are already requested"))
|
||||
return
|
||||
|
||||
for item in self.mr_items:
|
||||
if item.quantity == item.requested_qty:
|
||||
continue
|
||||
|
||||
item_doc = frappe.get_cached_doc("Item", item.item_code)
|
||||
|
||||
material_request_type = item.material_request_type or item_doc.default_material_request_type
|
||||
@@ -887,7 +924,7 @@ class ProductionPlan(Document):
|
||||
"from_warehouse": item.from_warehouse
|
||||
if material_request_type == "Material Transfer"
|
||||
else None,
|
||||
"qty": item.quantity,
|
||||
"qty": item.quantity - item.requested_qty,
|
||||
"schedule_date": schedule_date,
|
||||
"warehouse": item.warehouse,
|
||||
"sales_order": item.sales_order,
|
||||
@@ -1047,7 +1084,7 @@ class ProductionPlan(Document):
|
||||
filters={
|
||||
"production_plan": self.name,
|
||||
"status": ("not in", ["Closed", "Stopped"]),
|
||||
"docstatus": ("<", 2),
|
||||
"docstatus": 1,
|
||||
},
|
||||
fields="status",
|
||||
pluck="status",
|
||||
|
||||
@@ -449,11 +449,38 @@ class TestProductionPlan(IntegrationTestCase):
|
||||
self.assertEqual(plan.sub_assembly_items[0].supplier, "_Test Supplier")
|
||||
|
||||
def test_production_plan_for_subcontracting_po(self):
|
||||
from erpnext.controllers.status_updater import OverAllowanceError
|
||||
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
|
||||
from erpnext.subcontracting.doctype.subcontracting_bom.test_subcontracting_bom import (
|
||||
create_subcontracting_bom,
|
||||
)
|
||||
|
||||
def make_purchase_receipt_from_po(po_doc):
|
||||
from erpnext.buying.doctype.purchase_order.purchase_order import make_subcontracting_order
|
||||
from erpnext.controllers.subcontracting_controller import make_rm_stock_entry
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
|
||||
from erpnext.subcontracting.doctype.subcontracting_order.subcontracting_order import (
|
||||
make_subcontracting_receipt,
|
||||
)
|
||||
from erpnext.subcontracting.doctype.subcontracting_receipt.subcontracting_receipt import (
|
||||
make_purchase_receipt as scr_make_purchase_receipt,
|
||||
)
|
||||
|
||||
sco = make_subcontracting_order(po_doc.name)
|
||||
sco.supplier_warehouse = "Work In Progress - _TC1"
|
||||
sco.items[0].warehouse = "Finished Goods - _TC1"
|
||||
sco.submit()
|
||||
make_purchase_receipt(
|
||||
qty=10,
|
||||
item_code="Test Motherboard Wires 1",
|
||||
company="_Test Company 1",
|
||||
warehouse="Work In Progress - _TC1",
|
||||
).submit()
|
||||
make_rm_stock_entry(sco.name)
|
||||
scr = make_subcontracting_receipt(sco.name)
|
||||
scr.submit()
|
||||
scr_make_purchase_receipt(scr.name).submit()
|
||||
|
||||
fg_item = "Test Motherboard 1"
|
||||
bom_tree_1 = {"Test Laptop 1": {fg_item: {"Test Motherboard Wires 1": {}}}}
|
||||
create_nested_bom(bom_tree_1, prefix="")
|
||||
@@ -478,7 +505,12 @@ class TestProductionPlan(IntegrationTestCase):
|
||||
)
|
||||
|
||||
plan = create_production_plan(
|
||||
item_code="Test Laptop 1", planned_qty=10, use_multi_level_bom=1, do_not_submit=True
|
||||
item_code="Test Laptop 1",
|
||||
planned_qty=10,
|
||||
use_multi_level_bom=1,
|
||||
do_not_submit=True,
|
||||
company="_Test Company 1",
|
||||
skip_getting_mr_items=True,
|
||||
)
|
||||
plan.get_sub_assembly_items()
|
||||
plan.set_default_supplier_for_subcontracting_order()
|
||||
@@ -492,10 +524,109 @@ class TestProductionPlan(IntegrationTestCase):
|
||||
self.assertEqual(po_doc.supplier, "_Test Supplier")
|
||||
self.assertEqual(po_doc.items[0].qty, 10.0)
|
||||
self.assertEqual(po_doc.items[0].fg_item_qty, 10.0)
|
||||
self.assertEqual(po_doc.items[0].fg_item_qty, 10.0)
|
||||
self.assertEqual(po_doc.items[0].fg_item, fg_item)
|
||||
self.assertEqual(po_doc.items[0].item_code, service_item)
|
||||
|
||||
po_doc.items[0].qty = 11
|
||||
po_doc.items[0].fg_item_qty = 11
|
||||
|
||||
# Test - 1 : Quantity of item cannot exceed quantity in production plan
|
||||
self.assertRaises(OverAllowanceError, po_doc.submit)
|
||||
|
||||
po_doc.cancel()
|
||||
po_doc = frappe.copy_doc(po_doc)
|
||||
po_doc.items[0].qty = 5
|
||||
po_doc.items[0].fg_item_qty = 5
|
||||
po_doc.submit()
|
||||
make_purchase_receipt_from_po(po_doc)
|
||||
|
||||
plan.reload()
|
||||
plan.make_work_order()
|
||||
po = frappe.db.get_value("Purchase Order Item", {"production_plan": plan.name}, "parent")
|
||||
po_doc = frappe.get_doc("Purchase Order", po)
|
||||
|
||||
# Test - 2 : Quantity of item in new PO should be the available quantity from Production Plan
|
||||
self.assertEqual(po_doc.items[0].qty, 5.0)
|
||||
|
||||
po_doc.submit()
|
||||
plan.make_work_order()
|
||||
|
||||
# Test - 3 : New POs should not be created since the quantity is already fulfilled
|
||||
self.assertEqual(
|
||||
frappe.db.count("Purchase Order Item", {"production_plan": plan.name, "docstatus": 1}), 2
|
||||
) # 2 since we have already created and submitted 2 POs
|
||||
|
||||
def test_production_plan_for_mr_items(self):
|
||||
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
|
||||
|
||||
def setup_item(fg_item):
|
||||
item_doc = frappe.get_doc("Item", fg_item)
|
||||
company = "_Test Company"
|
||||
|
||||
item_doc.is_sub_contracted_item = 1
|
||||
for row in item_doc.item_defaults:
|
||||
if row.company == company and not row.default_supplier:
|
||||
row.default_supplier = "_Test Supplier"
|
||||
|
||||
if not item_doc.item_defaults:
|
||||
item_doc.append("item_defaults", {"company": company, "default_supplier": "_Test Supplier"})
|
||||
|
||||
item_doc.save()
|
||||
|
||||
fg_item = "Test Motherboard 1"
|
||||
fg_item_2 = "Test CPU 1"
|
||||
bom_tree_1 = {
|
||||
"Test Laptop 1": {fg_item: {"Test Motherboard Wires 1": {}}, fg_item_2: {"Test Pins 1": {}}}
|
||||
}
|
||||
create_nested_bom(bom_tree_1, prefix="")
|
||||
|
||||
setup_item(fg_item)
|
||||
setup_item(fg_item_2)
|
||||
|
||||
plan = create_production_plan(
|
||||
item_code="Test Laptop 1", planned_qty=10, use_multi_level_bom=1, do_not_submit=True
|
||||
)
|
||||
plan.get_sub_assembly_items()
|
||||
plan.set_default_supplier_for_subcontracting_order()
|
||||
plan.submit()
|
||||
|
||||
plan.make_material_request()
|
||||
mr_item = frappe.db.get_value("Material Request Item", {"production_plan": plan.name}, "parent")
|
||||
mr_doc = frappe.get_doc("Material Request", mr_item)
|
||||
mr_doc.submit()
|
||||
plan.reload()
|
||||
plan.make_material_request()
|
||||
|
||||
# Test 1 : No more MRs should be created as quantity from Production Plan is fulfilled
|
||||
self.assertEqual(frappe.db.count("Material Request Item", {"production_plan": plan.name}), 2)
|
||||
|
||||
mr_doc.cancel()
|
||||
plan.reload()
|
||||
|
||||
# Test 2 : Requested quantity should be updated in Production Plan on cancellation of MR
|
||||
self.assertEqual(plan.mr_items[0].requested_qty, 0)
|
||||
|
||||
plan.make_material_request()
|
||||
mr_item = frappe.db.get_value("Material Request Item", {"production_plan": plan.name}, "parent")
|
||||
mr_doc = frappe.get_doc("Material Request", mr_item)
|
||||
mr_doc.items[0].qty = 5
|
||||
mr_doc.submit()
|
||||
plan.reload()
|
||||
plan.make_material_request()
|
||||
mr_item = frappe.db.get_value("Material Request Item", {"production_plan": plan.name}, "parent")
|
||||
mr_doc = frappe.get_doc("Material Request", mr_item)
|
||||
|
||||
# Test 3 : Since Item 2 has been fully requested, it should not be included in the new MR by default
|
||||
self.assertEqual(len(mr_doc.items), 1)
|
||||
|
||||
# Test 4 : Quantity in new MR should be the available quantity from Production Plan
|
||||
self.assertEqual(mr_doc.items[0].qty, 5.0)
|
||||
|
||||
mr_doc.items[0].qty = 6
|
||||
|
||||
# Test 5 : Quantity of item cannot exceed available quantity from Production Plan
|
||||
self.assertRaises(frappe.ValidationError, mr_doc.submit)
|
||||
|
||||
def test_production_plan_combine_subassembly(self):
|
||||
"""
|
||||
Test combining Sub assembly items belonging to the same BOM in Prod Plan.
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"fieldname": "received_qty",
|
||||
"fieldtype": "Float",
|
||||
"label": "Received Qty",
|
||||
@@ -209,7 +210,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:10:20.876695",
|
||||
"modified": "2025-01-01 17:50:32.273610",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Production Plan Sub Assembly Item",
|
||||
|
||||
@@ -35,6 +35,7 @@ def get_production_plan_item_details(filters, data, order_details):
|
||||
"production_plan_item": row.name,
|
||||
"bom_no": row.bom_no,
|
||||
"production_item": row.item_code,
|
||||
"docstatus": 1,
|
||||
},
|
||||
pluck="name",
|
||||
)
|
||||
@@ -84,20 +85,24 @@ def get_production_plan_sub_assembly_item_details(filters, row, production_plan_
|
||||
subcontracted_item = item.type_of_manufacturing == "Subcontract"
|
||||
|
||||
if subcontracted_item:
|
||||
docname = frappe.get_value(
|
||||
docnames = frappe.get_all(
|
||||
"Purchase Order Item",
|
||||
{"production_plan_sub_assembly_item": item.name, "docstatus": ("<", 2)},
|
||||
"parent",
|
||||
filters={"production_plan_sub_assembly_item": item.name, "docstatus": 1},
|
||||
fields=["parent"],
|
||||
order_by="creation",
|
||||
pluck="parent",
|
||||
)
|
||||
else:
|
||||
docname = frappe.get_value(
|
||||
docnames = frappe.get_all(
|
||||
"Work Order",
|
||||
{"production_plan_sub_assembly_item": item.name, "docstatus": ("<", 2)},
|
||||
"name",
|
||||
filters={"production_plan_sub_assembly_item": item.name, "docstatus": 1},
|
||||
fields=["name"],
|
||||
order_by="creation",
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
data.append(
|
||||
{
|
||||
for docname in docnames:
|
||||
data_to_append = {
|
||||
"indent": 1 + item.indent,
|
||||
"item_code": item.production_item,
|
||||
"item_name": item.item_name,
|
||||
@@ -111,13 +116,15 @@ def get_production_plan_sub_assembly_item_details(filters, row, production_plan_
|
||||
"pending_qty": flt(item.qty)
|
||||
- flt(order_details.get((docname, item.production_item), {}).get("produced_qty", 0)),
|
||||
}
|
||||
)
|
||||
if data[-1] and data[-1]["item_code"] == item.production_item:
|
||||
data_to_append["pending_qty"] = data[-1]["pending_qty"] - data_to_append["produced_qty"]
|
||||
data.append(data_to_append)
|
||||
|
||||
|
||||
def get_work_order_details(filters, order_details):
|
||||
for row in frappe.get_all(
|
||||
"Work Order",
|
||||
filters={"production_plan": filters.get("production_plan")},
|
||||
filters={"production_plan": filters.get("production_plan"), "docstatus": 1},
|
||||
fields=["name", "produced_qty", "production_plan", "production_item", "sales_order"],
|
||||
):
|
||||
order_details.setdefault((row.name, row.production_item), row)
|
||||
@@ -126,10 +133,12 @@ def get_work_order_details(filters, order_details):
|
||||
def get_purchase_order_details(filters, order_details):
|
||||
for row in frappe.get_all(
|
||||
"Purchase Order Item",
|
||||
filters={"production_plan": filters.get("production_plan")},
|
||||
fields=["parent", "received_qty as produced_qty", "item_code"],
|
||||
filters={"production_plan": filters.get("production_plan"), "docstatus": 1},
|
||||
fields=["parent", "qty", "received_qty as produced_qty", "item_code", "fg_item", "fg_item_qty"],
|
||||
):
|
||||
order_details.setdefault((row.parent, row.item_code), row)
|
||||
if row.fg_item:
|
||||
row.produced_qty /= row.qty / row.fg_item_qty or 1
|
||||
order_details.setdefault((row.parent, row.fg_item or row.item_code), row)
|
||||
|
||||
|
||||
def get_column(filters):
|
||||
|
||||
@@ -199,6 +199,11 @@ erpnext.patches.v12_0.add_document_type_field_for_italy_einvoicing
|
||||
erpnext.patches.v13_0.update_shipment_status
|
||||
erpnext.patches.v13_0.remove_attribute_field_from_item_variant_setting
|
||||
erpnext.patches.v13_0.set_pos_closing_as_failed
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
erpnext.patches.v13_0.rename_stop_to_send_birthday_reminders
|
||||
execute:frappe.rename_doc("Workspace", "Loan Management", "Loans", force=True)
|
||||
>>>>>>> 24b2a31581 (feat: Employee reminders (#25735))
|
||||
erpnext.patches.v13_0.update_timesheet_changes
|
||||
erpnext.patches.v13_0.add_doctype_to_sla #14-06-2021
|
||||
erpnext.patches.v13_0.bill_for_rejected_quantity_in_purchase_invoice
|
||||
@@ -393,4 +398,5 @@ erpnext.patches.v15_0.set_is_exchange_gain_loss_in_payment_entry_deductions
|
||||
erpnext.patches.v14_0.update_stock_uom_in_work_order_item
|
||||
erpnext.patches.v15_0.enable_allow_existing_serial_no
|
||||
erpnext.patches.v15_0.update_cc_in_process_statement_of_accounts
|
||||
erpnext.patches.v15_0.refactor_closing_stock_balance #5
|
||||
erpnext.patches.v15_0.refactor_closing_stock_balance #5
|
||||
erpnext.patches.v15_0.update_asset_status_to_work_in_progress
|
||||
@@ -0,0 +1,23 @@
|
||||
import frappe
|
||||
from frappe.model.utils.rename_field import rename_field
|
||||
|
||||
def execute():
|
||||
frappe.reload_doc('hr', 'doctype', 'hr_settings')
|
||||
|
||||
try:
|
||||
# Rename the field
|
||||
rename_field('HR Settings', 'stop_birthday_reminders', 'send_birthday_reminders')
|
||||
|
||||
# Reverse the value
|
||||
old_value = frappe.db.get_single_value('HR Settings', 'send_birthday_reminders')
|
||||
|
||||
frappe.db.set_value(
|
||||
'HR Settings',
|
||||
'HR Settings',
|
||||
'send_birthday_reminders',
|
||||
1 if old_value == 0 else 0
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
if e.args[0] != 1054:
|
||||
raise
|
||||
@@ -0,0 +1,11 @@
|
||||
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()
|
||||
@@ -0,0 +1,256 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
from __future__ import unicode_literals
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import date_diff, getdate, rounded, add_days, cstr, cint, flt
|
||||
from frappe.model.document import Document
|
||||
from erpnext.payroll.doctype.payroll_period.payroll_period import get_payroll_period_days, get_period_factor
|
||||
from erpnext.payroll.doctype.salary_structure_assignment.salary_structure_assignment import get_assigned_salary_structure
|
||||
from erpnext.hr.utils import get_sal_slip_total_benefit_given, get_holiday_dates_for_employee, get_previous_claimed_amount, validate_active_employee
|
||||
|
||||
class EmployeeBenefitApplication(Document):
|
||||
def validate(self):
|
||||
validate_active_employee(self.employee)
|
||||
self.validate_duplicate_on_payroll_period()
|
||||
if not self.max_benefits:
|
||||
self.max_benefits = get_max_benefits_remaining(self.employee, self.date, self.payroll_period)
|
||||
if self.max_benefits and self.max_benefits > 0:
|
||||
self.validate_max_benefit_for_component()
|
||||
self.validate_prev_benefit_claim()
|
||||
if self.remaining_benefit > 0:
|
||||
self.validate_remaining_benefit_amount()
|
||||
else:
|
||||
frappe.throw(_("As per your assigned Salary Structure you cannot apply for benefits").format(self.employee))
|
||||
|
||||
def validate_prev_benefit_claim(self):
|
||||
if self.employee_benefits:
|
||||
for benefit in self.employee_benefits:
|
||||
if benefit.pay_against_benefit_claim == 1:
|
||||
payroll_period = frappe.get_doc("Payroll Period", self.payroll_period)
|
||||
benefit_claimed = get_previous_claimed_amount(self.employee, payroll_period, component = benefit.earning_component)
|
||||
benefit_given = get_sal_slip_total_benefit_given(self.employee, payroll_period, component = benefit.earning_component)
|
||||
benefit_claim_remining = benefit_claimed - benefit_given
|
||||
if benefit_claimed > 0 and benefit_claim_remining > benefit.amount:
|
||||
frappe.throw(_("An amount of {0} already claimed for the component {1}, set the amount equal or greater than {2}").format(
|
||||
benefit_claimed, benefit.earning_component, benefit_claim_remining))
|
||||
|
||||
def validate_remaining_benefit_amount(self):
|
||||
# check salary structure earnings have flexi component (sum of max_benefit_amount)
|
||||
# without pro-rata which satisfy the remaining_benefit
|
||||
# else pro-rata component for the amount
|
||||
# again comes the same validation and satisfy or throw
|
||||
benefit_components = []
|
||||
if self.employee_benefits:
|
||||
for employee_benefit in self.employee_benefits:
|
||||
benefit_components.append(employee_benefit.earning_component)
|
||||
salary_struct_name = get_assigned_salary_structure(self.employee, self.date)
|
||||
if salary_struct_name:
|
||||
non_pro_rata_amount = 0
|
||||
pro_rata_amount = 0
|
||||
salary_structure = frappe.get_doc("Salary Structure", salary_struct_name)
|
||||
if salary_structure.earnings:
|
||||
for earnings in salary_structure.earnings:
|
||||
if earnings.is_flexible_benefit == 1 and earnings.salary_component not in benefit_components:
|
||||
pay_against_benefit_claim, max_benefit_amount = frappe.db.get_value("Salary Component", earnings.salary_component, ["pay_against_benefit_claim", "max_benefit_amount"])
|
||||
if pay_against_benefit_claim != 1:
|
||||
pro_rata_amount += max_benefit_amount
|
||||
else:
|
||||
non_pro_rata_amount += max_benefit_amount
|
||||
|
||||
if pro_rata_amount == 0 and non_pro_rata_amount == 0:
|
||||
frappe.throw(_("Please add the remaining benefits {0} to any of the existing component").format(self.remaining_benefit))
|
||||
elif non_pro_rata_amount > 0 and non_pro_rata_amount < rounded(self.remaining_benefit):
|
||||
frappe.throw(_("You can claim only an amount of {0}, the rest amount {1} should be in the application as pro-rata component").format(
|
||||
non_pro_rata_amount, self.remaining_benefit - non_pro_rata_amount))
|
||||
elif non_pro_rata_amount == 0:
|
||||
frappe.throw(_("Please add the remaining benefits {0} to the application as pro-rata component").format(
|
||||
self.remaining_benefit))
|
||||
|
||||
def validate_max_benefit_for_component(self):
|
||||
if self.employee_benefits:
|
||||
max_benefit_amount = 0
|
||||
for employee_benefit in self.employee_benefits:
|
||||
self.validate_max_benefit(employee_benefit.earning_component)
|
||||
max_benefit_amount += employee_benefit.amount
|
||||
if max_benefit_amount > self.max_benefits:
|
||||
frappe.throw(_("Maximum benefit amount of employee {0} exceeds {1}").format(self.employee, self.max_benefits))
|
||||
|
||||
def validate_max_benefit(self, earning_component_name):
|
||||
max_benefit_amount = frappe.db.get_value("Salary Component", earning_component_name, "max_benefit_amount")
|
||||
benefit_amount = 0
|
||||
for employee_benefit in self.employee_benefits:
|
||||
if employee_benefit.earning_component == earning_component_name:
|
||||
benefit_amount += employee_benefit.amount
|
||||
prev_sal_slip_flexi_amount = get_sal_slip_total_benefit_given(self.employee, frappe.get_doc("Payroll Period", self.payroll_period), earning_component_name)
|
||||
benefit_amount += prev_sal_slip_flexi_amount
|
||||
if rounded(benefit_amount, 2) > max_benefit_amount:
|
||||
frappe.throw(_("Maximum benefit amount of component {0} exceeds {1}").format(earning_component_name, max_benefit_amount))
|
||||
|
||||
def validate_duplicate_on_payroll_period(self):
|
||||
application = frappe.db.exists(
|
||||
"Employee Benefit Application",
|
||||
{
|
||||
'employee': self.employee,
|
||||
'payroll_period': self.payroll_period,
|
||||
'docstatus': 1
|
||||
}
|
||||
)
|
||||
if application:
|
||||
frappe.throw(_("Employee {0} already submited an apllication {1} for the payroll period {2}").format(self.employee, application, self.payroll_period))
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_max_benefits(employee, on_date):
|
||||
sal_struct = get_assigned_salary_structure(employee, on_date)
|
||||
if sal_struct:
|
||||
max_benefits = frappe.db.get_value("Salary Structure", sal_struct, "max_benefits")
|
||||
if max_benefits > 0:
|
||||
return max_benefits
|
||||
return False
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_max_benefits_remaining(employee, on_date, payroll_period):
|
||||
max_benefits = get_max_benefits(employee, on_date)
|
||||
if max_benefits and max_benefits > 0:
|
||||
have_depends_on_payment_days = False
|
||||
per_day_amount_total = 0
|
||||
payroll_period_days = get_payroll_period_days(on_date, on_date, employee)[1]
|
||||
payroll_period_obj = frappe.get_doc("Payroll Period", payroll_period)
|
||||
|
||||
# Get all salary slip flexi amount in the payroll period
|
||||
prev_sal_slip_flexi_total = get_sal_slip_total_benefit_given(employee, payroll_period_obj)
|
||||
|
||||
if prev_sal_slip_flexi_total > 0:
|
||||
# Check salary structure hold depends_on_payment_days component
|
||||
# If yes then find the amount per day of each component and find the sum
|
||||
sal_struct_name = get_assigned_salary_structure(employee, on_date)
|
||||
if sal_struct_name:
|
||||
sal_struct = frappe.get_doc("Salary Structure", sal_struct_name)
|
||||
for sal_struct_row in sal_struct.get("earnings"):
|
||||
salary_component = frappe.get_doc("Salary Component", sal_struct_row.salary_component)
|
||||
if salary_component.depends_on_payment_days == 1 and salary_component.pay_against_benefit_claim != 1:
|
||||
have_depends_on_payment_days = True
|
||||
benefit_amount = get_benefit_amount_based_on_pro_rata(sal_struct, salary_component.max_benefit_amount)
|
||||
amount_per_day = benefit_amount / payroll_period_days
|
||||
per_day_amount_total += amount_per_day
|
||||
|
||||
# Then the sum multiply with the no of lwp in that period
|
||||
# Include that amount to the prev_sal_slip_flexi_total to get the actual
|
||||
if have_depends_on_payment_days and per_day_amount_total > 0:
|
||||
holidays = get_holiday_dates_for_employee(employee, payroll_period_obj.start_date, on_date)
|
||||
working_days = date_diff(on_date, payroll_period_obj.start_date) + 1
|
||||
leave_days = calculate_lwp(employee, payroll_period_obj.start_date, holidays, working_days)
|
||||
leave_days_amount = leave_days * per_day_amount_total
|
||||
prev_sal_slip_flexi_total += leave_days_amount
|
||||
|
||||
return max_benefits - prev_sal_slip_flexi_total
|
||||
return max_benefits
|
||||
|
||||
def calculate_lwp(employee, start_date, holidays, working_days):
|
||||
lwp = 0
|
||||
holidays = "','".join(holidays)
|
||||
for d in range(working_days):
|
||||
dt = add_days(cstr(getdate(start_date)), d)
|
||||
leave = frappe.db.sql("""
|
||||
select t1.name, t1.half_day
|
||||
from `tabLeave Application` t1, `tabLeave Type` t2
|
||||
where t2.name = t1.leave_type
|
||||
and t2.is_lwp = 1
|
||||
and t1.docstatus = 1
|
||||
and t1.employee = %(employee)s
|
||||
and CASE WHEN t2.include_holiday != 1 THEN %(dt)s not in ('{0}') and %(dt)s between from_date and to_date
|
||||
WHEN t2.include_holiday THEN %(dt)s between from_date and to_date
|
||||
END
|
||||
""".format(holidays), {"employee": employee, "dt": dt})
|
||||
if leave:
|
||||
lwp = cint(leave[0][1]) and (lwp + 0.5) or (lwp + 1)
|
||||
return lwp
|
||||
|
||||
def get_benefit_component_amount(employee, start_date, end_date, salary_component, sal_struct, payroll_frequency, payroll_period):
|
||||
if not payroll_period:
|
||||
frappe.msgprint(_("Start and end dates not in a valid Payroll Period, cannot calculate {0}")
|
||||
.format(salary_component))
|
||||
return False
|
||||
|
||||
# Considering there is only one application for a year
|
||||
benefit_application = frappe.db.sql("""
|
||||
select name
|
||||
from `tabEmployee Benefit Application`
|
||||
where
|
||||
payroll_period=%(payroll_period)s
|
||||
and employee=%(employee)s
|
||||
and docstatus = 1
|
||||
""", {
|
||||
'employee': employee,
|
||||
'payroll_period': payroll_period.name
|
||||
})
|
||||
|
||||
current_benefit_amount = 0.0
|
||||
component_max_benefit, depends_on_payment_days = frappe.db.get_value("Salary Component",
|
||||
salary_component, ["max_benefit_amount", "depends_on_payment_days"])
|
||||
|
||||
benefit_amount = 0
|
||||
if benefit_application:
|
||||
benefit_amount = frappe.db.get_value("Employee Benefit Application Detail",
|
||||
{"parent": benefit_application[0][0], "earning_component": salary_component}, "amount")
|
||||
elif component_max_benefit:
|
||||
benefit_amount = get_benefit_amount_based_on_pro_rata(sal_struct, component_max_benefit)
|
||||
|
||||
current_benefit_amount = 0
|
||||
if benefit_amount:
|
||||
total_sub_periods = get_period_factor(employee,
|
||||
start_date, end_date, payroll_frequency, payroll_period, depends_on_payment_days)[0]
|
||||
|
||||
current_benefit_amount = benefit_amount / total_sub_periods
|
||||
|
||||
return current_benefit_amount
|
||||
|
||||
def get_benefit_amount_based_on_pro_rata(sal_struct, component_max_benefit):
|
||||
max_benefits_total = 0
|
||||
benefit_amount = 0
|
||||
for d in sal_struct.get("earnings"):
|
||||
if d.is_flexible_benefit == 1:
|
||||
component = frappe.db.get_value("Salary Component", d.salary_component, ["max_benefit_amount", "pay_against_benefit_claim"], as_dict=1)
|
||||
if not component.pay_against_benefit_claim:
|
||||
max_benefits_total += component.max_benefit_amount
|
||||
|
||||
if max_benefits_total > 0:
|
||||
benefit_amount = sal_struct.max_benefits * component.max_benefit_amount / max_benefits_total
|
||||
if benefit_amount > component_max_benefit:
|
||||
benefit_amount = component_max_benefit
|
||||
|
||||
return benefit_amount
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_earning_components(doctype, txt, searchfield, start, page_len, filters):
|
||||
if len(filters) < 2:
|
||||
return {}
|
||||
|
||||
salary_structure = get_assigned_salary_structure(filters['employee'], filters['date'])
|
||||
|
||||
if salary_structure:
|
||||
return frappe.db.sql("""
|
||||
select salary_component
|
||||
from `tabSalary Detail`
|
||||
where parent = %s and is_flexible_benefit = 1
|
||||
order by name
|
||||
""", salary_structure)
|
||||
else:
|
||||
frappe.throw(_("Salary Structure not found for employee {0} and date {1}")
|
||||
.format(filters['employee'], filters['date']))
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_earning_components_max_benefits(employee, date, earning_component):
|
||||
salary_structure = get_assigned_salary_structure(employee, date)
|
||||
amount = frappe.db.sql("""
|
||||
select amount
|
||||
from `tabSalary Detail`
|
||||
where parent = %s and is_flexible_benefit = 1
|
||||
and salary_component = %s
|
||||
order by name
|
||||
""", salary_structure, earning_component)
|
||||
|
||||
return amount if amount else 0
|
||||
108
erpnext/payroll/doctype/payroll_period/payroll_period.py
Normal file
108
erpnext/payroll/doctype/payroll_period/payroll_period.py
Normal file
@@ -0,0 +1,108 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
from __future__ import unicode_literals
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import date_diff, getdate, formatdate, cint, month_diff, flt, add_months
|
||||
from frappe.model.document import Document
|
||||
from erpnext.hr.utils import get_holiday_dates_for_employee
|
||||
|
||||
class PayrollPeriod(Document):
|
||||
def validate(self):
|
||||
self.validate_dates()
|
||||
self.validate_overlap()
|
||||
|
||||
def validate_dates(self):
|
||||
if getdate(self.start_date) > getdate(self.end_date):
|
||||
frappe.throw(_("End date can not be less than start date"))
|
||||
|
||||
def validate_overlap(self):
|
||||
query = """
|
||||
select name
|
||||
from `tab{0}`
|
||||
where name != %(name)s
|
||||
and company = %(company)s and (start_date between %(start_date)s and %(end_date)s \
|
||||
or end_date between %(start_date)s and %(end_date)s \
|
||||
or (start_date < %(start_date)s and end_date > %(end_date)s))
|
||||
"""
|
||||
if not self.name:
|
||||
# hack! if name is null, it could cause problems with !=
|
||||
self.name = "New "+self.doctype
|
||||
|
||||
overlap_doc = frappe.db.sql(query.format(self.doctype),{
|
||||
"start_date": self.start_date,
|
||||
"end_date": self.end_date,
|
||||
"name": self.name,
|
||||
"company": self.company
|
||||
}, as_dict = 1)
|
||||
|
||||
if overlap_doc:
|
||||
msg = _("A {0} exists between {1} and {2} (").format(self.doctype,
|
||||
formatdate(self.start_date), formatdate(self.end_date)) \
|
||||
+ """ <b><a href="/app/Form/{0}/{1}">{1}</a></b>""".format(self.doctype, overlap_doc[0].name) \
|
||||
+ _(") for {0}").format(self.company)
|
||||
frappe.throw(msg)
|
||||
|
||||
def get_payroll_period_days(start_date, end_date, employee, company=None):
|
||||
if not company:
|
||||
company = frappe.db.get_value("Employee", employee, "company")
|
||||
payroll_period = frappe.db.sql("""
|
||||
select name, start_date, end_date
|
||||
from `tabPayroll Period`
|
||||
where
|
||||
company=%(company)s
|
||||
and %(start_date)s between start_date and end_date
|
||||
and %(end_date)s between start_date and end_date
|
||||
""", {
|
||||
'company': company,
|
||||
'start_date': start_date,
|
||||
'end_date': end_date
|
||||
})
|
||||
|
||||
if len(payroll_period) > 0:
|
||||
actual_no_of_days = date_diff(getdate(payroll_period[0][2]), getdate(payroll_period[0][1])) + 1
|
||||
working_days = actual_no_of_days
|
||||
if not cint(frappe.db.get_value("Payroll Settings", None, "include_holidays_in_total_working_days")):
|
||||
holidays = get_holiday_dates_for_employee(employee, getdate(payroll_period[0][1]), getdate(payroll_period[0][2]))
|
||||
working_days -= len(holidays)
|
||||
return payroll_period[0][0], working_days, actual_no_of_days
|
||||
return False, False, False
|
||||
|
||||
def get_payroll_period(from_date, to_date, company):
|
||||
payroll_period = frappe.db.sql("""
|
||||
select name, start_date, end_date
|
||||
from `tabPayroll Period`
|
||||
where start_date<=%s and end_date>= %s and company=%s
|
||||
""", (from_date, to_date, company), as_dict=1)
|
||||
|
||||
return payroll_period[0] if payroll_period else None
|
||||
|
||||
def get_period_factor(employee, start_date, end_date, payroll_frequency, payroll_period, depends_on_payment_days=0):
|
||||
# TODO if both deduct checked update the factor to make tax consistent
|
||||
period_start, period_end = payroll_period.start_date, payroll_period.end_date
|
||||
joining_date, relieving_date = frappe.db.get_value("Employee", employee, ["date_of_joining", "relieving_date"])
|
||||
|
||||
if getdate(joining_date) > getdate(period_start):
|
||||
period_start = joining_date
|
||||
if relieving_date and getdate(relieving_date) < getdate(period_end):
|
||||
period_end = relieving_date
|
||||
if month_diff(period_end, start_date) > 1:
|
||||
start_date = add_months(start_date, - (month_diff(period_end, start_date)+1))
|
||||
|
||||
total_sub_periods, remaining_sub_periods = 0.0, 0.0
|
||||
|
||||
if payroll_frequency == "Monthly" and not depends_on_payment_days:
|
||||
total_sub_periods = month_diff(payroll_period.end_date, payroll_period.start_date)
|
||||
remaining_sub_periods = month_diff(period_end, start_date)
|
||||
else:
|
||||
salary_days = date_diff(end_date, start_date) + 1
|
||||
|
||||
days_in_payroll_period = date_diff(payroll_period.end_date, payroll_period.start_date) + 1
|
||||
total_sub_periods = flt(days_in_payroll_period) / flt(salary_days)
|
||||
|
||||
remaining_days_in_payroll_period = date_diff(period_end, start_date) + 1
|
||||
remaining_sub_periods = flt(remaining_days_in_payroll_period) / flt(salary_days)
|
||||
|
||||
return total_sub_periods, remaining_sub_periods
|
||||
1334
erpnext/payroll/doctype/salary_slip/salary_slip.py
Normal file
1334
erpnext/payroll/doctype/salary_slip/salary_slip.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -166,7 +166,7 @@ class Timesheet(Document):
|
||||
task.status = "Completed"
|
||||
else:
|
||||
task.status = "Working"
|
||||
task.save()
|
||||
task.save(ignore_permissions=True)
|
||||
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()
|
||||
project_doc.save(ignore_permissions=True)
|
||||
|
||||
def validate_dates(self):
|
||||
for time_log in self.time_logs:
|
||||
|
||||
@@ -16,7 +16,7 @@ erpnext.accounts.bank_reconciliation.DataTableManager = class DataTableManager {
|
||||
}
|
||||
|
||||
make_dt() {
|
||||
var me = this;
|
||||
const me = this;
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_bank_transactions",
|
||||
args: {
|
||||
@@ -193,6 +193,7 @@ 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),
|
||||
});
|
||||
|
||||
@@ -302,12 +302,16 @@ 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")) {
|
||||
if (!this.frm.is_new() && this.frm.doc.docstatus === 0 && frappe.model.can_create("Quality Inspection") && show_qc_button) {
|
||||
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)
|
||||
@@ -1038,6 +1042,14 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
due_date() {
|
||||
// due_date is to be changed, payment terms template and/or payment schedule must
|
||||
// be removed as due_date is automatically changed based on payment terms
|
||||
|
||||
// if there is only one row in payment schedule child table, set its due date as the due date
|
||||
if (this.frm.doc.payment_schedule.length == 1){
|
||||
this.frm.doc.payment_schedule[0].due_date = this.frm.doc.due_date;
|
||||
this.frm.refresh_field("payment_schedule");
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
this.frm.doc.due_date &&
|
||||
!this.frm.updating_party_details &&
|
||||
|
||||
@@ -74,9 +74,22 @@ $.extend(erpnext, {
|
||||
});
|
||||
},
|
||||
|
||||
<<<<<<< HEAD
|
||||
route_to_pending_reposts: (args) => {
|
||||
frappe.set_route("List", "Repost Item Valuation", args);
|
||||
},
|
||||
=======
|
||||
proceed_save_with_reminders_frequency_change: () => {
|
||||
frappe.ui.hide_open_dialog();
|
||||
|
||||
frappe.call({
|
||||
method: 'erpnext.hr.doctype.hr_settings.hr_settings.set_proceed_with_frequency_change',
|
||||
callback: () => {
|
||||
cur_frm.save();
|
||||
}
|
||||
});
|
||||
}
|
||||
>>>>>>> 24b2a31581 (feat: Employee reminders (#25735))
|
||||
});
|
||||
|
||||
$.extend(erpnext.utils, {
|
||||
|
||||
@@ -413,7 +413,11 @@ 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
|
||||
"""
|
||||
has_qty = item.qty > 0
|
||||
balance_qty = item.qty - ordered_items.get(item.item_code, 0.0)
|
||||
if balance_qty <= 0:
|
||||
return False
|
||||
|
||||
has_qty = balance_qty
|
||||
|
||||
if not selected_rows:
|
||||
return not item.is_alternative
|
||||
|
||||
@@ -51,6 +51,38 @@ class TestQuotation(IntegrationTestCase):
|
||||
|
||||
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
|
||||
|
||||
@@ -210,10 +210,21 @@ erpnext.PointOfSale.ItemDetails = class {
|
||||
|
||||
make_auto_serial_selection_btn(item) {
|
||||
if (item.has_serial_no || item.has_batch_no) {
|
||||
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>`
|
||||
);
|
||||
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>`
|
||||
);
|
||||
}
|
||||
this.$form_container.find(".serial_no-control").find("textarea").css("height", "6rem");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
erpnext.PointOfSale.PastOrderSummary = class {
|
||||
constructor({ wrapper, events }) {
|
||||
constructor({ wrapper, events, pos_profile }) {
|
||||
this.wrapper = wrapper;
|
||||
this.events = events;
|
||||
this.pos_profile = pos_profile;
|
||||
|
||||
this.init_component();
|
||||
}
|
||||
@@ -355,6 +356,8 @@ erpnext.PointOfSale.PastOrderSummary = class {
|
||||
const condition_btns_map = this.get_condition_btn_map(after_submission);
|
||||
|
||||
this.add_summary_btns(condition_btns_map);
|
||||
|
||||
this.print_receipt_on_order_complete();
|
||||
}
|
||||
|
||||
attach_document_info(doc) {
|
||||
@@ -421,4 +424,16 @@ erpnext.PointOfSale.PastOrderSummary = class {
|
||||
toggle_component(show) {
|
||||
show ? this.$component.css("display", "flex") : this.$component.css("display", "none");
|
||||
}
|
||||
|
||||
async print_receipt_on_order_complete() {
|
||||
const res = await frappe.db.get_value(
|
||||
"POS Profile",
|
||||
this.pos_profile,
|
||||
"print_receipt_on_order_complete"
|
||||
);
|
||||
|
||||
if (res.message.print_receipt_on_order_complete) {
|
||||
this.print_receipt();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
247
erpnext/setup/doctype/employee/employee_reminders.py
Normal file
247
erpnext/setup/doctype/employee/employee_reminders.py
Normal file
@@ -0,0 +1,247 @@
|
||||
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import comma_sep, getdate, today, add_months, add_days
|
||||
from erpnext.hr.doctype.employee.employee import get_all_employee_emails, get_employee_email
|
||||
from erpnext.hr.utils import get_holidays_for_employee
|
||||
|
||||
# -----------------
|
||||
# HOLIDAY REMINDERS
|
||||
# -----------------
|
||||
def send_reminders_in_advance_weekly():
|
||||
to_send_in_advance = int(frappe.db.get_single_value("HR Settings", "send_holiday_reminders") or 1)
|
||||
frequency = frappe.db.get_single_value("HR Settings", "frequency")
|
||||
if not (to_send_in_advance and frequency == "Weekly"):
|
||||
return
|
||||
|
||||
send_advance_holiday_reminders("Weekly")
|
||||
|
||||
def send_reminders_in_advance_monthly():
|
||||
to_send_in_advance = int(frappe.db.get_single_value("HR Settings", "send_holiday_reminders") or 1)
|
||||
frequency = frappe.db.get_single_value("HR Settings", "frequency")
|
||||
if not (to_send_in_advance and frequency == "Monthly"):
|
||||
return
|
||||
|
||||
send_advance_holiday_reminders("Monthly")
|
||||
|
||||
def send_advance_holiday_reminders(frequency):
|
||||
"""Send Holiday Reminders in Advance to Employees
|
||||
`frequency` (str): 'Weekly' or 'Monthly'
|
||||
"""
|
||||
if frequency == "Weekly":
|
||||
start_date = getdate()
|
||||
end_date = add_days(getdate(), 7)
|
||||
elif frequency == "Monthly":
|
||||
# Sent on 1st of every month
|
||||
start_date = getdate()
|
||||
end_date = add_months(getdate(), 1)
|
||||
else:
|
||||
return
|
||||
|
||||
employees = frappe.db.get_all('Employee', pluck='name')
|
||||
for employee in employees:
|
||||
holidays = get_holidays_for_employee(
|
||||
employee,
|
||||
start_date, end_date,
|
||||
only_non_weekly=True,
|
||||
raise_exception=False
|
||||
)
|
||||
|
||||
if not (holidays is None):
|
||||
send_holidays_reminder_in_advance(employee, holidays)
|
||||
|
||||
def send_holidays_reminder_in_advance(employee, holidays):
|
||||
employee_doc = frappe.get_doc('Employee', employee)
|
||||
employee_email = get_employee_email(employee_doc)
|
||||
frequency = frappe.db.get_single_value("HR Settings", "frequency")
|
||||
|
||||
email_header = _("Holidays this Month.") if frequency == "Monthly" else _("Holidays this Week.")
|
||||
frappe.sendmail(
|
||||
recipients=[employee_email],
|
||||
subject=_("Upcoming Holidays Reminder"),
|
||||
template="holiday_reminder",
|
||||
args=dict(
|
||||
reminder_text=_("Hey {}! This email is to remind you about the upcoming holidays.").format(employee_doc.get('first_name')),
|
||||
message=_("Below is the list of upcoming holidays for you:"),
|
||||
advance_holiday_reminder=True,
|
||||
holidays=holidays,
|
||||
frequency=frequency[:-2]
|
||||
),
|
||||
header=email_header
|
||||
)
|
||||
|
||||
# ------------------
|
||||
# BIRTHDAY REMINDERS
|
||||
# ------------------
|
||||
def send_birthday_reminders():
|
||||
"""Send Employee birthday reminders if no 'Stop Birthday Reminders' is not set."""
|
||||
to_send = int(frappe.db.get_single_value("HR Settings", "send_birthday_reminders") or 1)
|
||||
if not to_send:
|
||||
return
|
||||
|
||||
employees_born_today = get_employees_who_are_born_today()
|
||||
|
||||
for company, birthday_persons in employees_born_today.items():
|
||||
employee_emails = get_all_employee_emails(company)
|
||||
birthday_person_emails = [get_employee_email(doc) for doc in birthday_persons]
|
||||
recipients = list(set(employee_emails) - set(birthday_person_emails))
|
||||
|
||||
reminder_text, message = get_birthday_reminder_text_and_message(birthday_persons)
|
||||
send_birthday_reminder(recipients, reminder_text, birthday_persons, message)
|
||||
|
||||
if len(birthday_persons) > 1:
|
||||
# special email for people sharing birthdays
|
||||
for person in birthday_persons:
|
||||
person_email = person["user_id"] or person["personal_email"] or person["company_email"]
|
||||
others = [d for d in birthday_persons if d != person]
|
||||
reminder_text, message = get_birthday_reminder_text_and_message(others)
|
||||
send_birthday_reminder(person_email, reminder_text, others, message)
|
||||
|
||||
def get_birthday_reminder_text_and_message(birthday_persons):
|
||||
if len(birthday_persons) == 1:
|
||||
birthday_person_text = birthday_persons[0]['name']
|
||||
else:
|
||||
# converts ["Jim", "Rim", "Dim"] to Jim, Rim & Dim
|
||||
person_names = [d['name'] for d in birthday_persons]
|
||||
birthday_person_text = comma_sep(person_names, frappe._("{0} & {1}"), False)
|
||||
|
||||
reminder_text = _("Today is {0}'s birthday 🎉").format(birthday_person_text)
|
||||
message = _("A friendly reminder of an important date for our team.")
|
||||
message += "<br>"
|
||||
message += _("Everyone, let’s congratulate {0} on their birthday.").format(birthday_person_text)
|
||||
|
||||
return reminder_text, message
|
||||
|
||||
def send_birthday_reminder(recipients, reminder_text, birthday_persons, message):
|
||||
frappe.sendmail(
|
||||
recipients=recipients,
|
||||
subject=_("Birthday Reminder"),
|
||||
template="birthday_reminder",
|
||||
args=dict(
|
||||
reminder_text=reminder_text,
|
||||
birthday_persons=birthday_persons,
|
||||
message=message,
|
||||
),
|
||||
header=_("Birthday Reminder 🎂")
|
||||
)
|
||||
|
||||
def get_employees_who_are_born_today():
|
||||
"""Get all employee born today & group them based on their company"""
|
||||
return get_employees_having_an_event_today("birthday")
|
||||
|
||||
def get_employees_having_an_event_today(event_type):
|
||||
"""Get all employee who have `event_type` today
|
||||
& group them based on their company. `event_type`
|
||||
can be `birthday` or `work_anniversary`"""
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
# Set column based on event type
|
||||
if event_type == 'birthday':
|
||||
condition_column = 'date_of_birth'
|
||||
elif event_type == 'work_anniversary':
|
||||
condition_column = 'date_of_joining'
|
||||
else:
|
||||
return
|
||||
|
||||
employees_born_today = frappe.db.multisql({
|
||||
"mariadb": f"""
|
||||
SELECT `personal_email`, `company`, `company_email`, `user_id`, `employee_name` AS 'name', `image`, `date_of_joining`
|
||||
FROM `tabEmployee`
|
||||
WHERE
|
||||
DAY({condition_column}) = DAY(%(today)s)
|
||||
AND
|
||||
MONTH({condition_column}) = MONTH(%(today)s)
|
||||
AND
|
||||
`status` = 'Active'
|
||||
""",
|
||||
"postgres": f"""
|
||||
SELECT "personal_email", "company", "company_email", "user_id", "employee_name" AS 'name', "image"
|
||||
FROM "tabEmployee"
|
||||
WHERE
|
||||
DATE_PART('day', {condition_column}) = date_part('day', %(today)s)
|
||||
AND
|
||||
DATE_PART('month', {condition_column}) = date_part('month', %(today)s)
|
||||
AND
|
||||
"status" = 'Active'
|
||||
""",
|
||||
}, dict(today=today(), condition_column=condition_column), as_dict=1)
|
||||
|
||||
grouped_employees = defaultdict(lambda: [])
|
||||
|
||||
for employee_doc in employees_born_today:
|
||||
grouped_employees[employee_doc.get('company')].append(employee_doc)
|
||||
|
||||
return grouped_employees
|
||||
|
||||
|
||||
# --------------------------
|
||||
# WORK ANNIVERSARY REMINDERS
|
||||
# --------------------------
|
||||
def send_work_anniversary_reminders():
|
||||
"""Send Employee Work Anniversary Reminders if 'Send Work Anniversary Reminders' is checked"""
|
||||
to_send = int(frappe.db.get_single_value("HR Settings", "send_work_anniversary_reminders") or 1)
|
||||
if not to_send:
|
||||
return
|
||||
|
||||
employees_joined_today = get_employees_having_an_event_today("work_anniversary")
|
||||
|
||||
for company, anniversary_persons in employees_joined_today.items():
|
||||
employee_emails = get_all_employee_emails(company)
|
||||
anniversary_person_emails = [get_employee_email(doc) for doc in anniversary_persons]
|
||||
recipients = list(set(employee_emails) - set(anniversary_person_emails))
|
||||
|
||||
reminder_text, message = get_work_anniversary_reminder_text_and_message(anniversary_persons)
|
||||
send_work_anniversary_reminder(recipients, reminder_text, anniversary_persons, message)
|
||||
|
||||
if len(anniversary_persons) > 1:
|
||||
# email for people sharing work anniversaries
|
||||
for person in anniversary_persons:
|
||||
person_email = person["user_id"] or person["personal_email"] or person["company_email"]
|
||||
others = [d for d in anniversary_persons if d != person]
|
||||
reminder_text, message = get_work_anniversary_reminder_text_and_message(others)
|
||||
send_work_anniversary_reminder(person_email, reminder_text, others, message)
|
||||
|
||||
def get_work_anniversary_reminder_text_and_message(anniversary_persons):
|
||||
if len(anniversary_persons) == 1:
|
||||
anniversary_person = anniversary_persons[0]['name']
|
||||
persons_name = anniversary_person
|
||||
# Number of years completed at the company
|
||||
completed_years = getdate().year - anniversary_persons[0]['date_of_joining'].year
|
||||
anniversary_person += f" completed {completed_years} years"
|
||||
else:
|
||||
person_names_with_years = []
|
||||
names = []
|
||||
for person in anniversary_persons:
|
||||
person_text = person['name']
|
||||
names.append(person_text)
|
||||
# Number of years completed at the company
|
||||
completed_years = getdate().year - person['date_of_joining'].year
|
||||
person_text += f" completed {completed_years} years"
|
||||
person_names_with_years.append(person_text)
|
||||
|
||||
# converts ["Jim", "Rim", "Dim"] to Jim, Rim & Dim
|
||||
anniversary_person = comma_sep(person_names_with_years, frappe._("{0} & {1}"), False)
|
||||
persons_name = comma_sep(names, frappe._("{0} & {1}"), False)
|
||||
|
||||
reminder_text = _("Today {0} at our Company! 🎉").format(anniversary_person)
|
||||
message = _("A friendly reminder of an important date for our team.")
|
||||
message += "<br>"
|
||||
message += _("Everyone, let’s congratulate {0} on their work anniversary!").format(persons_name)
|
||||
|
||||
return reminder_text, message
|
||||
|
||||
def send_work_anniversary_reminder(recipients, reminder_text, anniversary_persons, message):
|
||||
frappe.sendmail(
|
||||
recipients=recipients,
|
||||
subject=_("Work Anniversary Reminder"),
|
||||
template="anniversary_reminder",
|
||||
args=dict(
|
||||
reminder_text=reminder_text,
|
||||
anniversary_persons=anniversary_persons,
|
||||
message=message,
|
||||
),
|
||||
header=_("🎊️🎊️ Work Anniversary Reminder 🎊️🎊️")
|
||||
)
|
||||
173
erpnext/setup/doctype/employee/test_employee_reminders.py
Normal file
173
erpnext/setup/doctype/employee/test_employee_reminders.py
Normal file
@@ -0,0 +1,173 @@
|
||||
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import frappe
|
||||
import unittest
|
||||
|
||||
from frappe.utils import getdate
|
||||
from datetime import timedelta
|
||||
from erpnext.hr.doctype.employee.test_employee import make_employee
|
||||
from erpnext.hr.doctype.hr_settings.hr_settings import set_proceed_with_frequency_change
|
||||
|
||||
|
||||
class TestEmployeeReminders(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
from erpnext.hr.doctype.holiday_list.test_holiday_list import make_holiday_list
|
||||
|
||||
# Create a test holiday list
|
||||
test_holiday_dates = cls.get_test_holiday_dates()
|
||||
test_holiday_list = make_holiday_list(
|
||||
'TestHolidayRemindersList',
|
||||
holiday_dates=[
|
||||
{'holiday_date': test_holiday_dates[0], 'description': 'test holiday1'},
|
||||
{'holiday_date': test_holiday_dates[1], 'description': 'test holiday2'},
|
||||
{'holiday_date': test_holiday_dates[2], 'description': 'test holiday3', 'weekly_off': 1},
|
||||
{'holiday_date': test_holiday_dates[3], 'description': 'test holiday4'},
|
||||
{'holiday_date': test_holiday_dates[4], 'description': 'test holiday5'},
|
||||
{'holiday_date': test_holiday_dates[5], 'description': 'test holiday6'},
|
||||
],
|
||||
from_date=getdate()-timedelta(days=10),
|
||||
to_date=getdate()+timedelta(weeks=5)
|
||||
)
|
||||
|
||||
# Create a test employee
|
||||
test_employee = frappe.get_doc(
|
||||
'Employee',
|
||||
make_employee('test@gopher.io', company="_Test Company")
|
||||
)
|
||||
|
||||
# Attach the holiday list to employee
|
||||
test_employee.holiday_list = test_holiday_list.name
|
||||
test_employee.save()
|
||||
|
||||
# Attach to class
|
||||
cls.test_employee = test_employee
|
||||
cls.test_holiday_dates = test_holiday_dates
|
||||
|
||||
@classmethod
|
||||
def get_test_holiday_dates(cls):
|
||||
today_date = getdate()
|
||||
return [
|
||||
today_date,
|
||||
today_date-timedelta(days=4),
|
||||
today_date-timedelta(days=3),
|
||||
today_date+timedelta(days=1),
|
||||
today_date+timedelta(days=3),
|
||||
today_date+timedelta(weeks=3)
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
# Clear Email Queue
|
||||
frappe.db.sql("delete from `tabEmail Queue`")
|
||||
|
||||
def test_is_holiday(self):
|
||||
from erpnext.hr.doctype.employee.employee import is_holiday
|
||||
|
||||
self.assertTrue(is_holiday(self.test_employee.name))
|
||||
self.assertTrue(is_holiday(self.test_employee.name, date=self.test_holiday_dates[1]))
|
||||
self.assertFalse(is_holiday(self.test_employee.name, date=getdate()-timedelta(days=1)))
|
||||
|
||||
# Test weekly_off holidays
|
||||
self.assertTrue(is_holiday(self.test_employee.name, date=self.test_holiday_dates[2]))
|
||||
self.assertFalse(is_holiday(self.test_employee.name, date=self.test_holiday_dates[2], only_non_weekly=True))
|
||||
|
||||
# Test with descriptions
|
||||
has_holiday, descriptions = is_holiday(self.test_employee.name, with_description=True)
|
||||
self.assertTrue(has_holiday)
|
||||
self.assertTrue('test holiday1' in descriptions)
|
||||
|
||||
def test_birthday_reminders(self):
|
||||
employee = frappe.get_doc("Employee", frappe.db.sql_list("select name from tabEmployee limit 1")[0])
|
||||
employee.date_of_birth = "1992" + frappe.utils.nowdate()[4:]
|
||||
employee.company_email = "test@example.com"
|
||||
employee.company = "_Test Company"
|
||||
employee.save()
|
||||
|
||||
from erpnext.hr.doctype.employee.employee_reminders import get_employees_who_are_born_today, send_birthday_reminders
|
||||
|
||||
employees_born_today = get_employees_who_are_born_today()
|
||||
self.assertTrue(employees_born_today.get("_Test Company"))
|
||||
|
||||
hr_settings = frappe.get_doc("HR Settings", "HR Settings")
|
||||
hr_settings.send_birthday_reminders = 1
|
||||
hr_settings.save()
|
||||
|
||||
send_birthday_reminders()
|
||||
|
||||
email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True)
|
||||
self.assertTrue("Subject: Birthday Reminder" in email_queue[0].message)
|
||||
|
||||
def test_work_anniversary_reminders(self):
|
||||
employee = frappe.get_doc("Employee", frappe.db.sql_list("select name from tabEmployee limit 1")[0])
|
||||
employee.date_of_joining = "1998" + frappe.utils.nowdate()[4:]
|
||||
employee.company_email = "test@example.com"
|
||||
employee.company = "_Test Company"
|
||||
employee.save()
|
||||
|
||||
from erpnext.hr.doctype.employee.employee_reminders import get_employees_having_an_event_today, send_work_anniversary_reminders
|
||||
|
||||
employees_having_work_anniversary = get_employees_having_an_event_today('work_anniversary')
|
||||
self.assertTrue(employees_having_work_anniversary.get("_Test Company"))
|
||||
|
||||
hr_settings = frappe.get_doc("HR Settings", "HR Settings")
|
||||
hr_settings.send_work_anniversary_reminders = 1
|
||||
hr_settings.save()
|
||||
|
||||
send_work_anniversary_reminders()
|
||||
|
||||
email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True)
|
||||
self.assertTrue("Subject: Work Anniversary Reminder" in email_queue[0].message)
|
||||
|
||||
def test_send_holidays_reminder_in_advance(self):
|
||||
from erpnext.hr.utils import get_holidays_for_employee
|
||||
from erpnext.hr.doctype.employee.employee_reminders import send_holidays_reminder_in_advance
|
||||
|
||||
# Get HR settings and enable advance holiday reminders
|
||||
hr_settings = frappe.get_doc("HR Settings", "HR Settings")
|
||||
hr_settings.send_holiday_reminders = 1
|
||||
set_proceed_with_frequency_change()
|
||||
hr_settings.frequency = 'Weekly'
|
||||
hr_settings.save()
|
||||
|
||||
holidays = get_holidays_for_employee(
|
||||
self.test_employee.get('name'),
|
||||
getdate(), getdate() + timedelta(days=3),
|
||||
only_non_weekly=True,
|
||||
raise_exception=False
|
||||
)
|
||||
|
||||
send_holidays_reminder_in_advance(
|
||||
self.test_employee.get('name'),
|
||||
holidays
|
||||
)
|
||||
|
||||
email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True)
|
||||
self.assertEqual(len(email_queue), 1)
|
||||
|
||||
def test_advance_holiday_reminders_monthly(self):
|
||||
from erpnext.hr.doctype.employee.employee_reminders import send_reminders_in_advance_monthly
|
||||
# Get HR settings and enable advance holiday reminders
|
||||
hr_settings = frappe.get_doc("HR Settings", "HR Settings")
|
||||
hr_settings.send_holiday_reminders = 1
|
||||
set_proceed_with_frequency_change()
|
||||
hr_settings.frequency = 'Monthly'
|
||||
hr_settings.save()
|
||||
|
||||
send_reminders_in_advance_monthly()
|
||||
|
||||
email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True)
|
||||
self.assertTrue(len(email_queue) > 0)
|
||||
|
||||
def test_advance_holiday_reminders_weekly(self):
|
||||
from erpnext.hr.doctype.employee.employee_reminders import send_reminders_in_advance_weekly
|
||||
# Get HR settings and enable advance holiday reminders
|
||||
hr_settings = frappe.get_doc("HR Settings", "HR Settings")
|
||||
hr_settings.send_holiday_reminders = 1
|
||||
hr_settings.frequency = 'Weekly'
|
||||
hr_settings.save()
|
||||
|
||||
send_reminders_in_advance_weekly()
|
||||
|
||||
email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True)
|
||||
self.assertTrue(len(email_queue) > 0)
|
||||
@@ -12,7 +12,6 @@ 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"}
|
||||
|
||||
@@ -983,11 +982,6 @@ 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)
|
||||
|
||||
|
||||
@@ -159,6 +159,29 @@ class MaterialRequest(BuyingController):
|
||||
self.reset_default_field_value("set_warehouse", "items", "warehouse")
|
||||
self.reset_default_field_value("set_from_warehouse", "items", "from_warehouse")
|
||||
|
||||
self.validate_pp_qty()
|
||||
|
||||
def validate_pp_qty(self):
|
||||
items_from_pp = [item for item in self.items if item.material_request_plan_item]
|
||||
if items_from_pp:
|
||||
items_mr_plan_items = [item.material_request_plan_item for item in items_from_pp]
|
||||
table = frappe.qb.DocType("Material Request Plan Item")
|
||||
query = (
|
||||
frappe.qb.from_(table)
|
||||
.select(table.name, (table.quantity - table.requested_qty).as_("available_qty"))
|
||||
.where(table.name.isin(items_mr_plan_items))
|
||||
)
|
||||
result = query.run(as_dict=True)
|
||||
|
||||
for item in items_from_pp:
|
||||
row = next(r for r in result if r.name == item.material_request_plan_item)
|
||||
if item.qty > row.available_qty:
|
||||
frappe.throw(
|
||||
_("Quantity cannot be greater than {0} for Item {1}").format(
|
||||
row.available_qty, item.item_code
|
||||
)
|
||||
)
|
||||
|
||||
def before_update_after_submit(self):
|
||||
self.validate_schedule_date()
|
||||
|
||||
@@ -233,7 +256,7 @@ class MaterialRequest(BuyingController):
|
||||
)
|
||||
|
||||
def on_cancel(self):
|
||||
self.update_requested_qty_in_production_plan()
|
||||
self.update_requested_qty_in_production_plan(cancel=True)
|
||||
self.update_requested_qty()
|
||||
|
||||
def get_mr_items_ordered_qty(self, mr_items):
|
||||
@@ -337,11 +360,14 @@ class MaterialRequest(BuyingController):
|
||||
},
|
||||
)
|
||||
|
||||
def update_requested_qty_in_production_plan(self):
|
||||
def update_requested_qty_in_production_plan(self, cancel=False):
|
||||
production_plans = []
|
||||
for d in self.get("items"):
|
||||
if d.production_plan and d.material_request_plan_item:
|
||||
qty = d.qty if self.docstatus == 1 else 0
|
||||
requested_qty = frappe.get_value(
|
||||
"Material Request Plan Item", d.material_request_plan_item, "requested_qty"
|
||||
)
|
||||
qty = (requested_qty + d.qty) if not cancel else (requested_qty - d.qty)
|
||||
frappe.db.set_value(
|
||||
"Material Request Plan Item", d.material_request_plan_item, "requested_qty", qty
|
||||
)
|
||||
|
||||
@@ -383,6 +383,39 @@ class PurchaseReceipt(BuyingController):
|
||||
self.repost_future_sle_and_gle()
|
||||
self.set_consumed_qty_in_subcontract_order()
|
||||
self.reserve_stock_for_sales_order()
|
||||
self.update_received_qty_if_from_pp()
|
||||
|
||||
def update_received_qty_if_from_pp(self):
|
||||
from frappe.query_builder.functions import Sum
|
||||
|
||||
items_from_po = [item.purchase_order_item for item in self.items if item.purchase_order_item]
|
||||
if items_from_po:
|
||||
table = frappe.qb.DocType("Purchase Order Item")
|
||||
subquery = (
|
||||
frappe.qb.from_(table)
|
||||
.select(table.production_plan_sub_assembly_item)
|
||||
.distinct()
|
||||
.where(table.name.isin(items_from_po) & table.production_plan_sub_assembly_item.isnotnull())
|
||||
)
|
||||
result = subquery.run(as_dict=True)
|
||||
if result:
|
||||
result = [item.production_plan_sub_assembly_item for item in result]
|
||||
query = (
|
||||
frappe.qb.from_(table)
|
||||
.select(
|
||||
table.production_plan_sub_assembly_item,
|
||||
Sum(table.received_qty / (table.qty / table.fg_item_qty)).as_("received_qty"),
|
||||
)
|
||||
.where(table.production_plan_sub_assembly_item.isin(result))
|
||||
.groupby(table.production_plan_sub_assembly_item)
|
||||
)
|
||||
for row in query.run(as_dict=True):
|
||||
frappe.set_value(
|
||||
"Production Plan Sub Assembly Item",
|
||||
row.production_plan_sub_assembly_item,
|
||||
"received_qty",
|
||||
row.received_qty,
|
||||
)
|
||||
|
||||
def check_next_docstatus(self):
|
||||
submit_rv = frappe.db.sql(
|
||||
@@ -424,6 +457,7 @@ class PurchaseReceipt(BuyingController):
|
||||
)
|
||||
self.delete_auto_created_batches()
|
||||
self.set_consumed_qty_in_subcontract_order()
|
||||
self.update_received_qty_if_from_pp()
|
||||
|
||||
def get_gl_entries(self, warehouse_account=None, via_landed_cost_voucher=False):
|
||||
from erpnext.accounts.general_ledger import process_gl_map
|
||||
|
||||
@@ -1557,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"):
|
||||
if parent_doc.get("is_return") and parent_doc.get("doctype") != "Stock Entry":
|
||||
type_of_transaction = "Inward"
|
||||
if (
|
||||
parent_doc.get("doctype") in ["Purchase Receipt", "Purchase Invoice"]
|
||||
|
||||
@@ -169,21 +169,6 @@ 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,
|
||||
|
||||
@@ -499,7 +499,7 @@ class StockEntry(StockController):
|
||||
if not self.process_loss_qty:
|
||||
continue
|
||||
|
||||
if fg_completed_qty != (flt(fg_item_qty) + flt(self.process_loss_qty, precision)):
|
||||
if fg_completed_qty != (flt(fg_item_qty, precision) + flt(self.process_loss_qty, precision)):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Since there is a process loss of {0} units for the finished good {1}, you should reduce the quantity by {0} units for the finished good {1} in the Items Table."
|
||||
@@ -628,7 +628,9 @@ class StockEntry(StockController):
|
||||
completed_qty = (
|
||||
d.completed_qty + d.process_loss_qty + (allowance_percentage / 100 * d.completed_qty)
|
||||
)
|
||||
if total_completed_qty > flt(completed_qty):
|
||||
if flt(total_completed_qty, self.precision("fg_completed_qty")) > flt(
|
||||
completed_qty, self.precision("fg_completed_qty")
|
||||
):
|
||||
job_card = frappe.db.get_value("Job Card", {"operation_id": d.name}, "name")
|
||||
if not job_card:
|
||||
frappe.throw(
|
||||
|
||||
@@ -89,18 +89,22 @@ def get_average_age(fifo_queue: list, to_date: str) -> float:
|
||||
|
||||
def get_range_age(filters: Filters, fifo_queue: list, to_date: str, item_dict: dict) -> list:
|
||||
precision = cint(frappe.db.get_single_value("System Settings", "float_precision", cache=True))
|
||||
range_values = [0.0] * (len(filters.ranges) + 1)
|
||||
range_values = [0.0] * ((len(filters.ranges) * 2) + 2)
|
||||
|
||||
for item in fifo_queue:
|
||||
age = flt(date_diff(to_date, item[1]))
|
||||
qty = flt(item[0]) if not item_dict["has_serial_no"] else 1.0
|
||||
stock_value = flt(item[2])
|
||||
|
||||
for i, age_limit in enumerate(filters.ranges):
|
||||
if age <= flt(age_limit):
|
||||
i *= 2
|
||||
range_values[i] = flt(range_values[i] + qty, precision)
|
||||
range_values[i + 1] = flt(range_values[i + 1] + stock_value, precision)
|
||||
break
|
||||
else:
|
||||
range_values[-1] = flt(range_values[-1] + qty, precision)
|
||||
range_values[-2] = flt(range_values[-2] + qty, precision)
|
||||
range_values[-1] = flt(range_values[-1] + stock_value, precision)
|
||||
|
||||
return range_values
|
||||
|
||||
@@ -199,6 +203,7 @@ def setup_ageing_columns(filters: Filters, range_columns: list):
|
||||
for i, label in enumerate(ranges):
|
||||
fieldname = "range" + str(i + 1)
|
||||
add_column(range_columns, label=_("Age ({0})").format(label), fieldname=fieldname)
|
||||
add_column(range_columns, label=_("Value ({0})").format(label), fieldname=fieldname + "value")
|
||||
|
||||
|
||||
def add_column(range_columns: list, label: str, fieldname: str, fieldtype: str = "Float", width: int = 140):
|
||||
@@ -298,16 +303,22 @@ class FIFOSlots:
|
||||
# neutralize 0/negative stock by adding positive stock
|
||||
fifo_queue[0][0] += flt(row.actual_qty)
|
||||
fifo_queue[0][1] = row.posting_date
|
||||
fifo_queue[0][2] += flt(row.stock_value_difference)
|
||||
else:
|
||||
fifo_queue.append([flt(row.actual_qty), row.posting_date])
|
||||
fifo_queue.append(
|
||||
[flt(row.actual_qty), row.posting_date, flt(row.stock_value_difference)]
|
||||
)
|
||||
return
|
||||
|
||||
valuation = row.stock_value_difference / row.actual_qty
|
||||
for serial_no in serial_nos:
|
||||
if self.serial_no_batch_purchase_details.get(serial_no):
|
||||
fifo_queue.append([serial_no, self.serial_no_batch_purchase_details.get(serial_no)])
|
||||
fifo_queue.append(
|
||||
[serial_no, self.serial_no_batch_purchase_details.get(serial_no), valuation]
|
||||
)
|
||||
else:
|
||||
self.serial_no_batch_purchase_details.setdefault(serial_no, row.posting_date)
|
||||
fifo_queue.append([serial_no, row.posting_date])
|
||||
fifo_queue.append([serial_no, row.posting_date, valuation])
|
||||
|
||||
def __compute_outgoing_stock(self, row: dict, fifo_queue: list, transfer_key: tuple, serial_nos: list):
|
||||
"Update FIFO Queue on outward stock."
|
||||
@@ -316,34 +327,44 @@ class FIFOSlots:
|
||||
return
|
||||
|
||||
qty_to_pop = abs(row.actual_qty)
|
||||
stock_value = abs(row.stock_value_difference)
|
||||
|
||||
while qty_to_pop:
|
||||
slot = fifo_queue[0] if fifo_queue else [0, None]
|
||||
slot = fifo_queue[0] if fifo_queue else [0, None, 0]
|
||||
if 0 < flt(slot[0]) <= qty_to_pop:
|
||||
# qty to pop >= slot qty
|
||||
# if +ve and not enough or exactly same balance in current slot, consume whole slot
|
||||
qty_to_pop -= flt(slot[0])
|
||||
stock_value -= flt(slot[2])
|
||||
self.transferred_item_details[transfer_key].append(fifo_queue.pop(0))
|
||||
elif not fifo_queue:
|
||||
# negative stock, no balance but qty yet to consume
|
||||
fifo_queue.append([-(qty_to_pop), row.posting_date])
|
||||
self.transferred_item_details[transfer_key].append([qty_to_pop, row.posting_date])
|
||||
fifo_queue.append([-(qty_to_pop), row.posting_date, -(stock_value)])
|
||||
self.transferred_item_details[transfer_key].append(
|
||||
[qty_to_pop, row.posting_date, stock_value]
|
||||
)
|
||||
qty_to_pop = 0
|
||||
stock_value = 0
|
||||
else:
|
||||
# qty to pop < slot qty, ample balance
|
||||
# consume actual_qty from first slot
|
||||
slot[0] = flt(slot[0]) - qty_to_pop
|
||||
self.transferred_item_details[transfer_key].append([qty_to_pop, slot[1]])
|
||||
slot[2] = flt(slot[2]) - stock_value
|
||||
self.transferred_item_details[transfer_key].append([qty_to_pop, slot[1], stock_value])
|
||||
qty_to_pop = 0
|
||||
stock_value = 0
|
||||
|
||||
def __adjust_incoming_transfer_qty(self, transfer_data: dict, fifo_queue: list, row: dict):
|
||||
"Add previously removed stock back to FIFO Queue."
|
||||
transfer_qty_to_pop = flt(row.actual_qty)
|
||||
stock_value = flt(row.stock_value_difference)
|
||||
|
||||
def add_to_fifo_queue(slot):
|
||||
if fifo_queue and flt(fifo_queue[0][0]) <= 0:
|
||||
# neutralize 0/negative stock by adding positive stock
|
||||
fifo_queue[0][0] += flt(slot[0])
|
||||
fifo_queue[0][1] = slot[1]
|
||||
fifo_queue[0][2] += flt(slot[2])
|
||||
else:
|
||||
fifo_queue.append(slot)
|
||||
|
||||
@@ -351,16 +372,20 @@ class FIFOSlots:
|
||||
if transfer_data and 0 < transfer_data[0][0] <= transfer_qty_to_pop:
|
||||
# bucket qty is not enough, consume whole
|
||||
transfer_qty_to_pop -= transfer_data[0][0]
|
||||
stock_value -= transfer_data[0][2]
|
||||
add_to_fifo_queue(transfer_data.pop(0))
|
||||
elif not transfer_data:
|
||||
# transfer bucket is empty, extra incoming qty
|
||||
add_to_fifo_queue([transfer_qty_to_pop, row.posting_date])
|
||||
add_to_fifo_queue([transfer_qty_to_pop, row.posting_date, stock_value])
|
||||
transfer_qty_to_pop = 0
|
||||
stock_value = 0
|
||||
else:
|
||||
# ample bucket qty to consume
|
||||
transfer_data[0][0] -= transfer_qty_to_pop
|
||||
add_to_fifo_queue([transfer_qty_to_pop, transfer_data[0][1]])
|
||||
transfer_data[0][2] -= stock_value
|
||||
add_to_fifo_queue([transfer_qty_to_pop, transfer_data[0][1], stock_value])
|
||||
transfer_qty_to_pop = 0
|
||||
stock_value = 0
|
||||
|
||||
def __update_balances(self, row: dict, key: tuple | str):
|
||||
self.item_details[key]["qty_after_transaction"] = row.qty_after_transaction
|
||||
@@ -412,6 +437,7 @@ class FIFOSlots:
|
||||
item.stock_uom,
|
||||
item.has_serial_no,
|
||||
sle.actual_qty,
|
||||
sle.stock_value_difference,
|
||||
sle.posting_date,
|
||||
sle.voucher_type,
|
||||
sle.voucher_no,
|
||||
|
||||
@@ -18,6 +18,7 @@ class TestStockAgeing(IntegrationTestCase):
|
||||
name="Flask Item",
|
||||
actual_qty=30,
|
||||
qty_after_transaction=30,
|
||||
stock_value_difference=30,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-01",
|
||||
voucher_type="Stock Entry",
|
||||
@@ -29,6 +30,7 @@ class TestStockAgeing(IntegrationTestCase):
|
||||
name="Flask Item",
|
||||
actual_qty=20,
|
||||
qty_after_transaction=50,
|
||||
stock_value_difference=20,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-02",
|
||||
voucher_type="Stock Entry",
|
||||
@@ -40,6 +42,7 @@ class TestStockAgeing(IntegrationTestCase):
|
||||
name="Flask Item",
|
||||
actual_qty=(-10),
|
||||
qty_after_transaction=40,
|
||||
stock_value_difference=(-10),
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-03",
|
||||
voucher_type="Stock Entry",
|
||||
@@ -57,6 +60,8 @@ class TestStockAgeing(IntegrationTestCase):
|
||||
|
||||
self.assertEqual(result["qty_after_transaction"], result["total_qty"])
|
||||
self.assertEqual(queue[0][0], 20.0)
|
||||
data = format_report_data(self.filters, slots, self.filters["to_date"])
|
||||
self.assertEqual(data[0][8], 40.0) # valuating for stock value between age 0-30
|
||||
|
||||
def test_insufficient_balance(self):
|
||||
"Reference: Case 3 in stock_ageing_fifo_logic.md (same wh)"
|
||||
@@ -65,6 +70,7 @@ class TestStockAgeing(IntegrationTestCase):
|
||||
name="Flask Item",
|
||||
actual_qty=(-30),
|
||||
qty_after_transaction=(-30),
|
||||
stock_value_difference=0,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-01",
|
||||
voucher_type="Stock Entry",
|
||||
@@ -76,6 +82,7 @@ class TestStockAgeing(IntegrationTestCase):
|
||||
name="Flask Item",
|
||||
actual_qty=20,
|
||||
qty_after_transaction=(-10),
|
||||
stock_value_difference=0,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-02",
|
||||
voucher_type="Stock Entry",
|
||||
@@ -87,6 +94,7 @@ class TestStockAgeing(IntegrationTestCase):
|
||||
name="Flask Item",
|
||||
actual_qty=20,
|
||||
qty_after_transaction=10,
|
||||
stock_value_difference=0,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-03",
|
||||
voucher_type="Stock Entry",
|
||||
@@ -98,6 +106,7 @@ class TestStockAgeing(IntegrationTestCase):
|
||||
name="Flask Item",
|
||||
actual_qty=10,
|
||||
qty_after_transaction=20,
|
||||
stock_value_difference=0,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-03",
|
||||
voucher_type="Stock Entry",
|
||||
@@ -126,6 +135,7 @@ class TestStockAgeing(IntegrationTestCase):
|
||||
name="Flask Item",
|
||||
actual_qty=30,
|
||||
qty_after_transaction=30,
|
||||
stock_value_difference=0,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-01",
|
||||
voucher_type="Stock Entry",
|
||||
@@ -137,6 +147,7 @@ class TestStockAgeing(IntegrationTestCase):
|
||||
name="Flask Item",
|
||||
actual_qty=0,
|
||||
qty_after_transaction=50,
|
||||
stock_value_difference=0,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-02",
|
||||
voucher_type="Stock Reconciliation",
|
||||
@@ -148,6 +159,7 @@ class TestStockAgeing(IntegrationTestCase):
|
||||
name="Flask Item",
|
||||
actual_qty=(-10),
|
||||
qty_after_transaction=40,
|
||||
stock_value_difference=0,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-03",
|
||||
voucher_type="Stock Entry",
|
||||
@@ -178,6 +190,7 @@ class TestStockAgeing(IntegrationTestCase):
|
||||
name="Flask Item",
|
||||
actual_qty=0,
|
||||
qty_after_transaction=1000,
|
||||
stock_value_difference=0,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-01",
|
||||
voucher_type="Stock Reconciliation",
|
||||
@@ -189,6 +202,7 @@ class TestStockAgeing(IntegrationTestCase):
|
||||
name="Flask Item",
|
||||
actual_qty=0,
|
||||
qty_after_transaction=400,
|
||||
stock_value_difference=0,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-02",
|
||||
voucher_type="Stock Reconciliation",
|
||||
@@ -200,6 +214,7 @@ class TestStockAgeing(IntegrationTestCase):
|
||||
name="Flask Item",
|
||||
actual_qty=(-10),
|
||||
qty_after_transaction=390,
|
||||
stock_value_difference=0,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-03",
|
||||
voucher_type="Stock Entry",
|
||||
@@ -233,6 +248,7 @@ class TestStockAgeing(IntegrationTestCase):
|
||||
name="Flask Item",
|
||||
actual_qty=0,
|
||||
qty_after_transaction=1000,
|
||||
stock_value_difference=0,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-01",
|
||||
voucher_type="Stock Reconciliation",
|
||||
@@ -244,6 +260,7 @@ class TestStockAgeing(IntegrationTestCase):
|
||||
name="Flask Item",
|
||||
actual_qty=0,
|
||||
qty_after_transaction=400,
|
||||
stock_value_difference=0,
|
||||
warehouse="WH 2",
|
||||
posting_date="2021-12-02",
|
||||
voucher_type="Stock Reconciliation",
|
||||
@@ -255,6 +272,7 @@ class TestStockAgeing(IntegrationTestCase):
|
||||
name="Flask Item",
|
||||
actual_qty=(-10),
|
||||
qty_after_transaction=990,
|
||||
stock_value_difference=0,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-03",
|
||||
voucher_type="Stock Entry",
|
||||
@@ -301,6 +319,7 @@ class TestStockAgeing(IntegrationTestCase):
|
||||
name="Flask Item",
|
||||
actual_qty=500,
|
||||
qty_after_transaction=500,
|
||||
stock_value_difference=0,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-03",
|
||||
voucher_type="Stock Entry",
|
||||
@@ -312,6 +331,7 @@ class TestStockAgeing(IntegrationTestCase):
|
||||
name="Flask Item",
|
||||
actual_qty=(-50),
|
||||
qty_after_transaction=450,
|
||||
stock_value_difference=0,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-04",
|
||||
voucher_type="Stock Entry",
|
||||
@@ -323,6 +343,7 @@ class TestStockAgeing(IntegrationTestCase):
|
||||
name="Flask Item",
|
||||
actual_qty=(-50),
|
||||
qty_after_transaction=400,
|
||||
stock_value_difference=0,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-04",
|
||||
voucher_type="Stock Entry",
|
||||
@@ -334,6 +355,7 @@ class TestStockAgeing(IntegrationTestCase):
|
||||
name="Flask Item",
|
||||
actual_qty=100,
|
||||
qty_after_transaction=500,
|
||||
stock_value_difference=0,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-04",
|
||||
voucher_type="Stock Entry",
|
||||
@@ -370,6 +392,7 @@ class TestStockAgeing(IntegrationTestCase):
|
||||
name="Flask Item",
|
||||
actual_qty=500,
|
||||
qty_after_transaction=500,
|
||||
stock_value_difference=0,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-03",
|
||||
voucher_type="Stock Entry",
|
||||
@@ -381,6 +404,7 @@ class TestStockAgeing(IntegrationTestCase):
|
||||
name="Flask Item",
|
||||
actual_qty=(-100),
|
||||
qty_after_transaction=400,
|
||||
stock_value_difference=0,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-04",
|
||||
voucher_type="Stock Entry",
|
||||
@@ -392,6 +416,7 @@ class TestStockAgeing(IntegrationTestCase):
|
||||
name="Flask Item",
|
||||
actual_qty=50,
|
||||
qty_after_transaction=450,
|
||||
stock_value_difference=0,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-04",
|
||||
voucher_type="Stock Entry",
|
||||
@@ -426,6 +451,7 @@ class TestStockAgeing(IntegrationTestCase):
|
||||
name="Flask Item",
|
||||
actual_qty=20,
|
||||
qty_after_transaction=20,
|
||||
stock_value_difference=0,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-03",
|
||||
voucher_type="Stock Entry",
|
||||
@@ -437,6 +463,7 @@ class TestStockAgeing(IntegrationTestCase):
|
||||
name="Flask Item",
|
||||
actual_qty=(-50),
|
||||
qty_after_transaction=(-30),
|
||||
stock_value_difference=0,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-04",
|
||||
voucher_type="Stock Entry",
|
||||
@@ -448,6 +475,7 @@ class TestStockAgeing(IntegrationTestCase):
|
||||
name="Flask Item",
|
||||
actual_qty=(-50),
|
||||
qty_after_transaction=(-80),
|
||||
stock_value_difference=0,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-04",
|
||||
voucher_type="Stock Entry",
|
||||
@@ -459,6 +487,7 @@ class TestStockAgeing(IntegrationTestCase):
|
||||
name="Flask Item",
|
||||
actual_qty=50,
|
||||
qty_after_transaction=(-30),
|
||||
stock_value_difference=0,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-04",
|
||||
voucher_type="Stock Entry",
|
||||
@@ -496,6 +525,7 @@ class TestStockAgeing(IntegrationTestCase):
|
||||
name="Flask Item",
|
||||
actual_qty=500,
|
||||
qty_after_transaction=500,
|
||||
stock_value_difference=0,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-03",
|
||||
voucher_type="Stock Entry",
|
||||
@@ -507,6 +537,7 @@ class TestStockAgeing(IntegrationTestCase):
|
||||
name="Flask Item",
|
||||
actual_qty=(-50),
|
||||
qty_after_transaction=450,
|
||||
stock_value_difference=0,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-04",
|
||||
voucher_type="Stock Entry",
|
||||
@@ -518,6 +549,7 @@ class TestStockAgeing(IntegrationTestCase):
|
||||
name="Flask Item",
|
||||
actual_qty=100,
|
||||
qty_after_transaction=550,
|
||||
stock_value_difference=0,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-04",
|
||||
voucher_type="Stock Entry",
|
||||
@@ -553,6 +585,7 @@ class TestStockAgeing(IntegrationTestCase):
|
||||
name="Flask Item",
|
||||
actual_qty=20,
|
||||
qty_after_transaction=20,
|
||||
stock_value_difference=0,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-03",
|
||||
voucher_type="Stock Entry",
|
||||
@@ -564,6 +597,7 @@ class TestStockAgeing(IntegrationTestCase):
|
||||
name="Flask Item",
|
||||
actual_qty=(-50),
|
||||
qty_after_transaction=(-30),
|
||||
stock_value_difference=0,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-04",
|
||||
voucher_type="Stock Entry",
|
||||
@@ -575,6 +609,7 @@ class TestStockAgeing(IntegrationTestCase):
|
||||
name="Flask Item",
|
||||
actual_qty=50,
|
||||
qty_after_transaction=20,
|
||||
stock_value_difference=0,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-04",
|
||||
voucher_type="Stock Entry",
|
||||
@@ -586,6 +621,7 @@ class TestStockAgeing(IntegrationTestCase):
|
||||
name="Flask Item",
|
||||
actual_qty=50,
|
||||
qty_after_transaction=70,
|
||||
stock_value_difference=0,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-04",
|
||||
voucher_type="Stock Entry",
|
||||
@@ -623,6 +659,7 @@ class TestStockAgeing(IntegrationTestCase):
|
||||
name="Flask Item",
|
||||
actual_qty=(-50),
|
||||
qty_after_transaction=(-50),
|
||||
stock_value_difference=0,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-01",
|
||||
voucher_type="Stock Entry",
|
||||
@@ -634,6 +671,7 @@ class TestStockAgeing(IntegrationTestCase):
|
||||
name="Flask Item",
|
||||
actual_qty=(-50),
|
||||
qty_after_transaction=(-100),
|
||||
stock_value_difference=0,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-01",
|
||||
voucher_type="Stock Entry",
|
||||
@@ -645,6 +683,7 @@ class TestStockAgeing(IntegrationTestCase):
|
||||
name="Flask Item",
|
||||
actual_qty=30,
|
||||
qty_after_transaction=(-70),
|
||||
stock_value_difference=0,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-01",
|
||||
voucher_type="Stock Entry",
|
||||
@@ -722,6 +761,113 @@ class TestStockAgeing(IntegrationTestCase):
|
||||
self.assertEqual(bal_qty, 0.9)
|
||||
self.assertEqual(bal_qty, range_qty_sum)
|
||||
|
||||
def test_ageing_stock_valuation(self):
|
||||
"Test stock valuation for each time bucket."
|
||||
sle = [
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=10,
|
||||
qty_after_transaction=10,
|
||||
stock_value_difference=10,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-01",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False,
|
||||
serial_no=None,
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=20,
|
||||
qty_after_transaction=30,
|
||||
stock_value_difference=20,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-02",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False,
|
||||
serial_no=None,
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=(-10),
|
||||
qty_after_transaction=20,
|
||||
stock_value_difference=(-10),
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-03",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="003",
|
||||
has_serial_no=False,
|
||||
serial_no=None,
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=10,
|
||||
qty_after_transaction=30,
|
||||
stock_value_difference=20,
|
||||
warehouse="WH 1",
|
||||
posting_date="2022-01-01",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="004",
|
||||
has_serial_no=False,
|
||||
serial_no=None,
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=(-15),
|
||||
qty_after_transaction=15,
|
||||
stock_value_difference=(-15),
|
||||
warehouse="WH 1",
|
||||
posting_date="2022-01-02",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="005",
|
||||
has_serial_no=False,
|
||||
serial_no=None,
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=10,
|
||||
qty_after_transaction=25,
|
||||
stock_value_difference=5,
|
||||
warehouse="WH 1",
|
||||
posting_date="2022-02-01",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="006",
|
||||
has_serial_no=False,
|
||||
serial_no=None,
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=5,
|
||||
qty_after_transaction=30,
|
||||
stock_value_difference=2.5,
|
||||
warehouse="WH 1",
|
||||
posting_date="2022-02-02",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="007",
|
||||
has_serial_no=False,
|
||||
serial_no=None,
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=5,
|
||||
qty_after_transaction=35,
|
||||
stock_value_difference=15,
|
||||
warehouse="WH 1",
|
||||
posting_date="2022-03-01",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="008",
|
||||
has_serial_no=False,
|
||||
serial_no=None,
|
||||
),
|
||||
]
|
||||
|
||||
slots = FIFOSlots(self.filters, sle).generate()
|
||||
report_data = format_report_data(self.filters, slots, "2022-03-31")
|
||||
range_values = report_data[0][7:15]
|
||||
range_valuations = range_values[1::2]
|
||||
self.assertEqual(range_valuations, [15, 7.5, 20, 5])
|
||||
|
||||
|
||||
def generate_item_and_item_wh_wise_slots(filters, sle):
|
||||
"Return results with and without 'show_warehouse_wise_stock'"
|
||||
|
||||
@@ -173,7 +173,6 @@ 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)
|
||||
|
||||
@@ -1040,7 +1040,7 @@ class update_entries_after:
|
||||
|
||||
def get_dynamic_incoming_outgoing_rate(self, sle):
|
||||
# Get updated incoming/outgoing rate from transaction
|
||||
if sle.recalculate_rate:
|
||||
if sle.recalculate_rate or self.has_landed_cost_based_on_pi(sle):
|
||||
rate = self.get_incoming_outgoing_rate_from_transaction(sle)
|
||||
|
||||
if flt(sle.actual_qty) >= 0:
|
||||
@@ -1048,6 +1048,14 @@ 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
|
||||
|
||||
25
erpnext/templates/emails/anniversary_reminder.html
Normal file
25
erpnext/templates/emails/anniversary_reminder.html
Normal file
@@ -0,0 +1,25 @@
|
||||
<div class="gray-container text-center">
|
||||
<div>
|
||||
{% for person in anniversary_persons %}
|
||||
{% if person.image %}
|
||||
<img
|
||||
class="avatar-frame standard-image"
|
||||
src="{{ person.image }}"
|
||||
style="{{ css_style or '' }}"
|
||||
title="{{ person.name }}">
|
||||
</span>
|
||||
{% else %}
|
||||
<span
|
||||
class="avatar-frame standard-image"
|
||||
style="{{ css_style or '' }}"
|
||||
title="{{ person.name }}">
|
||||
{{ frappe.utils.get_abbr(person.name) }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div style="margin-top: 15px">
|
||||
<span>{{ reminder_text }}</span>
|
||||
<p class="text-muted">{{ message }}</p>
|
||||
</div>
|
||||
</div>
|
||||
16
erpnext/templates/emails/holiday_reminder.html
Normal file
16
erpnext/templates/emails/holiday_reminder.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<div>
|
||||
<span>{{ reminder_text }}</span>
|
||||
<p class="text-muted">{{ message }}</p>
|
||||
</div>
|
||||
|
||||
{% if advance_holiday_reminder %}
|
||||
{% if holidays | len > 0 %}
|
||||
<ol>
|
||||
{% for holiday in holidays %}
|
||||
<li>{{ frappe.format(holiday.holiday_date, 'Date') }} - {{ holiday.description }}</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
{% else %}
|
||||
<p>You don't have no upcoming holidays this {{ frequency }}.</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
Reference in New Issue
Block a user