Merge pull request #29454 from nextchamp-saqib/fix-dn-mapping
fix(sales order): incorrect no. of items fetched while creating dn
diff --git a/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py
index 5746a84..968137e 100644
--- a/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py
+++ b/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py
@@ -628,6 +628,26 @@
for doc in [si, si1]:
doc.delete()
+ def test_multiple_pricing_rules_with_min_qty(self):
+ make_pricing_rule(discount_percentage=20, selling=1, priority=1, min_qty=4,
+ apply_multiple_pricing_rules=1, title="_Test Pricing Rule with Min Qty - 1")
+ make_pricing_rule(discount_percentage=10, selling=1, priority=2, min_qty=4,
+ apply_multiple_pricing_rules=1, title="_Test Pricing Rule with Min Qty - 2")
+
+ si = create_sales_invoice(do_not_submit=True, customer="_Test Customer 1", qty=1, currency="USD")
+ item = si.items[0]
+ item.stock_qty = 1
+ si.save()
+ self.assertFalse(item.discount_percentage)
+ item.qty = 5
+ item.stock_qty = 5
+ si.save()
+ self.assertEqual(item.discount_percentage, 30)
+ si.delete()
+
+ frappe.delete_doc_if_exists("Pricing Rule", "_Test Pricing Rule with Min Qty - 1")
+ frappe.delete_doc_if_exists("Pricing Rule", "_Test Pricing Rule with Min Qty - 2")
+
test_dependencies = ["Campaign"]
def make_pricing_rule(**args):
diff --git a/erpnext/accounts/doctype/pricing_rule/utils.py b/erpnext/accounts/doctype/pricing_rule/utils.py
index 02bfc9d..7792590 100644
--- a/erpnext/accounts/doctype/pricing_rule/utils.py
+++ b/erpnext/accounts/doctype/pricing_rule/utils.py
@@ -73,7 +73,7 @@
for key in sorted(pricing_rule_dict):
pricing_rules_list.extend(pricing_rule_dict.get(key))
- return pricing_rules_list or pricing_rules
+ return pricing_rules_list
def filter_pricing_rule_based_on_condition(pricing_rules, doc=None):
filtered_pricing_rules = []
diff --git a/erpnext/accounts/doctype/sales_invoice/regional/india.js b/erpnext/accounts/doctype/sales_invoice/regional/india.js
index 6336db1..f54bce8 100644
--- a/erpnext/accounts/doctype/sales_invoice/regional/india.js
+++ b/erpnext/accounts/doctype/sales_invoice/regional/india.js
@@ -1,6 +1,8 @@
{% include "erpnext/regional/india/taxes.js" %}
+{% include "erpnext/regional/india/e_invoice/einvoice.js" %}
erpnext.setup_auto_gst_taxation('Sales Invoice');
+erpnext.setup_einvoice_actions('Sales Invoice')
frappe.ui.form.on("Sales Invoice", {
setup: function(frm) {
diff --git a/erpnext/accounts/doctype/sales_invoice/regional/india_list.js b/erpnext/accounts/doctype/sales_invoice/regional/india_list.js
index d9d6634..f01325d 100644
--- a/erpnext/accounts/doctype/sales_invoice/regional/india_list.js
+++ b/erpnext/accounts/doctype/sales_invoice/regional/india_list.js
@@ -36,4 +36,139 @@
};
list_view.page.add_actions_menu_item(__('Generate E-Way Bill JSON'), action, false);
+
+ const generate_irns = () => {
+ const docnames = list_view.get_checked_items(true);
+ if (docnames && docnames.length) {
+ frappe.call({
+ method: 'erpnext.regional.india.e_invoice.utils.generate_einvoices',
+ args: { docnames },
+ freeze: true,
+ freeze_message: __('Generating E-Invoices...')
+ });
+ } else {
+ frappe.msgprint({
+ message: __('Please select at least one sales invoice to generate IRN'),
+ title: __('No Invoice Selected'),
+ indicator: 'red'
+ });
+ }
+ };
+
+ const cancel_irns = () => {
+ const docnames = list_view.get_checked_items(true);
+
+ const fields = [
+ {
+ "label": "Reason",
+ "fieldname": "reason",
+ "fieldtype": "Select",
+ "reqd": 1,
+ "default": "1-Duplicate",
+ "options": ["1-Duplicate", "2-Data Entry Error", "3-Order Cancelled", "4-Other"]
+ },
+ {
+ "label": "Remark",
+ "fieldname": "remark",
+ "fieldtype": "Data",
+ "reqd": 1
+ }
+ ];
+
+ const d = new frappe.ui.Dialog({
+ title: __("Cancel IRN"),
+ fields: fields,
+ primary_action: function() {
+ const data = d.get_values();
+ frappe.call({
+ method: 'erpnext.regional.india.e_invoice.utils.cancel_irns',
+ args: {
+ doctype: list_view.doctype,
+ docnames,
+ reason: data.reason.split('-')[0],
+ remark: data.remark
+ },
+ freeze: true,
+ freeze_message: __('Cancelling E-Invoices...'),
+ });
+ d.hide();
+ },
+ primary_action_label: __('Submit')
+ });
+ d.show();
+ };
+
+ let einvoicing_enabled = false;
+ frappe.db.get_single_value("E Invoice Settings", "enable").then(enabled => {
+ einvoicing_enabled = enabled;
+ });
+
+ list_view.$result.on("change", "input[type=checkbox]", () => {
+ if (einvoicing_enabled) {
+ const docnames = list_view.get_checked_items(true);
+ // show/hide e-invoicing actions when no sales invoices are checked
+ if (docnames && docnames.length) {
+ // prevent adding actions twice if e-invoicing action group already exists
+ if (list_view.page.get_inner_group_button(__('E-Invoicing')).length == 0) {
+ list_view.page.add_inner_button(__('Generate IRNs'), generate_irns, __('E-Invoicing'));
+ list_view.page.add_inner_button(__('Cancel IRNs'), cancel_irns, __('E-Invoicing'));
+ }
+ } else {
+ list_view.page.remove_inner_button(__('Generate IRNs'), __('E-Invoicing'));
+ list_view.page.remove_inner_button(__('Cancel IRNs'), __('E-Invoicing'));
+ }
+ }
+ });
+
+ frappe.realtime.on("bulk_einvoice_generation_complete", (data) => {
+ const { failures, user, invoices } = data;
+
+ if (invoices.length != failures.length) {
+ frappe.msgprint({
+ message: __('{0} e-invoices generated successfully', [invoices.length]),
+ title: __('Bulk E-Invoice Generation Complete'),
+ indicator: 'orange'
+ });
+ }
+
+ if (failures && failures.length && user == frappe.session.user) {
+ let message = `
+ Failed to generate IRNs for following ${failures.length} sales invoices:
+ <ul style="padding-left: 20px; padding-top: 5px;">
+ ${failures.map(d => `<li>${d.docname}</li>`).join('')}
+ </ul>
+ `;
+ frappe.msgprint({
+ message: message,
+ title: __('Bulk E-Invoice Generation Complete'),
+ indicator: 'orange'
+ });
+ }
+ });
+
+ frappe.realtime.on("bulk_einvoice_cancellation_complete", (data) => {
+ const { failures, user, invoices } = data;
+
+ if (invoices.length != failures.length) {
+ frappe.msgprint({
+ message: __('{0} e-invoices cancelled successfully', [invoices.length]),
+ title: __('Bulk E-Invoice Cancellation Complete'),
+ indicator: 'orange'
+ });
+ }
+
+ if (failures && failures.length && user == frappe.session.user) {
+ let message = `
+ Failed to cancel IRNs for following ${failures.length} sales invoices:
+ <ul style="padding-left: 20px; padding-top: 5px;">
+ ${failures.map(d => `<li>${d.docname}</li>`).join('')}
+ </ul>
+ `;
+ frappe.msgprint({
+ message: message,
+ title: __('Bulk E-Invoice Cancellation Complete'),
+ indicator: 'orange'
+ });
+ }
+ });
};
diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js
index 39dfd8d..af6a52a 100644
--- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js
+++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js
@@ -469,7 +469,7 @@
let row = frappe.get_doc(d.doctype, d.name)
set_timesheet_detail_rate(row.doctype, row.name, me.frm.doc.currency, row.timesheet_detail)
});
- frm.trigger("calculate_timesheet_totals");
+ this.frm.trigger("calculate_timesheet_totals");
}
}
};
diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
index f04e7ea..bc44358 100644
--- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
+++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
@@ -294,6 +294,8 @@
def before_cancel(self):
self.check_if_consolidated_invoice()
+
+ super(SalesInvoice, self).before_cancel()
self.update_time_sheet(None)
def on_cancel(self):
diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
index 55e3853..941061f 100644
--- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
+++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
@@ -2100,6 +2100,54 @@
self.assertEqual(data['billLists'][0]['actualFromStateCode'],7)
self.assertEqual(data['billLists'][0]['fromStateCode'],27)
+ def test_einvoice_submission_without_irn(self):
+ # init
+ einvoice_settings = frappe.get_doc('E Invoice Settings')
+ einvoice_settings.enable = 1
+ einvoice_settings.applicable_from = nowdate()
+ einvoice_settings.append('credentials', {
+ 'company': '_Test Company',
+ 'gstin': '27AAECE4835E1ZR',
+ 'username': 'test',
+ 'password': 'test'
+ })
+ einvoice_settings.save()
+
+ country = frappe.flags.country
+ frappe.flags.country = 'India'
+
+ si = make_sales_invoice_for_ewaybill()
+ self.assertRaises(frappe.ValidationError, si.submit)
+
+ si.irn = 'test_irn'
+ si.submit()
+
+ # reset
+ einvoice_settings = frappe.get_doc('E Invoice Settings')
+ einvoice_settings.enable = 0
+ frappe.flags.country = country
+
+ def test_einvoice_json(self):
+ from erpnext.regional.india.e_invoice.utils import make_einvoice, validate_totals
+
+ si = get_sales_invoice_for_e_invoice()
+ si.discount_amount = 100
+ si.save()
+
+ einvoice = make_einvoice(si)
+ self.assertTrue(einvoice['EwbDtls'])
+ validate_totals(einvoice)
+
+ si.apply_discount_on = 'Net Total'
+ si.save()
+ einvoice = make_einvoice(si)
+ validate_totals(einvoice)
+
+ [d.set('included_in_print_rate', 1) for d in si.taxes]
+ si.save()
+ einvoice = make_einvoice(si)
+ validate_totals(einvoice)
+
def test_item_tax_net_range(self):
item = create_item("T Shirt")
diff --git a/erpnext/accounts/doctype/shipping_rule/shipping_rule.py b/erpnext/accounts/doctype/shipping_rule/shipping_rule.py
index 7e51299..792e7d2 100644
--- a/erpnext/accounts/doctype/shipping_rule/shipping_rule.py
+++ b/erpnext/accounts/doctype/shipping_rule/shipping_rule.py
@@ -71,7 +71,8 @@
if doc.currency != doc.company_currency:
shipping_amount = flt(shipping_amount / doc.conversion_rate, 2)
- self.add_shipping_rule_to_tax_table(doc, shipping_amount)
+ if shipping_amount:
+ self.add_shipping_rule_to_tax_table(doc, shipping_amount)
def get_shipping_amount_from_rules(self, value):
for condition in self.get("conditions"):
diff --git a/erpnext/patches/v14_0/__init__.py b/erpnext/accounts/print_format/gst_e_invoice/__init__.py
similarity index 100%
copy from erpnext/patches/v14_0/__init__.py
copy to erpnext/accounts/print_format/gst_e_invoice/__init__.py
diff --git a/erpnext/accounts/print_format/gst_e_invoice/gst_e_invoice.html b/erpnext/accounts/print_format/gst_e_invoice/gst_e_invoice.html
new file mode 100644
index 0000000..e658049
--- /dev/null
+++ b/erpnext/accounts/print_format/gst_e_invoice/gst_e_invoice.html
@@ -0,0 +1,173 @@
+{%- from "templates/print_formats/standard_macros.html" import add_header, render_field, print_value -%}
+{%- set einvoice = json.loads(doc.signed_einvoice) -%}
+
+<div class="page-break">
+ <div {% if print_settings.repeat_header_footer %} id="header-html" class="hidden-pdf" {% endif %}>
+ {% if letter_head and not no_letterhead %}
+ <div class="letter-head">{{ letter_head }}</div>
+ {% endif %}
+ <div class="print-heading">
+ <h2>E Invoice<br><small>{{ doc.name }}</small></h2>
+ </div>
+ </div>
+ {% if print_settings.repeat_header_footer %}
+ <div id="footer-html" class="visible-pdf">
+ {% if not no_letterhead and footer %}
+ <div class="letter-head-footer">
+ {{ footer }}
+ </div>
+ {% endif %}
+ <p class="text-center small page-number visible-pdf">
+ {{ _("Page {0} of {1}").format('<span class="page"></span>', '<span class="topage"></span>') }}
+ </p>
+ </div>
+ {% endif %}
+ <h5 class="font-bold" style="margin-top: 0px;">1. Transaction Details</h5>
+ <div class="row section-break" style="border-bottom: 1px solid #d1d8dd; padding-bottom: 10px;">
+ <div class="col-xs-8 column-break">
+ <div class="row data-field">
+ <div class="col-xs-4"><label>IRN</label></div>
+ <div class="col-xs-8 value">{{ einvoice.Irn }}</div>
+ </div>
+ <div class="row data-field">
+ <div class="col-xs-4"><label>Ack. No</label></div>
+ <div class="col-xs-8 value">{{ einvoice.AckNo }}</div>
+ </div>
+ <div class="row data-field">
+ <div class="col-xs-4"><label>Ack. Date</label></div>
+ <div class="col-xs-8 value">{{ frappe.utils.format_datetime(einvoice.AckDt, "dd/MM/yyyy hh:mm:ss") }}</div>
+ </div>
+ <div class="row data-field">
+ <div class="col-xs-4"><label>Category</label></div>
+ <div class="col-xs-8 value">{{ einvoice.TranDtls.SupTyp }}</div>
+ </div>
+ <div class="row data-field">
+ <div class="col-xs-4"><label>Document Type</label></div>
+ <div class="col-xs-8 value">{{ einvoice.DocDtls.Typ }}</div>
+ </div>
+ <div class="row data-field">
+ <div class="col-xs-4"><label>Document No</label></div>
+ <div class="col-xs-8 value">{{ einvoice.DocDtls.No }}</div>
+ </div>
+ </div>
+ <div class="col-xs-4 column-break">
+ <img src="{{ doc.qrcode_image }}" width="175px" style="float: right;">
+ </div>
+ </div>
+ <h5 class="font-bold" style="margin-top: 15px; margin-bottom: 10px;">2. Party Details</h5>
+ <div class="row section-break" style="border-bottom: 1px solid #d1d8dd; padding-bottom: 10px;">
+ {%- set seller = einvoice.SellerDtls -%}
+ <div class="col-xs-6 column-break">
+ <h5 style="margin-bottom: 5px;">Seller</h5>
+ <p>{{ seller.Gstin }}</p>
+ <p>{{ seller.LglNm }}</p>
+ <p>{{ seller.Addr1 }}</p>
+ {%- if seller.Addr2 -%} <p>{{ seller.Addr2 }}</p> {% endif %}
+ <p>{{ seller.Loc }}</p>
+ <p>{{ frappe.db.get_value("Address", doc.company_address, "gst_state") }} - {{ seller.Pin }}</p>
+
+ {%- if einvoice.ShipDtls -%}
+ {%- set shipping = einvoice.ShipDtls -%}
+ <h5 style="margin-bottom: 5px;">Shipped From</h5>
+ <p>{{ shipping.Gstin }}</p>
+ <p>{{ shipping.LglNm }}</p>
+ <p>{{ shipping.Addr1 }}</p>
+ {%- if shipping.Addr2 -%} <p>{{ shipping.Addr2 }}</p> {% endif %}
+ <p>{{ shipping.Loc }}</p>
+ <p>{{ frappe.db.get_value("Address", doc.shipping_address_name, "gst_state") }} - {{ shipping.Pin }}</p>
+ {% endif %}
+ </div>
+ {%- set buyer = einvoice.BuyerDtls -%}
+ <div class="col-xs-6 column-break">
+ <h5 style="margin-bottom: 5px;">Buyer</h5>
+ <p>{{ buyer.Gstin }}</p>
+ <p>{{ buyer.LglNm }}</p>
+ <p>{{ buyer.Addr1 }}</p>
+ {%- if buyer.Addr2 -%} <p>{{ buyer.Addr2 }}</p> {% endif %}
+ <p>{{ buyer.Loc }}</p>
+ <p>{{ frappe.db.get_value("Address", doc.customer_address, "gst_state") }} - {{ buyer.Pin }}</p>
+
+ {%- if einvoice.DispDtls -%}
+ {%- set dispatch = einvoice.DispDtls -%}
+ <h5 style="margin-bottom: 5px;">Dispatched From</h5>
+ {%- if dispatch.Gstin -%} <p>{{ dispatch.Gstin }}</p> {% endif %}
+ <p>{{ dispatch.LglNm }}</p>
+ <p>{{ dispatch.Addr1 }}</p>
+ {%- if dispatch.Addr2 -%} <p>{{ dispatch.Addr2 }}</p> {% endif %}
+ <p>{{ dispatch.Loc }}</p>
+ <p>{{ frappe.db.get_value("Address", doc.dispatch_address_name, "gst_state") }} - {{ dispatch.Pin }}</p>
+ {% endif %}
+ </div>
+ </div>
+ <div style="overflow-x: auto;">
+ <h5 class="font-bold" style="margin-top: 15px; margin-bottom: 10px;">3. Item Details</h5>
+ <table class="table table-bordered">
+ <thead>
+ <tr>
+ <th class="text-left" style="width: 3%;">Sr. No.</th>
+ <th class="text-left">Item</th>
+ <th class="text-left" style="width: 10%;">HSN Code</th>
+ <th class="text-left" style="width: 5%;">Qty</th>
+ <th class="text-left" style="width: 5%;">UOM</th>
+ <th class="text-left">Rate</th>
+ <th class="text-left" style="width: 5%;">Discount</th>
+ <th class="text-left">Taxable Amount</th>
+ <th class="text-left" style="width: 7%;">Tax Rate</th>
+ <th class="text-left" style="width: 5%;">Other Charges</th>
+ <th class="text-left">Total</th>
+ </tr>
+ </thead>
+ <tbody>
+ {% for item in einvoice.ItemList %}
+ <tr>
+ <td class="text-left" style="width: 3%;">{{ item.SlNo }}</td>
+ <td class="text-left">{{ item.PrdDesc }}</td>
+ <td class="text-left" style="width: 10%;">{{ item.HsnCd }}</td>
+ <td class="text-right" style="width: 5%;">{{ item.Qty }}</td>
+ <td class="text-left" style="width: 5%;">{{ item.Unit }}</td>
+ <td class="text-right">{{ frappe.utils.fmt_money(item.UnitPrice, None, "INR") }}</td>
+ <td class="text-right" style="width: 5%;">{{ frappe.utils.fmt_money(item.Discount, None, "INR") }}</td>
+ <td class="text-right">{{ frappe.utils.fmt_money(item.AssAmt, None, "INR") }}</td>
+ <td class="text-right" style="width: 7%;">{{ item.GstRt + item.CesRt }} %</td>
+ <td class="text-right" style="width: 5%;">{{ frappe.utils.fmt_money(0, None, "INR") }}</td>
+ <td class="text-right">{{ frappe.utils.fmt_money(item.TotItemVal, None, "INR") }}</td>
+ </tr>
+ {% endfor %}
+ </tbody>
+ </table>
+ </div>
+ <div style="overflow-x: auto;">
+ <h5 class="font-bold" style="margin-bottom: 0px;">4. Value Details</h5>
+ <table class="table table-bordered">
+ <thead>
+ <tr>
+ <th class="text-left">Taxable Amount</th>
+ <th class="text-left">CGST</th>
+ <th class="text-left"">SGST</th>
+ <th class="text-left">IGST</th>
+ <th class="text-left">CESS</th>
+ <th class="text-left" style="width: 10%;">State CESS</th>
+ <th class="text-left">Discount</th>
+ <th class="text-left" style="width: 10%;">Other Charges</th>
+ <th class="text-left" style="width: 10%;">Round Off</th>
+ <th class="text-left">Total Value</th>
+ </tr>
+ </thead>
+ <tbody>
+ {%- set value_details = einvoice.ValDtls -%}
+ <tr>
+ <td class="text-right">{{ frappe.utils.fmt_money(value_details.AssVal, None, "INR") }}</td>
+ <td class="text-right">{{ frappe.utils.fmt_money(value_details.CgstVal, None, "INR") }}</td>
+ <td class="text-right">{{ frappe.utils.fmt_money(value_details.SgstVal, None, "INR") }}</td>
+ <td class="text-right">{{ frappe.utils.fmt_money(value_details.IgstVal, None, "INR") }}</td>
+ <td class="text-right">{{ frappe.utils.fmt_money(value_details.CesVal, None, "INR") }}</td>
+ <td class="text-right">{{ frappe.utils.fmt_money(0, None, "INR") }}</td>
+ <td class="text-right">{{ frappe.utils.fmt_money(value_details.Discount, None, "INR") }}</td>
+ <td class="text-right">{{ frappe.utils.fmt_money(value_details.OthChrg, None, "INR") }}</td>
+ <td class="text-right">{{ frappe.utils.fmt_money(value_details.RndOffAmt, None, "INR") }}</td>
+ <td class="text-right">{{ frappe.utils.fmt_money(value_details.TotInvVal, None, "INR") }}</td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+</div>
diff --git a/erpnext/accounts/print_format/gst_e_invoice/gst_e_invoice.json b/erpnext/accounts/print_format/gst_e_invoice/gst_e_invoice.json
new file mode 100644
index 0000000..1001199
--- /dev/null
+++ b/erpnext/accounts/print_format/gst_e_invoice/gst_e_invoice.json
@@ -0,0 +1,24 @@
+{
+ "align_labels_right": 1,
+ "creation": "2020-10-10 18:01:21.032914",
+ "custom_format": 0,
+ "default_print_language": "en-US",
+ "disabled": 1,
+ "doc_type": "Sales Invoice",
+ "docstatus": 0,
+ "doctype": "Print Format",
+ "font": "Default",
+ "html": "",
+ "idx": 0,
+ "line_breaks": 1,
+ "modified": "2020-10-23 19:54:40.634936",
+ "modified_by": "Administrator",
+ "module": "Accounts",
+ "name": "GST E-Invoice",
+ "owner": "Administrator",
+ "print_format_builder": 0,
+ "print_format_type": "Jinja",
+ "raw_printing": 0,
+ "show_section_headings": 1,
+ "standard": "Yes"
+}
\ No newline at end of file
diff --git a/erpnext/assets/doctype/asset/asset.js b/erpnext/assets/doctype/asset/asset.js
index 153f5c5..f414930 100644
--- a/erpnext/assets/doctype/asset/asset.js
+++ b/erpnext/assets/doctype/asset/asset.js
@@ -108,6 +108,10 @@
frm.trigger("create_asset_repair");
}, __("Manage"));
+ frm.add_custom_button(__("Split Asset"), function() {
+ frm.trigger("split_asset");
+ }, __("Manage"));
+
if (frm.doc.status != 'Fully Depreciated') {
frm.add_custom_button(__("Adjust Asset Value"), function() {
frm.trigger("create_asset_value_adjustment");
@@ -322,6 +326,43 @@
});
},
+ split_asset: function(frm) {
+ const title = __('Split Asset');
+
+ const fields = [
+ {
+ fieldname: 'split_qty',
+ fieldtype: 'Int',
+ label: __('Split Qty'),
+ reqd: 1
+ }
+ ];
+
+ let dialog = new frappe.ui.Dialog({
+ title: title,
+ fields: fields
+ });
+
+ dialog.set_primary_action(__('Split'), function() {
+ const dialog_data = dialog.get_values();
+ frappe.call({
+ args: {
+ "asset_name": frm.doc.name,
+ "split_qty": cint(dialog_data.split_qty)
+ },
+ method: "erpnext.assets.doctype.asset.asset.split_asset",
+ callback: function(r) {
+ let doclist = frappe.model.sync(r.message);
+ frappe.set_route("Form", doclist[0].doctype, doclist[0].name);
+ }
+ });
+
+ dialog.hide();
+ });
+
+ dialog.show();
+ },
+
create_asset_value_adjustment: function(frm) {
frappe.call({
args: {
diff --git a/erpnext/assets/doctype/asset/asset.json b/erpnext/assets/doctype/asset/asset.json
index 0a7c041..6e6bbf1 100644
--- a/erpnext/assets/doctype/asset/asset.json
+++ b/erpnext/assets/doctype/asset/asset.json
@@ -3,7 +3,7 @@
"allow_import": 1,
"allow_rename": 1,
"autoname": "naming_series:",
- "creation": "2016-03-01 17:01:27.920130",
+ "creation": "2022-01-18 02:26:55.975005",
"doctype": "DocType",
"document_type": "Document",
"engine": "InnoDB",
@@ -23,6 +23,7 @@
"asset_name",
"asset_category",
"location",
+ "split_from",
"custodian",
"department",
"disposal_date",
@@ -142,6 +143,7 @@
},
{
"allow_on_submit": 1,
+ "fetch_from": "item_code.image",
"fieldname": "image",
"fieldtype": "Attach Image",
"hidden": 1,
@@ -483,6 +485,13 @@
"label": "Finance Books"
},
{
+ "fieldname": "split_from",
+ "fieldtype": "Link",
+ "label": "Split From",
+ "options": "Asset",
+ "read_only": 1
+ },
+ {
"fieldname": "asset_quantity",
"fieldtype": "Int",
"label": "Asset Quantity",
@@ -509,7 +518,7 @@
"link_fieldname": "asset"
}
],
- "modified": "2022-01-18 12:57:36.741192",
+ "modified": "2022-01-30 20:19:24.680027",
"modified_by": "Administrator",
"module": "Assets",
"name": "Asset",
diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py
index ac64a95..6e87426 100644
--- a/erpnext/assets/doctype/asset/asset.py
+++ b/erpnext/assets/doctype/asset/asset.py
@@ -38,7 +38,8 @@
self.validate_item()
self.validate_cost_center()
self.set_missing_values()
- self.prepare_depreciation_data()
+ if not self.split_from:
+ self.prepare_depreciation_data()
self.validate_gross_and_purchase_amount()
if self.get("schedules"):
self.validate_expected_value_after_useful_life()
@@ -202,143 +203,143 @@
start = self.clear_depreciation_schedule()
for finance_book in self.get('finance_books'):
- self.validate_asset_finance_books(finance_book)
+ self._make_depreciation_schedule(finance_book, start, date_of_sale)
- # value_after_depreciation - current Asset value
- if self.docstatus == 1 and finance_book.value_after_depreciation:
- value_after_depreciation = flt(finance_book.value_after_depreciation)
- else:
- value_after_depreciation = (flt(self.gross_purchase_amount) -
- flt(self.opening_accumulated_depreciation))
+ def _make_depreciation_schedule(self, finance_book, start, date_of_sale):
+ self.validate_asset_finance_books(finance_book)
- finance_book.value_after_depreciation = value_after_depreciation
+ value_after_depreciation = self._get_value_after_depreciation(finance_book)
+ finance_book.value_after_depreciation = value_after_depreciation
- number_of_pending_depreciations = cint(finance_book.total_number_of_depreciations) - \
- cint(self.number_of_depreciations_booked)
+ number_of_pending_depreciations = cint(finance_book.total_number_of_depreciations) - \
+ cint(self.number_of_depreciations_booked)
- has_pro_rata = self.check_is_pro_rata(finance_book)
+ has_pro_rata = self.check_is_pro_rata(finance_book)
+ if has_pro_rata:
+ number_of_pending_depreciations += 1
- if has_pro_rata:
- number_of_pending_depreciations += 1
+ skip_row = False
- skip_row = False
+ for n in range(start[finance_book.idx-1], number_of_pending_depreciations):
+ # If depreciation is already completed (for double declining balance)
+ if skip_row: continue
- for n in range(start[finance_book.idx-1], number_of_pending_depreciations):
- # If depreciation is already completed (for double declining balance)
- if skip_row: continue
+ depreciation_amount = get_depreciation_amount(self, value_after_depreciation, finance_book)
- depreciation_amount = get_depreciation_amount(self, value_after_depreciation, finance_book)
+ if not has_pro_rata or n < cint(number_of_pending_depreciations) - 1:
+ schedule_date = add_months(finance_book.depreciation_start_date,
+ n * cint(finance_book.frequency_of_depreciation))
- if not has_pro_rata or n < cint(number_of_pending_depreciations) - 1:
- schedule_date = add_months(finance_book.depreciation_start_date,
- n * cint(finance_book.frequency_of_depreciation))
+ # schedule date will be a year later from start date
+ # so monthly schedule date is calculated by removing 11 months from it
+ monthly_schedule_date = add_months(schedule_date, - finance_book.frequency_of_depreciation + 1)
- # schedule date will be a year later from start date
- # so monthly schedule date is calculated by removing 11 months from it
- monthly_schedule_date = add_months(schedule_date, - finance_book.frequency_of_depreciation + 1)
-
- # if asset is being sold
- if date_of_sale:
- from_date = self.get_from_date(finance_book.finance_book)
- depreciation_amount, days, months = self.get_pro_rata_amt(finance_book, depreciation_amount,
- from_date, date_of_sale)
-
- if depreciation_amount > 0:
- self.append("schedules", {
- "schedule_date": date_of_sale,
- "depreciation_amount": depreciation_amount,
- "depreciation_method": finance_book.depreciation_method,
- "finance_book": finance_book.finance_book,
- "finance_book_id": finance_book.idx
- })
-
- break
-
- # For first row
- if has_pro_rata and not self.opening_accumulated_depreciation and n==0:
- from_date = add_days(self.available_for_use_date, -1) # needed to calc depr amount for available_for_use_date too
- depreciation_amount, days, months = self.get_pro_rata_amt(finance_book, depreciation_amount,
- from_date, finance_book.depreciation_start_date)
-
- # For first depr schedule date will be the start date
- # so monthly schedule date is calculated by removing month difference between use date and start date
- monthly_schedule_date = add_months(finance_book.depreciation_start_date, - months + 1)
-
- # For last row
- elif has_pro_rata and n == cint(number_of_pending_depreciations) - 1:
- if not self.flags.increase_in_asset_life:
- # In case of increase_in_asset_life, the self.to_date is already set on asset_repair submission
- self.to_date = add_months(self.available_for_use_date,
- (n + self.number_of_depreciations_booked) * cint(finance_book.frequency_of_depreciation))
-
- depreciation_amount_without_pro_rata = depreciation_amount
-
- depreciation_amount, days, months = self.get_pro_rata_amt(finance_book,
- depreciation_amount, schedule_date, self.to_date)
-
- depreciation_amount = self.get_adjusted_depreciation_amount(depreciation_amount_without_pro_rata,
- depreciation_amount, finance_book.finance_book)
-
- monthly_schedule_date = add_months(schedule_date, 1)
- schedule_date = add_days(schedule_date, days)
- last_schedule_date = schedule_date
-
- if not depreciation_amount: continue
- value_after_depreciation -= flt(depreciation_amount,
- self.precision("gross_purchase_amount"))
-
- # Adjust depreciation amount in the last period based on the expected value after useful life
- if finance_book.expected_value_after_useful_life and ((n == cint(number_of_pending_depreciations) - 1
- and value_after_depreciation != finance_book.expected_value_after_useful_life)
- or value_after_depreciation < finance_book.expected_value_after_useful_life):
- depreciation_amount += (value_after_depreciation - finance_book.expected_value_after_useful_life)
- skip_row = True
+ # if asset is being sold
+ if date_of_sale:
+ from_date = self.get_from_date(finance_book.finance_book)
+ depreciation_amount, days, months = self.get_pro_rata_amt(finance_book, depreciation_amount,
+ from_date, date_of_sale)
if depreciation_amount > 0:
- # With monthly depreciation, each depreciation is divided by months remaining until next date
- if self.allow_monthly_depreciation:
- # month range is 1 to 12
- # In pro rata case, for first and last depreciation, month range would be different
- month_range = months \
- if (has_pro_rata and n==0) or (has_pro_rata and n == cint(number_of_pending_depreciations) - 1) \
- else finance_book.frequency_of_depreciation
+ self._add_depreciation_row(date_of_sale, depreciation_amount, finance_book.depreciation_method,
+ finance_book.finance_book, finance_book.idx)
- for r in range(month_range):
- if (has_pro_rata and n == 0):
- # For first entry of monthly depr
- if r == 0:
- days_until_first_depr = date_diff(monthly_schedule_date, self.available_for_use_date)
- per_day_amt = depreciation_amount / days
- depreciation_amount_for_current_month = per_day_amt * days_until_first_depr
- depreciation_amount -= depreciation_amount_for_current_month
- date = monthly_schedule_date
- amount = depreciation_amount_for_current_month
- else:
- date = add_months(monthly_schedule_date, r)
- amount = depreciation_amount / (month_range - 1)
- elif (has_pro_rata and n == cint(number_of_pending_depreciations) - 1) and r == cint(month_range) - 1:
- # For last entry of monthly depr
- date = last_schedule_date
- amount = depreciation_amount / month_range
+ break
+
+ # For first row
+ if has_pro_rata and not self.opening_accumulated_depreciation and n==0:
+ from_date = add_days(self.available_for_use_date, -1) # needed to calc depr amount for available_for_use_date too
+ depreciation_amount, days, months = self.get_pro_rata_amt(finance_book, depreciation_amount,
+ from_date, finance_book.depreciation_start_date)
+
+ # For first depr schedule date will be the start date
+ # so monthly schedule date is calculated by removing month difference between use date and start date
+ monthly_schedule_date = add_months(finance_book.depreciation_start_date, - months + 1)
+
+ # For last row
+ elif has_pro_rata and n == cint(number_of_pending_depreciations) - 1:
+ if not self.flags.increase_in_asset_life:
+ # In case of increase_in_asset_life, the self.to_date is already set on asset_repair submission
+ self.to_date = add_months(self.available_for_use_date,
+ (n + self.number_of_depreciations_booked) * cint(finance_book.frequency_of_depreciation))
+
+ depreciation_amount_without_pro_rata = depreciation_amount
+
+ depreciation_amount, days, months = self.get_pro_rata_amt(finance_book,
+ depreciation_amount, schedule_date, self.to_date)
+
+ depreciation_amount = self.get_adjusted_depreciation_amount(depreciation_amount_without_pro_rata,
+ depreciation_amount, finance_book.finance_book)
+
+ monthly_schedule_date = add_months(schedule_date, 1)
+ schedule_date = add_days(schedule_date, days)
+ last_schedule_date = schedule_date
+
+ if not depreciation_amount: continue
+ value_after_depreciation -= flt(depreciation_amount,
+ self.precision("gross_purchase_amount"))
+
+ # Adjust depreciation amount in the last period based on the expected value after useful life
+ if finance_book.expected_value_after_useful_life and ((n == cint(number_of_pending_depreciations) - 1
+ and value_after_depreciation != finance_book.expected_value_after_useful_life)
+ or value_after_depreciation < finance_book.expected_value_after_useful_life):
+ depreciation_amount += (value_after_depreciation - finance_book.expected_value_after_useful_life)
+ skip_row = True
+
+ if depreciation_amount > 0:
+ # With monthly depreciation, each depreciation is divided by months remaining until next date
+ if self.allow_monthly_depreciation:
+ # month range is 1 to 12
+ # In pro rata case, for first and last depreciation, month range would be different
+ month_range = months \
+ if (has_pro_rata and n==0) or (has_pro_rata and n == cint(number_of_pending_depreciations) - 1) \
+ else finance_book.frequency_of_depreciation
+
+ for r in range(month_range):
+ if (has_pro_rata and n == 0):
+ # For first entry of monthly depr
+ if r == 0:
+ days_until_first_depr = date_diff(monthly_schedule_date, self.available_for_use_date)
+ per_day_amt = depreciation_amount / days
+ depreciation_amount_for_current_month = per_day_amt * days_until_first_depr
+ depreciation_amount -= depreciation_amount_for_current_month
+ date = monthly_schedule_date
+ amount = depreciation_amount_for_current_month
else:
date = add_months(monthly_schedule_date, r)
- amount = depreciation_amount / month_range
+ amount = depreciation_amount / (month_range - 1)
+ elif (has_pro_rata and n == cint(number_of_pending_depreciations) - 1) and r == cint(month_range) - 1:
+ # For last entry of monthly depr
+ date = last_schedule_date
+ amount = depreciation_amount / month_range
+ else:
+ date = add_months(monthly_schedule_date, r)
+ amount = depreciation_amount / month_range
- self.append("schedules", {
- "schedule_date": date,
- "depreciation_amount": amount,
- "depreciation_method": finance_book.depreciation_method,
- "finance_book": finance_book.finance_book,
- "finance_book_id": finance_book.idx
- })
- else:
- self.append("schedules", {
- "schedule_date": schedule_date,
- "depreciation_amount": depreciation_amount,
- "depreciation_method": finance_book.depreciation_method,
- "finance_book": finance_book.finance_book,
- "finance_book_id": finance_book.idx
- })
+ self._add_depreciation_row(date, amount, finance_book.depreciation_method,
+ finance_book.finance_book, finance_book.idx)
+ else:
+ self._add_depreciation_row(schedule_date, depreciation_amount, finance_book.depreciation_method,
+ finance_book.finance_book, finance_book.idx)
+
+ def _add_depreciation_row(self, schedule_date, depreciation_amount, depreciation_method, finance_book, finance_book_id):
+ self.append("schedules", {
+ "schedule_date": schedule_date,
+ "depreciation_amount": depreciation_amount,
+ "depreciation_method": depreciation_method,
+ "finance_book": finance_book,
+ "finance_book_id": finance_book_id
+ })
+
+ def _get_value_after_depreciation(self, finance_book):
+ # value_after_depreciation - current Asset value
+ if self.docstatus == 1 and finance_book.value_after_depreciation:
+ value_after_depreciation = flt(finance_book.value_after_depreciation)
+ else:
+ value_after_depreciation = (flt(self.gross_purchase_amount) -
+ flt(self.opening_accumulated_depreciation))
+
+ return value_after_depreciation
# depreciation schedules need to be cleared before modification due to increase in asset life/asset sales
# JE: Journal Entry, FB: Finance Book
@@ -348,7 +349,6 @@
depr_schedule = []
for schedule in self.get('schedules'):
-
# to update start when there are JEs linked with all the schedule rows corresponding to an FB
if len(start) == (int(schedule.finance_book_id) - 2):
start.append(num_of_depreciations_completed)
@@ -924,3 +924,113 @@
depreciation_amount = flt(depreciable_value * (flt(row.rate_of_depreciation) / 100))
return depreciation_amount
+
+@frappe.whitelist()
+def split_asset(asset_name, split_qty):
+ asset = frappe.get_doc("Asset", asset_name)
+ split_qty = cint(split_qty)
+
+ if split_qty >= asset.asset_quantity:
+ frappe.throw(_("Split qty cannot be grater than or equal to asset qty"))
+
+ remaining_qty = asset.asset_quantity - split_qty
+
+ new_asset = create_new_asset_after_split(asset, split_qty)
+ update_existing_asset(asset, remaining_qty)
+
+ return new_asset
+
+def update_existing_asset(asset, remaining_qty):
+ remaining_gross_purchase_amount = flt((asset.gross_purchase_amount * remaining_qty) / asset.asset_quantity)
+ opening_accumulated_depreciation = flt((asset.opening_accumulated_depreciation * remaining_qty) / asset.asset_quantity)
+
+ frappe.db.set_value("Asset", asset.name, {
+ 'opening_accumulated_depreciation': opening_accumulated_depreciation,
+ 'gross_purchase_amount': remaining_gross_purchase_amount,
+ 'asset_quantity': remaining_qty
+ })
+
+ for finance_book in asset.get('finance_books'):
+ value_after_depreciation = flt((finance_book.value_after_depreciation * remaining_qty)/asset.asset_quantity)
+ expected_value_after_useful_life = flt((finance_book.expected_value_after_useful_life * remaining_qty)/asset.asset_quantity)
+ frappe.db.set_value('Asset Finance Book', finance_book.name, 'value_after_depreciation', value_after_depreciation)
+ frappe.db.set_value('Asset Finance Book', finance_book.name, 'expected_value_after_useful_life', expected_value_after_useful_life)
+
+ accumulated_depreciation = 0
+
+ for term in asset.get('schedules'):
+ depreciation_amount = flt((term.depreciation_amount * remaining_qty)/asset.asset_quantity)
+ frappe.db.set_value('Depreciation Schedule', term.name, 'depreciation_amount', depreciation_amount)
+ accumulated_depreciation += depreciation_amount
+ frappe.db.set_value('Depreciation Schedule', term.name, 'accumulated_depreciation_amount', accumulated_depreciation)
+
+def create_new_asset_after_split(asset, split_qty):
+ new_asset = frappe.copy_doc(asset)
+ new_gross_purchase_amount = flt((asset.gross_purchase_amount * split_qty) / asset.asset_quantity)
+ opening_accumulated_depreciation = flt((asset.opening_accumulated_depreciation * split_qty) / asset.asset_quantity)
+
+ new_asset.gross_purchase_amount = new_gross_purchase_amount
+ new_asset.opening_accumulated_depreciation = opening_accumulated_depreciation
+ new_asset.asset_quantity = split_qty
+ new_asset.split_from = asset.name
+ accumulated_depreciation = 0
+
+ for finance_book in new_asset.get('finance_books'):
+ finance_book.value_after_depreciation = flt((finance_book.value_after_depreciation * split_qty)/asset.asset_quantity)
+ finance_book.expected_value_after_useful_life = flt((finance_book.expected_value_after_useful_life * split_qty)/asset.asset_quantity)
+
+ for term in new_asset.get('schedules'):
+ depreciation_amount = flt((term.depreciation_amount * split_qty)/asset.asset_quantity)
+ term.depreciation_amount = depreciation_amount
+ accumulated_depreciation += depreciation_amount
+ term.accumulated_depreciation_amount = accumulated_depreciation
+
+ new_asset.submit()
+ new_asset.set_status()
+
+ for term in new_asset.get('schedules'):
+ # Update references in JV
+ if term.journal_entry:
+ add_reference_in_jv_on_split(term.journal_entry, new_asset.name, asset.name, term.depreciation_amount)
+
+ return new_asset
+
+def add_reference_in_jv_on_split(entry_name, new_asset_name, old_asset_name, depreciation_amount):
+ journal_entry = frappe.get_doc('Journal Entry', entry_name)
+ entries_to_add = []
+ idx = len(journal_entry.get('accounts')) + 1
+
+ for account in journal_entry.get('accounts'):
+ if account.reference_name == old_asset_name:
+ entries_to_add.append(frappe.copy_doc(account).as_dict())
+ if account.credit:
+ account.credit = account.credit - depreciation_amount
+ account.credit_in_account_currency = account.credit_in_account_currency - \
+ account.exchange_rate * depreciation_amount
+ elif account.debit:
+ account.debit = account.debit - depreciation_amount
+ account.debit_in_account_currency = account.debit_in_account_currency - \
+ account.exchange_rate * depreciation_amount
+
+ for entry in entries_to_add:
+ entry.reference_name = new_asset_name
+ if entry.credit:
+ entry.credit = depreciation_amount
+ entry.credit_in_account_currency = entry.exchange_rate * depreciation_amount
+ elif entry.debit:
+ entry.debit = depreciation_amount
+ entry.debit_in_account_currency = entry.exchange_rate * depreciation_amount
+
+ entry.idx = idx
+ idx += 1
+
+ journal_entry.append('accounts', entry)
+
+ journal_entry.flags.ignore_validate_update_after_submit = True
+ journal_entry.save()
+
+ # Repost GL Entries
+ journal_entry.docstatus = 2
+ journal_entry.make_gl_entries(1)
+ journal_entry.docstatus = 1
+ journal_entry.make_gl_entries()
\ No newline at end of file
diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py
index b9545f4..c08dc21 100644
--- a/erpnext/assets/doctype/asset/test_asset.py
+++ b/erpnext/assets/doctype/asset/test_asset.py
@@ -7,7 +7,7 @@
from frappe.utils import add_days, add_months, cstr, flt, get_last_day, getdate, nowdate
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
-from erpnext.assets.doctype.asset.asset import make_sales_invoice
+from erpnext.assets.doctype.asset.asset import make_sales_invoice, split_asset
from erpnext.assets.doctype.asset.depreciation import (
post_depreciation_entries,
restore_asset,
@@ -245,6 +245,57 @@
si.cancel()
self.assertEqual(frappe.db.get_value("Asset", asset.name, "status"), "Partially Depreciated")
+ def test_asset_splitting(self):
+ asset = create_asset(
+ calculate_depreciation = 1,
+ asset_quantity=10,
+ available_for_use_date = '2020-01-01',
+ purchase_date = '2020-01-01',
+ expected_value_after_useful_life = 0,
+ total_number_of_depreciations = 6,
+ number_of_depreciations_booked = 1,
+ frequency_of_depreciation = 10,
+ depreciation_start_date = '2021-01-01',
+ opening_accumulated_depreciation=20000,
+ gross_purchase_amount=120000,
+ submit = 1
+ )
+
+ post_depreciation_entries(date="2021-01-01")
+
+ self.assertEqual(asset.asset_quantity, 10)
+ self.assertEqual(asset.gross_purchase_amount, 120000)
+ self.assertEqual(asset.opening_accumulated_depreciation, 20000)
+
+ new_asset = split_asset(asset.name, 2)
+ asset.load_from_db()
+
+ self.assertEqual(new_asset.asset_quantity, 2)
+ self.assertEqual(new_asset.gross_purchase_amount, 24000)
+ self.assertEqual(new_asset.opening_accumulated_depreciation, 4000)
+ self.assertEqual(new_asset.split_from, asset.name)
+ self.assertEqual(new_asset.schedules[0].depreciation_amount, 4000)
+ self.assertEqual(new_asset.schedules[1].depreciation_amount, 4000)
+
+ self.assertEqual(asset.asset_quantity, 8)
+ self.assertEqual(asset.gross_purchase_amount, 96000)
+ self.assertEqual(asset.opening_accumulated_depreciation, 16000)
+ self.assertEqual(asset.schedules[0].depreciation_amount, 16000)
+ self.assertEqual(asset.schedules[1].depreciation_amount, 16000)
+
+ journal_entry = asset.schedules[0].journal_entry
+
+ jv = frappe.get_doc('Journal Entry', journal_entry)
+ self.assertEqual(jv.accounts[0].credit_in_account_currency, 16000)
+ self.assertEqual(jv.accounts[1].debit_in_account_currency, 16000)
+ self.assertEqual(jv.accounts[2].credit_in_account_currency, 4000)
+ self.assertEqual(jv.accounts[3].debit_in_account_currency, 4000)
+
+ self.assertEqual(jv.accounts[0].reference_name, asset.name)
+ self.assertEqual(jv.accounts[1].reference_name, asset.name)
+ self.assertEqual(jv.accounts[2].reference_name, new_asset.name)
+ self.assertEqual(jv.accounts[3].reference_name, new_asset.name)
+
def test_expense_head(self):
pr = make_purchase_receipt(item_code="Macbook Pro",
qty=2, rate=200000.0, location="Test Location")
@@ -1197,7 +1248,8 @@
"available_for_use_date": args.available_for_use_date or "2020-06-06",
"location": args.location or "Test Location",
"asset_owner": args.asset_owner or "Company",
- "is_existing_asset": args.is_existing_asset or 1
+ "is_existing_asset": args.is_existing_asset or 1,
+ "asset_quantity": args.get("asset_quantity") or 1
})
if asset.calculate_depreciation:
diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py
index 4775f56..bab16a4 100644
--- a/erpnext/controllers/accounts_controller.py
+++ b/erpnext/controllers/accounts_controller.py
@@ -167,9 +167,14 @@
validate_regional(self)
+ validate_einvoice_fields(self)
+
if self.doctype != 'Material Request':
apply_pricing_rule_on_transaction(self)
+ def before_cancel(self):
+ validate_einvoice_fields(self)
+
def on_trash(self):
# delete sl and gl entries on deletion of transaction
if frappe.db.get_single_value('Accounts Settings', 'delete_linked_ledger_entries'):
@@ -2151,3 +2156,7 @@
@erpnext.allow_regional
def validate_regional(doc):
pass
+
+@erpnext.allow_regional
+def validate_einvoice_fields(doc):
+ pass
diff --git a/erpnext/controllers/employee_boarding_controller.py b/erpnext/controllers/employee_boarding_controller.py
index b8dc92e..ae2c737 100644
--- a/erpnext/controllers/employee_boarding_controller.py
+++ b/erpnext/controllers/employee_boarding_controller.py
@@ -132,13 +132,17 @@
def on_cancel(self):
# delete task project
- for task in frappe.get_all('Task', filters={'project': self.project}):
+ project = self.project
+ for task in frappe.get_all('Task', filters={'project': project}):
frappe.delete_doc('Task', task.name, force=1)
- frappe.delete_doc('Project', self.project, force=1)
+ frappe.delete_doc('Project', project, force=1)
self.db_set('project', '')
for activity in self.activities:
activity.db_set('task', '')
+ frappe.msgprint(_('Linked Project {} and Tasks deleted.').format(
+ project), alert=True, indicator='blue')
+
@frappe.whitelist()
def get_onboarding_details(parent, parenttype):
diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py
index b97432e..2912d3e 100644
--- a/erpnext/controllers/stock_controller.py
+++ b/erpnext/controllers/stock_controller.py
@@ -77,17 +77,17 @@
.format(d.idx, get_link_to_form("Batch", d.get("batch_no"))))
def clean_serial_nos(self):
+ from erpnext.stock.doctype.serial_no.serial_no import clean_serial_no_string
+
for row in self.get("items"):
if hasattr(row, "serial_no") and row.serial_no:
- # replace commas by linefeed
- row.serial_no = row.serial_no.replace(",", "\n")
+ # remove extra whitespace and store one serial no on each line
+ row.serial_no = clean_serial_no_string(row.serial_no)
- # strip preceeding and succeeding spaces for each SN
- # (SN could have valid spaces in between e.g. SN - 123 - 2021)
- serial_no_list = row.serial_no.split("\n")
- serial_no_list = [sn.strip() for sn in serial_no_list]
-
- row.serial_no = "\n".join(serial_no_list)
+ for row in self.get('packed_items') or []:
+ if hasattr(row, "serial_no") and row.serial_no:
+ # remove extra whitespace and store one serial no on each line
+ row.serial_no = clean_serial_no_string(row.serial_no)
def get_gl_entries(self, warehouse_account=None, default_expense_account=None,
default_cost_center=None):
diff --git a/erpnext/crm/doctype/campaign/campaign.js b/erpnext/crm/doctype/campaign/campaign.js
index 11bfa74..cac45c6 100644
--- a/erpnext/crm/doctype/campaign/campaign.js
+++ b/erpnext/crm/doctype/campaign/campaign.js
@@ -5,7 +5,7 @@
refresh: function(frm) {
erpnext.toggle_naming_series();
- if (frm.doc.__islocal) {
+ if (frm.is_new()) {
frm.toggle_display("naming_series", frappe.boot.sysdefaults.campaign_naming_by=="Naming Series");
} else {
cur_frm.add_custom_button(__("View Leads"), function() {
diff --git a/erpnext/crm/doctype/crm_settings/crm_settings.py b/erpnext/crm/doctype/crm_settings/crm_settings.py
index bde5254..98cf7d8 100644
--- a/erpnext/crm/doctype/crm_settings/crm_settings.py
+++ b/erpnext/crm/doctype/crm_settings/crm_settings.py
@@ -1,9 +1,10 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
-# import frappe
+import frappe
from frappe.model.document import Document
class CRMSettings(Document):
- pass
+ def validate(self):
+ frappe.db.set_default("campaign_naming_by", self.get("campaign_naming_by", ""))
diff --git a/erpnext/crm/doctype/opportunity/opportunity.js b/erpnext/crm/doctype/opportunity/opportunity.js
index f8376e6..8e7d67e 100644
--- a/erpnext/crm/doctype/opportunity/opportunity.js
+++ b/erpnext/crm/doctype/opportunity/opportunity.js
@@ -24,6 +24,14 @@
frm.trigger('set_contact_link');
}
},
+
+ validate: function(frm) {
+ if (frm.doc.status == "Lost" && !frm.doc.lost_reasons.length) {
+ frm.trigger('set_as_lost_dialog');
+ frappe.throw(__("Lost Reasons are required in case opportunity is Lost."));
+ }
+ },
+
contact_date: function(frm) {
if(frm.doc.contact_date < frappe.datetime.now_datetime()){
frm.set_value("contact_date", "");
@@ -82,7 +90,7 @@
frm.trigger('setup_opportunity_from');
erpnext.toggle_naming_series();
- if(!doc.__islocal && doc.status!=="Lost") {
+ if(!frm.is_new() && doc.status!=="Lost") {
if(doc.with_items){
frm.add_custom_button(__('Supplier Quotation'),
function() {
@@ -187,11 +195,11 @@
change_form_labels: function(frm) {
let company_currency = erpnext.get_currency(frm.doc.company);
- frm.set_currency_labels(["base_opportunity_amount", "base_total", "base_grand_total"], company_currency);
- frm.set_currency_labels(["opportunity_amount", "total", "grand_total"], frm.doc.currency);
+ frm.set_currency_labels(["base_opportunity_amount", "base_total"], company_currency);
+ frm.set_currency_labels(["opportunity_amount", "total"], frm.doc.currency);
// toggle fields
- frm.toggle_display(["conversion_rate", "base_opportunity_amount", "base_total", "base_grand_total"],
+ frm.toggle_display(["conversion_rate", "base_opportunity_amount", "base_total"],
frm.doc.currency != company_currency);
},
@@ -209,20 +217,15 @@
},
calculate_total: function(frm) {
- let total = 0, base_total = 0, grand_total = 0, base_grand_total = 0;
+ let total = 0, base_total = 0;
frm.doc.items.forEach(item => {
total += item.amount;
base_total += item.base_amount;
})
- base_grand_total = base_total + frm.doc.base_opportunity_amount;
- grand_total = total + frm.doc.opportunity_amount;
-
frm.set_value({
'total': flt(total),
- 'base_total': flt(base_total),
- 'grand_total': flt(grand_total),
- 'base_grand_total': flt(base_grand_total)
+ 'base_total': flt(base_total)
});
}
diff --git a/erpnext/crm/doctype/opportunity/opportunity.json b/erpnext/crm/doctype/opportunity/opportunity.json
index dc32d9a..089f2d2 100644
--- a/erpnext/crm/doctype/opportunity/opportunity.json
+++ b/erpnext/crm/doctype/opportunity/opportunity.json
@@ -42,10 +42,8 @@
"items",
"section_break_32",
"base_total",
- "base_grand_total",
"column_break_33",
"total",
- "grand_total",
"contact_info",
"customer_address",
"address_display",
@@ -476,21 +474,6 @@
"fieldtype": "Column Break"
},
{
- "fieldname": "base_grand_total",
- "fieldtype": "Currency",
- "label": "Grand Total (Company Currency)",
- "options": "Company:company:default_currency",
- "print_hide": 1,
- "read_only": 1
- },
- {
- "fieldname": "grand_total",
- "fieldtype": "Currency",
- "label": "Grand Total",
- "options": "currency",
- "read_only": 1
- },
- {
"fieldname": "lost_detail_section",
"fieldtype": "Section Break",
"label": "Lost Reasons"
@@ -510,7 +493,7 @@
"icon": "fa fa-info-sign",
"idx": 195,
"links": [],
- "modified": "2021-10-21 12:04:30.151379",
+ "modified": "2022-01-29 19:32:26.382896",
"modified_by": "Administrator",
"module": "CRM",
"name": "Opportunity",
@@ -547,6 +530,7 @@
"show_name_in_global_search": 1,
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"subject_field": "title",
"timeline_field": "party_name",
"title_field": "title",
diff --git a/erpnext/crm/doctype/opportunity/opportunity.py b/erpnext/crm/doctype/opportunity/opportunity.py
index a4fd765..2d53874 100644
--- a/erpnext/crm/doctype/opportunity/opportunity.py
+++ b/erpnext/crm/doctype/opportunity/opportunity.py
@@ -69,8 +69,6 @@
self.total = flt(total)
self.base_total = flt(base_total)
- self.grand_total = flt(self.total) + flt(self.opportunity_amount)
- self.base_grand_total = flt(self.base_total) + flt(self.base_opportunity_amount)
def make_new_lead_if_required(self):
"""Set lead against new opportunity"""
diff --git a/erpnext/crm/doctype/prospect/prospect.js b/erpnext/crm/doctype/prospect/prospect.js
index 67018e1..8721a5b 100644
--- a/erpnext/crm/doctype/prospect/prospect.js
+++ b/erpnext/crm/doctype/prospect/prospect.js
@@ -3,6 +3,8 @@
frappe.ui.form.on('Prospect', {
refresh (frm) {
+ frappe.dynamic_link = { doc: frm.doc, fieldname: "name", doctype: frm.doctype };
+
if (!frm.is_new() && frappe.boot.user.can_create.includes("Customer")) {
frm.add_custom_button(__("Customer"), function() {
frappe.model.open_mapped_doc({
diff --git a/erpnext/crm/module_onboarding/crm/crm.json b/erpnext/crm/module_onboarding/crm/crm.json
index 8315218..0faad1d 100644
--- a/erpnext/crm/module_onboarding/crm/crm.json
+++ b/erpnext/crm/module_onboarding/crm/crm.json
@@ -16,7 +16,7 @@
"documentation_url": "https://docs.erpnext.com/docs/user/manual/en/CRM",
"idx": 0,
"is_complete": 0,
- "modified": "2020-07-08 14:05:42.644448",
+ "modified": "2022-01-29 20:14:29.502145",
"modified_by": "Administrator",
"module": "CRM",
"name": "CRM",
@@ -33,6 +33,9 @@
},
{
"step": "Create and Send Quotation"
+ },
+ {
+ "step": "CRM Settings"
}
],
"subtitle": "Lead, Opportunity, Customer, and more.",
diff --git a/erpnext/crm/onboarding_step/create_and_send_quotation/create_and_send_quotation.json b/erpnext/crm/onboarding_step/create_and_send_quotation/create_and_send_quotation.json
index 78f7e4d..f0f50de 100644
--- a/erpnext/crm/onboarding_step/create_and_send_quotation/create_and_send_quotation.json
+++ b/erpnext/crm/onboarding_step/create_and_send_quotation/create_and_send_quotation.json
@@ -5,7 +5,6 @@
"doctype": "Onboarding Step",
"idx": 0,
"is_complete": 0,
- "is_mandatory": 0,
"is_single": 0,
"is_skipped": 0,
"modified": "2020-05-28 21:07:11.461172",
@@ -13,6 +12,7 @@
"name": "Create and Send Quotation",
"owner": "Administrator",
"reference_document": "Quotation",
+ "show_form_tour": 0,
"show_full_form": 1,
"title": "Create and Send Quotation",
"validate_action": 1
diff --git a/erpnext/crm/onboarding_step/create_lead/create_lead.json b/erpnext/crm/onboarding_step/create_lead/create_lead.json
index c45e8b0..cb5cce6 100644
--- a/erpnext/crm/onboarding_step/create_lead/create_lead.json
+++ b/erpnext/crm/onboarding_step/create_lead/create_lead.json
@@ -5,7 +5,6 @@
"doctype": "Onboarding Step",
"idx": 0,
"is_complete": 0,
- "is_mandatory": 0,
"is_single": 0,
"is_skipped": 0,
"modified": "2020-05-28 21:07:01.373403",
@@ -13,6 +12,7 @@
"name": "Create Lead",
"owner": "Administrator",
"reference_document": "Lead",
+ "show_form_tour": 0,
"show_full_form": 1,
"title": "Create Lead",
"validate_action": 1
diff --git a/erpnext/crm/onboarding_step/create_opportunity/create_opportunity.json b/erpnext/crm/onboarding_step/create_opportunity/create_opportunity.json
index 0ee9317..96e0256 100644
--- a/erpnext/crm/onboarding_step/create_opportunity/create_opportunity.json
+++ b/erpnext/crm/onboarding_step/create_opportunity/create_opportunity.json
@@ -5,7 +5,6 @@
"doctype": "Onboarding Step",
"idx": 0,
"is_complete": 0,
- "is_mandatory": 0,
"is_single": 0,
"is_skipped": 0,
"modified": "2021-01-21 15:28:52.483839",
@@ -13,6 +12,7 @@
"name": "Create Opportunity",
"owner": "Administrator",
"reference_document": "Opportunity",
+ "show_form_tour": 0,
"show_full_form": 1,
"title": "Create Opportunity",
"validate_action": 1
diff --git a/erpnext/crm/onboarding_step/crm_settings/crm_settings.json b/erpnext/crm/onboarding_step/crm_settings/crm_settings.json
new file mode 100644
index 0000000..555d795
--- /dev/null
+++ b/erpnext/crm/onboarding_step/crm_settings/crm_settings.json
@@ -0,0 +1,21 @@
+{
+ "action": "Go to Page",
+ "creation": "2022-01-29 20:14:24.803844",
+ "description": "# CRM Settings\n\nCRM module\u2019s features are configurable as per your business needs. CRM Settings is the place where you can set your preferences for:\n- Campaign\n- Lead\n- Opportunity\n- Quotation",
+ "docstatus": 0,
+ "doctype": "Onboarding Step",
+ "idx": 0,
+ "is_complete": 0,
+ "is_single": 1,
+ "is_skipped": 0,
+ "modified": "2022-01-29 20:14:24.803844",
+ "modified_by": "Administrator",
+ "name": "CRM Settings",
+ "owner": "Administrator",
+ "path": "#crm-settings/CRM%20Settings",
+ "reference_document": "CRM Settings",
+ "show_form_tour": 0,
+ "show_full_form": 0,
+ "title": "CRM Settings",
+ "validate_action": 1
+}
\ No newline at end of file
diff --git a/erpnext/crm/onboarding_step/introduction_to_crm/introduction_to_crm.json b/erpnext/crm/onboarding_step/introduction_to_crm/introduction_to_crm.json
index fa26921a..8871753 100644
--- a/erpnext/crm/onboarding_step/introduction_to_crm/introduction_to_crm.json
+++ b/erpnext/crm/onboarding_step/introduction_to_crm/introduction_to_crm.json
@@ -5,13 +5,13 @@
"doctype": "Onboarding Step",
"idx": 0,
"is_complete": 0,
- "is_mandatory": 0,
"is_single": 0,
"is_skipped": 0,
"modified": "2020-05-14 17:28:16.448676",
"modified_by": "Administrator",
"name": "Introduction to CRM",
"owner": "Administrator",
+ "show_form_tour": 0,
"show_full_form": 0,
"title": "Introduction to CRM",
"validate_action": 1,
diff --git a/erpnext/hooks.py b/erpnext/hooks.py
index d172da3..04f5793 100644
--- a/erpnext/hooks.py
+++ b/erpnext/hooks.py
@@ -443,6 +443,7 @@
'erpnext.controllers.taxes_and_totals.get_regional_round_off_accounts': 'erpnext.regional.india.utils.get_regional_round_off_accounts',
'erpnext.hr.utils.calculate_annual_eligible_hra_exemption': 'erpnext.regional.india.utils.calculate_annual_eligible_hra_exemption',
'erpnext.hr.utils.calculate_hra_exemption_for_period': 'erpnext.regional.india.utils.calculate_hra_exemption_for_period',
+ 'erpnext.controllers.accounts_controller.validate_einvoice_fields': 'erpnext.regional.india.e_invoice.utils.validate_einvoice_fields',
'erpnext.assets.doctype.asset.asset.get_depreciation_amount': 'erpnext.regional.india.utils.get_depreciation_amount',
'erpnext.stock.doctype.item.item.set_item_tax_from_hsn_code': 'erpnext.regional.india.utils.set_item_tax_from_hsn_code'
},
diff --git a/erpnext/hr/doctype/employee/employee_dashboard.py b/erpnext/hr/doctype/employee/employee_dashboard.py
index a1247d9..fb62b04 100644
--- a/erpnext/hr/doctype/employee/employee_dashboard.py
+++ b/erpnext/hr/doctype/employee/employee_dashboard.py
@@ -21,7 +21,7 @@
},
{
'label': _('Lifecycle'),
- 'items': ['Employee Transfer', 'Employee Promotion', 'Employee Grievance']
+ 'items': ['Employee Onboarding', 'Employee Transfer', 'Employee Promotion', 'Employee Grievance']
},
{
'label': _('Exit'),
diff --git a/erpnext/hr/doctype/employee_boarding_activity/employee_boarding_activity.json b/erpnext/hr/doctype/employee_boarding_activity/employee_boarding_activity.json
index 044a5a9..8474bd0 100644
--- a/erpnext/hr/doctype/employee_boarding_activity/employee_boarding_activity.json
+++ b/erpnext/hr/doctype/employee_boarding_activity/employee_boarding_activity.json
@@ -62,6 +62,7 @@
},
{
"default": "0",
+ "depends_on": "eval:['Employee Onboarding', 'Employee Onboarding Template'].includes(doc.parenttype)",
"description": "Applicable in the case of Employee Onboarding",
"fieldname": "required_for_employee_creation",
"fieldtype": "Check",
@@ -93,7 +94,7 @@
],
"istable": 1,
"links": [],
- "modified": "2021-07-30 15:55:22.470102",
+ "modified": "2022-01-29 14:05:00.543122",
"modified_by": "Administrator",
"module": "HR",
"name": "Employee Boarding Activity",
@@ -102,5 +103,6 @@
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/hr/doctype/employee_onboarding/employee_onboarding.js b/erpnext/hr/doctype/employee_onboarding/employee_onboarding.js
index 5d1a024..6fbb54d 100644
--- a/erpnext/hr/doctype/employee_onboarding/employee_onboarding.js
+++ b/erpnext/hr/doctype/employee_onboarding/employee_onboarding.js
@@ -3,12 +3,6 @@
frappe.ui.form.on('Employee Onboarding', {
setup: function(frm) {
- frm.add_fetch("employee_onboarding_template", "company", "company");
- frm.add_fetch("employee_onboarding_template", "department", "department");
- frm.add_fetch("employee_onboarding_template", "designation", "designation");
- frm.add_fetch("employee_onboarding_template", "employee_grade", "employee_grade");
-
-
frm.set_query("job_applicant", function () {
return {
filters:{
@@ -71,5 +65,19 @@
}
});
}
+ },
+
+ job_applicant: function(frm) {
+ if (frm.doc.job_applicant) {
+ frappe.db.get_value('Employee', {'job_applicant': frm.doc.job_applicant}, 'name', (r) => {
+ if (r.name) {
+ frm.set_value('employee', r.name);
+ } else {
+ frm.set_value('employee', '');
+ }
+ });
+ } else {
+ frm.set_value('employee', '');
+ }
}
});
diff --git a/erpnext/hr/doctype/employee_onboarding/employee_onboarding.json b/erpnext/hr/doctype/employee_onboarding/employee_onboarding.json
index fd877a6..1d2ea0c 100644
--- a/erpnext/hr/doctype/employee_onboarding/employee_onboarding.json
+++ b/erpnext/hr/doctype/employee_onboarding/employee_onboarding.json
@@ -92,6 +92,7 @@
"options": "Employee Onboarding Template"
},
{
+ "fetch_from": "employee_onboarding_template.company",
"fieldname": "company",
"fieldtype": "Link",
"label": "Company",
@@ -99,6 +100,7 @@
"reqd": 1
},
{
+ "fetch_from": "employee_onboarding_template.department",
"fieldname": "department",
"fieldtype": "Link",
"in_list_view": 1,
@@ -106,6 +108,7 @@
"options": "Department"
},
{
+ "fetch_from": "employee_onboarding_template.designation",
"fieldname": "designation",
"fieldtype": "Link",
"in_list_view": 1,
@@ -113,6 +116,7 @@
"options": "Designation"
},
{
+ "fetch_from": "employee_onboarding_template.employee_grade",
"fieldname": "employee_grade",
"fieldtype": "Link",
"label": "Employee Grade",
@@ -170,10 +174,11 @@
],
"is_submittable": 1,
"links": [],
- "modified": "2021-07-30 14:55:04.560683",
+ "modified": "2022-01-29 12:33:57.120384",
"modified_by": "Administrator",
"module": "HR",
"name": "Employee Onboarding",
+ "naming_rule": "Expression (old style)",
"owner": "Administrator",
"permissions": [
{
@@ -194,6 +199,7 @@
],
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"title_field": "employee_name",
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/hr/doctype/employee_onboarding/employee_onboarding.py b/erpnext/hr/doctype/employee_onboarding/employee_onboarding.py
index eba2a03..a0939a8 100644
--- a/erpnext/hr/doctype/employee_onboarding/employee_onboarding.py
+++ b/erpnext/hr/doctype/employee_onboarding/employee_onboarding.py
@@ -14,10 +14,15 @@
class EmployeeOnboarding(EmployeeBoardingController):
def validate(self):
super(EmployeeOnboarding, self).validate()
+ self.set_employee()
self.validate_duplicate_employee_onboarding()
+ def set_employee(self):
+ if not self.employee:
+ self.employee = frappe.db.get_value('Employee', {'job_applicant': self.job_applicant}, 'name')
+
def validate_duplicate_employee_onboarding(self):
- emp_onboarding = frappe.db.exists("Employee Onboarding", {"job_applicant": self.job_applicant})
+ emp_onboarding = frappe.db.exists("Employee Onboarding", {"job_applicant": self.job_applicant, "docstatus": ("!=", 2)})
if emp_onboarding and emp_onboarding != self.name:
frappe.throw(_("Employee Onboarding: {0} already exists for Job Applicant: {1}").format(frappe.bold(emp_onboarding), frappe.bold(self.job_applicant)))
diff --git a/erpnext/hr/doctype/interview/interview.py b/erpnext/hr/doctype/interview/interview.py
index 4bb003d..a3b111c 100644
--- a/erpnext/hr/doctype/interview/interview.py
+++ b/erpnext/hr/doctype/interview/interview.py
@@ -7,7 +7,7 @@
import frappe
from frappe import _
from frappe.model.document import Document
-from frappe.utils import cstr, get_datetime, get_link_to_form
+from frappe.utils import cstr, flt, get_datetime, get_link_to_form
class DuplicateInterviewRoundError(frappe.ValidationError):
@@ -18,6 +18,7 @@
self.validate_duplicate_interview()
self.validate_designation()
self.validate_overlap()
+ self.set_average_rating()
def on_submit(self):
if self.status not in ['Cleared', 'Rejected']:
@@ -67,6 +68,13 @@
overlapping_details = _('Interview overlaps with {0}').format(get_link_to_form('Interview', overlaps[0][0]))
frappe.throw(overlapping_details, title=_('Overlap'))
+ def set_average_rating(self):
+ total_rating = 0
+ for entry in self.interview_details:
+ if entry.average_rating:
+ total_rating += entry.average_rating
+
+ self.average_rating = flt(total_rating / len(self.interview_details) if len(self.interview_details) else 0)
@frappe.whitelist()
def reschedule_interview(self, scheduled_on, from_time, to_time):
diff --git a/erpnext/hr/doctype/interview/test_interview.py b/erpnext/hr/doctype/interview/test_interview.py
index 1a2257a..fdb11af 100644
--- a/erpnext/hr/doctype/interview/test_interview.py
+++ b/erpnext/hr/doctype/interview/test_interview.py
@@ -12,6 +12,7 @@
from erpnext.hr.doctype.designation.test_designation import create_designation
from erpnext.hr.doctype.interview.interview import DuplicateInterviewRoundError
+from erpnext.hr.doctype.job_applicant.job_applicant import get_interview_details
from erpnext.hr.doctype.job_applicant.test_job_applicant import create_job_applicant
@@ -70,6 +71,20 @@
email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True)
self.assertTrue("Subject: Interview Feedback Reminder" in email_queue[0].message)
+ def test_get_interview_details_for_applicant_dashboard(self):
+ job_applicant = create_job_applicant()
+ interview = create_interview_and_dependencies(job_applicant.name)
+
+ details = get_interview_details(job_applicant.name)
+ self.assertEqual(details.get('stars'), 5)
+ self.assertEqual(details.get('interviews').get(interview.name), {
+ 'name': interview.name,
+ 'interview_round': interview.interview_round,
+ 'expected_average_rating': interview.expected_average_rating * 5,
+ 'average_rating': interview.average_rating * 5,
+ 'status': 'Pending'
+ })
+
def tearDown(self):
frappe.db.rollback()
@@ -106,7 +121,8 @@
interview_round = frappe.new_doc("Interview Round")
interview_round.round_name = name
interview_round.interview_type = create_interview_type()
- interview_round.expected_average_rating = 4
+ # average rating = 4
+ interview_round.expected_average_rating = 0.8
if designation:
interview_round.designation = designation
diff --git a/erpnext/hr/doctype/interview_feedback/interview_feedback.py b/erpnext/hr/doctype/interview_feedback/interview_feedback.py
index d046458..2ff00c1 100644
--- a/erpnext/hr/doctype/interview_feedback/interview_feedback.py
+++ b/erpnext/hr/doctype/interview_feedback/interview_feedback.py
@@ -57,7 +57,6 @@
def update_interview_details(self):
doc = frappe.get_doc('Interview', self.interview)
- total_rating = 0
if self.docstatus == 2:
for entry in doc.interview_details:
@@ -72,10 +71,6 @@
entry.comments = self.feedback
entry.result = self.result
- if entry.average_rating:
- total_rating += entry.average_rating
-
- doc.average_rating = flt(total_rating / len(doc.interview_details) if len(doc.interview_details) else 0)
doc.save()
doc.notify_update()
diff --git a/erpnext/hr/doctype/interview_feedback/test_interview_feedback.py b/erpnext/hr/doctype/interview_feedback/test_interview_feedback.py
index d2ec5b9..19c4642 100644
--- a/erpnext/hr/doctype/interview_feedback/test_interview_feedback.py
+++ b/erpnext/hr/doctype/interview_feedback/test_interview_feedback.py
@@ -24,7 +24,7 @@
create_skill_set(['Leadership'])
interview_feedback = create_interview_feedback(interview.name, interviewer, skill_ratings)
- interview_feedback.append("skill_assessment", {"skill": 'Leadership', 'rating': 4})
+ interview_feedback.append("skill_assessment", {"skill": 'Leadership', 'rating': 0.8})
frappe.set_user(interviewer)
self.assertRaises(frappe.ValidationError, interview_feedback.save)
@@ -50,7 +50,7 @@
avg_rating = flt(total_rating / len(feedback_1.skill_assessment) if len(feedback_1.skill_assessment) else 0)
- self.assertEqual(flt(avg_rating, 3), feedback_1.average_rating)
+ self.assertEqual(flt(avg_rating, 2), flt(feedback_1.average_rating, 2))
avg_on_interview_detail = frappe.db.get_value('Interview Detail', {
'parent': feedback_1.interview,
@@ -59,7 +59,7 @@
}, 'average_rating')
# 1. average should be reflected in Interview Detail.
- self.assertEqual(avg_on_interview_detail, feedback_1.average_rating)
+ self.assertEqual(flt(avg_on_interview_detail, 2), flt(feedback_1.average_rating, 2))
'''For Second Interviewer Feedback'''
interviewer = interview.interview_details[1].interviewer
@@ -97,5 +97,5 @@
skills = frappe.get_all("Expected Skill Set", filters={"parent": interview_round}, fields = ["skill"])
for d in skills:
- d["rating"] = random.randint(1, 5)
+ d["rating"] = random.random()
return skills
diff --git a/erpnext/hr/doctype/job_applicant/job_applicant.js b/erpnext/hr/doctype/job_applicant/job_applicant.js
index d7b1c6c..c1e8257 100644
--- a/erpnext/hr/doctype/job_applicant/job_applicant.js
+++ b/erpnext/hr/doctype/job_applicant/job_applicant.js
@@ -21,9 +21,9 @@
create_custom_buttons: function(frm) {
if (!frm.doc.__islocal && frm.doc.status !== "Rejected" && frm.doc.status !== "Accepted") {
- frm.add_custom_button(__("Create Interview"), function() {
+ frm.add_custom_button(__("Interview"), function() {
frm.events.create_dialog(frm);
- });
+ }, __("Create"));
}
if (!frm.doc.__islocal) {
@@ -40,10 +40,10 @@
frappe.route_options = {
"job_applicant": frm.doc.name,
"applicant_name": frm.doc.applicant_name,
- "designation": frm.doc.job_opening,
+ "designation": frm.doc.job_opening || frm.doc.designation,
};
frappe.new_doc("Job Offer");
- });
+ }, __("Create"));
}
}
},
@@ -55,13 +55,16 @@
job_applicant: frm.doc.name
},
callback: function(r) {
- $("div").remove(".form-dashboard-section.custom");
- frm.dashboard.add_section(
- frappe.render_template('job_applicant_dashboard', {
- data: r.message
- }),
- __("Interview Summary")
- );
+ if (r.message) {
+ $("div").remove(".form-dashboard-section.custom");
+ frm.dashboard.add_section(
+ frappe.render_template("job_applicant_dashboard", {
+ data: r.message.interviews,
+ number_of_stars: r.message.stars
+ }),
+ __("Interview Summary")
+ );
+ }
}
});
},
diff --git a/erpnext/hr/doctype/job_applicant/job_applicant.py b/erpnext/hr/doctype/job_applicant/job_applicant.py
index 5b3d9bf..ccc21ce 100644
--- a/erpnext/hr/doctype/job_applicant/job_applicant.py
+++ b/erpnext/hr/doctype/job_applicant/job_applicant.py
@@ -81,8 +81,13 @@
fields=["name", "interview_round", "expected_average_rating", "average_rating", "status"]
)
interview_detail_map = {}
+ meta = frappe.get_meta("Interview")
+ number_of_stars = meta.get_options("expected_average_rating") or 5
for detail in interview_details:
+ detail.expected_average_rating = detail.expected_average_rating * number_of_stars if detail.expected_average_rating else 0
+ detail.average_rating = detail.average_rating * number_of_stars if detail.average_rating else 0
+
interview_detail_map[detail.name] = detail
- return interview_detail_map
+ return {"interviews": interview_detail_map, "stars": number_of_stars}
diff --git a/erpnext/hr/doctype/job_applicant/job_applicant_dashboard.html b/erpnext/hr/doctype/job_applicant/job_applicant_dashboard.html
index c286787..734b2fe 100644
--- a/erpnext/hr/doctype/job_applicant/job_applicant_dashboard.html
+++ b/erpnext/hr/doctype/job_applicant/job_applicant_dashboard.html
@@ -17,24 +17,33 @@
<td class="text-left"> {%= key %} </td>
<td class="text-left"> {%= value["interview_round"] %} </td>
<td class="text-left"> {%= value["status"] %} </td>
- <td class="text-left">
- {% for (i = 0; i < value["expected_average_rating"]; i++) { %}
- <span class="fa fa-star " style="color: #F6C35E;"></span>
- {% } %}
- {% for (i = 0; i < (5-value["expected_average_rating"]); i++) { %}
- <span class="fa fa-star " style="color: #E7E9EB;"></span>
- {% } %}
- </td>
- <td class="text-left">
- {% if(value["average_rating"]){ %}
- {% for (i = 0; i < value["average_rating"]; i++) { %}
- <span class="fa fa-star " style="color: #F6C35E;"></span>
- {% } %}
- {% for (i = 0; i < (5-value["average_rating"]); i++) { %}
- <span class="fa fa-star " style="color: #E7E9EB;"></span>
- {% } %}
- {% } %}
- </td>
+ {% let right_class = ''; %}
+ {% let left_class = ''; %}
+
+ {% $.each([value["expected_average_rating"], value["average_rating"]], (_, val) => { %}
+ <td class="text-left">
+ <div class="rating">
+ {% for (let i = 1; i <= number_of_stars; i++) { %}
+ {% if (i <= val) { %}
+ {% right_class = 'star-click'; %}
+ {% } else { %}
+ {% right_class = ''; %}
+ {% } %}
+
+ {% if ((i <= val) || ((i - 0.5) == val)) { %}
+ {% left_class = 'star-click'; %}
+ {% } else { %}
+ {% left_class = ''; %}
+ {% } %}
+
+ <svg class="icon icon-md" data-rating={{i}} viewBox="0 0 24 24" fill="none">
+ <path class="right-half {{ right_class }}" d="M11.9987 3.00011C12.177 3.00011 12.3554 3.09303 12.4471 3.27888L14.8213 8.09112C14.8941 8.23872 15.0349 8.34102 15.1978 8.3647L20.5069 9.13641C20.917 9.19602 21.0807 9.69992 20.7841 9.9892L16.9421 13.7354C16.8243 13.8503 16.7706 14.0157 16.7984 14.1779L17.7053 19.4674C17.7753 19.8759 17.3466 20.1874 16.9798 19.9945L12.2314 17.4973C12.1586 17.459 12.0786 17.4398 11.9987 17.4398V3.00011Z" fill="var(--star-fill)" stroke="var(--star-fill)"/>
+ <path class="left-half {{ left_class }}" d="M11.9987 3.00011C11.8207 3.00011 11.6428 3.09261 11.5509 3.27762L9.15562 8.09836C9.08253 8.24546 8.94185 8.34728 8.77927 8.37075L3.42887 9.14298C3.01771 9.20233 2.85405 9.70811 3.1525 9.99707L7.01978 13.7414C7.13858 13.8564 7.19283 14.0228 7.16469 14.1857L6.25116 19.4762C6.18071 19.8842 6.6083 20.1961 6.97531 20.0045L11.7672 17.5022C11.8397 17.4643 11.9192 17.4454 11.9987 17.4454V3.00011Z" fill="var(--star-fill)" stroke="var(--star-fill)"/>
+ </svg>
+ {% } %}
+ </div>
+ </td>
+ {% }); %}
</tr>
{% } %}
</tbody>
diff --git a/erpnext/hr/doctype/job_offer/job_offer.py b/erpnext/hr/doctype/job_offer/job_offer.py
index 39f4719..072fc73 100644
--- a/erpnext/hr/doctype/job_offer/job_offer.py
+++ b/erpnext/hr/doctype/job_offer/job_offer.py
@@ -78,6 +78,7 @@
"doctype": "Employee",
"field_map": {
"applicant_name": "employee_name",
+ "offer_date": "scheduled_confirmation_date"
}}
}, target_doc, set_missing_values)
return doc
diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py
index 93ca805..03e0910 100644
--- a/erpnext/manufacturing/doctype/work_order/work_order.py
+++ b/erpnext/manufacturing/doctype/work_order/work_order.py
@@ -31,6 +31,7 @@
from erpnext.stock.doctype.item.item import get_item_defaults, validate_end_of_life
from erpnext.stock.doctype.serial_no.serial_no import (
auto_make_serial_nos,
+ clean_serial_no_string,
get_auto_serial_nos,
get_serial_nos,
)
@@ -358,6 +359,7 @@
frappe.delete_doc("Batch", row.name)
def make_serial_nos(self, args):
+ self.serial_no = clean_serial_no_string(self.serial_no)
serial_no_series = frappe.get_cached_value("Item", self.production_item, "serial_no_series")
if serial_no_series:
self.serial_no = get_auto_serial_nos(serial_no_series, self.qty)
diff --git a/erpnext/manufacturing/report/cost_of_poor_quality_report/cost_of_poor_quality_report.js b/erpnext/manufacturing/report/cost_of_poor_quality_report/cost_of_poor_quality_report.js
index 97e7e0a..72eed5e 100644
--- a/erpnext/manufacturing/report/cost_of_poor_quality_report/cost_of_poor_quality_report.js
+++ b/erpnext/manufacturing/report/cost_of_poor_quality_report/cost_of_poor_quality_report.js
@@ -17,14 +17,12 @@
fieldname:"from_date",
fieldtype: "Datetime",
default: frappe.datetime.convert_to_system_tz(frappe.datetime.add_months(frappe.datetime.now_datetime(), -1)),
- reqd: 1
},
{
label: __("To Date"),
fieldname:"to_date",
fieldtype: "Datetime",
default: frappe.datetime.now_datetime(),
- reqd: 1,
},
{
label: __("Job Card"),
diff --git a/erpnext/manufacturing/report/cost_of_poor_quality_report/cost_of_poor_quality_report.py b/erpnext/manufacturing/report/cost_of_poor_quality_report/cost_of_poor_quality_report.py
index 7741823..88b2117 100644
--- a/erpnext/manufacturing/report/cost_of_poor_quality_report/cost_of_poor_quality_report.py
+++ b/erpnext/manufacturing/report/cost_of_poor_quality_report/cost_of_poor_quality_report.py
@@ -3,46 +3,65 @@
import frappe
from frappe import _
-from frappe.utils import flt
def execute(filters=None):
- columns, data = [], []
+ return get_columns(filters), get_data(filters)
- columns = get_columns(filters)
- data = get_data(filters)
-
- return columns, data
def get_data(report_filters):
data = []
operations = frappe.get_all("Operation", filters = {"is_corrective_operation": 1})
if operations:
- operations = [d.name for d in operations]
- fields = ["production_item as item_code", "item_name", "work_order", "operation",
- "workstation", "total_time_in_mins", "name", "hour_rate", "serial_no", "batch_no"]
+ if report_filters.get('operation'):
+ operations = [report_filters.get('operation')]
+ else:
+ operations = [d.name for d in operations]
- filters = get_filters(report_filters, operations)
+ job_card = frappe.qb.DocType("Job Card")
- job_cards = frappe.get_all("Job Card", fields = fields,
- filters = filters)
+ operating_cost = ((job_card.hour_rate) * (job_card.total_time_in_mins) / 60.0).as_('operating_cost')
+ item_code = (job_card.production_item).as_('item_code')
- for row in job_cards:
- row.operating_cost = flt(row.hour_rate) * (flt(row.total_time_in_mins) / 60.0)
- data.append(row)
+ query = (frappe.qb
+ .from_(job_card)
+ .select(job_card.name, job_card.work_order, item_code, job_card.item_name,
+ job_card.operation, job_card.serial_no, job_card.batch_no,
+ job_card.workstation, job_card.total_time_in_mins, job_card.hour_rate,
+ operating_cost)
+ .where(
+ (job_card.docstatus == 1)
+ & (job_card.is_corrective_job_card == 1))
+ .groupby(job_card.name)
+ )
+ query = append_filters(query, report_filters, operations, job_card)
+ data = query.run(as_dict=True)
return data
-def get_filters(report_filters, operations):
- filters = {"docstatus": 1, "operation": ("in", operations), "is_corrective_job_card": 1}
- for field in ["name", "work_order", "operation", "workstation", "company", "serial_no", "batch_no", "production_item"]:
- if report_filters.get(field):
- if field != 'serial_no':
- filters[field] = report_filters.get(field)
- else:
- filters[field] = ('like', '% {} %'.format(report_filters.get(field)))
+def append_filters(query, report_filters, operations, job_card):
+ """Append optional filters to query builder. """
- return filters
+ for field in ("name", "work_order", "operation", "workstation",
+ "company", "serial_no", "batch_no", "production_item"):
+ if report_filters.get(field):
+ if field == 'serial_no':
+ query = query.where(job_card[field].like('%{}%'.format(report_filters.get(field))))
+ elif field == 'operation':
+ query = query.where(job_card[field].isin(operations))
+ else:
+ query = query.where(job_card[field] == report_filters.get(field))
+
+ if report_filters.get('from_date') or report_filters.get('to_date'):
+ job_card_time_log = frappe.qb.DocType("Job Card Time Log")
+
+ query = query.join(job_card_time_log).on(job_card.name == job_card_time_log.parent)
+ if report_filters.get('from_date'):
+ query = query.where(job_card_time_log.from_time >= report_filters.get('from_date'))
+ if report_filters.get('to_date'):
+ query = query.where(job_card_time_log.to_time <= report_filters.get('to_date'))
+
+ return query
def get_columns(filters):
return [
diff --git a/erpnext/manufacturing/report/test_reports.py b/erpnext/manufacturing/report/test_reports.py
index 1de4726..9f51ded 100644
--- a/erpnext/manufacturing/report/test_reports.py
+++ b/erpnext/manufacturing/report/test_reports.py
@@ -18,7 +18,7 @@
("BOM Operations Time", {}),
("BOM Stock Calculated", {"bom": frappe.get_last_doc("BOM").name, "qty_to_make": 2}),
("BOM Stock Report", {"bom": frappe.get_last_doc("BOM").name, "qty_to_produce": 2}),
- ("Cost of Poor Quality Report", {}),
+ ("Cost of Poor Quality Report", {"item": "_Test Item", "serial_no": "00"}),
("Downtime Analysis", {}),
(
"Exponential Smoothing Forecasting",
diff --git a/erpnext/patches.txt b/erpnext/patches.txt
index ad5062f..b04eeb8 100644
--- a/erpnext/patches.txt
+++ b/erpnext/patches.txt
@@ -203,6 +203,7 @@
erpnext.patches.v13_0.move_doctype_reports_and_notification_from_hr_to_payroll #22-06-2020
erpnext.patches.v13_0.move_payroll_setting_separately_from_hr_settings #22-06-2020
erpnext.patches.v12_0.create_itc_reversal_custom_fields
+execute:frappe.reload_doc("regional", "doctype", "e_invoice_settings")
erpnext.patches.v13_0.check_is_income_tax_component #22-06-2020
erpnext.patches.v13_0.loyalty_points_entry_for_pos_invoice #22-07-2020
erpnext.patches.v12_0.add_taxjar_integration_field
@@ -223,6 +224,7 @@
erpnext.patches.v13_0.set_app_name
erpnext.patches.v13_0.print_uom_after_quantity_patch
erpnext.patches.v13_0.set_payment_channel_in_payment_gateway_account
+erpnext.patches.v12_0.setup_einvoice_fields #2020-12-02
erpnext.patches.v13_0.updates_for_multi_currency_payroll
erpnext.patches.v13_0.update_reason_for_resignation_in_employee
execute:frappe.delete_doc("Report", "Quoted Item Comparison")
@@ -242,6 +244,8 @@
erpnext.patches.v13_0.item_reposting_for_incorrect_sl_and_gl
erpnext.patches.v13_0.delete_old_bank_reconciliation_doctypes
erpnext.patches.v13_0.update_vehicle_no_reqd_condition
+erpnext.patches.v12_0.add_einvoice_status_field #2021-03-17
+erpnext.patches.v12_0.add_einvoice_summary_report_permissions
erpnext.patches.v13_0.setup_fields_for_80g_certificate_and_donation
erpnext.patches.v13_0.rename_membership_settings_to_non_profit_settings
erpnext.patches.v13_0.setup_gratuity_rule_for_india_and_uae
@@ -255,6 +259,7 @@
erpnext.patches.v13_0.make_non_standard_user_type #13-04-2021 #17-01-2022
erpnext.patches.v13_0.update_shipment_status
erpnext.patches.v13_0.remove_attribute_field_from_item_variant_setting
+erpnext.patches.v12_0.add_ewaybill_validity_field
erpnext.patches.v13_0.germany_make_custom_fields
erpnext.patches.v13_0.germany_fill_debtor_creditor_number
erpnext.patches.v13_0.set_pos_closing_as_failed
@@ -271,6 +276,7 @@
erpnext.patches.v13_0.add_missing_fg_item_for_stock_entry
erpnext.patches.v13_0.update_subscription_status_in_memberships
erpnext.patches.v13_0.update_amt_in_work_order_required_items
+erpnext.patches.v12_0.show_einvoice_irn_cancelled_field
erpnext.patches.v13_0.delete_orphaned_tables
erpnext.patches.v13_0.update_export_type_for_gst #2021-08-16
erpnext.patches.v13_0.update_tds_check_field #3
@@ -281,7 +287,6 @@
erpnext.patches.v13_0.trim_whitespace_from_serial_nos # 16-01-2022
erpnext.patches.v13_0.migrate_stripe_api
erpnext.patches.v13_0.reset_clearance_date_for_intracompany_payment_entries
-erpnext.patches.v13_0.einvoicing_deprecation_warning
execute:frappe.reload_doc("erpnext_integrations", "doctype", "TaxJar Settings")
execute:frappe.reload_doc("erpnext_integrations", "doctype", "Product Tax Category")
erpnext.patches.v13_0.custom_fields_for_taxjar_integration #08-11-2021
@@ -327,11 +332,12 @@
[post_model_sync]
erpnext.patches.v14_0.rename_ongoing_status_in_sla_documents
erpnext.patches.v14_0.add_default_exit_questionnaire_notification_template
-erpnext.patches.v14_0.delete_einvoicing_doctypes
erpnext.patches.v14_0.delete_shopify_doctypes
erpnext.patches.v14_0.delete_hub_doctypes
erpnext.patches.v14_0.delete_hospitality_doctypes # 20-01-2022
erpnext.patches.v14_0.delete_agriculture_doctypes
erpnext.patches.v14_0.rearrange_company_fields
erpnext.patches.v14_0.update_leave_notification_template
+erpnext.patches.v14_0.restore_einvoice_fields
erpnext.patches.v13_0.update_sane_transfer_against
+erpnext.patches.v12_0.add_company_link_to_einvoice_settings
\ No newline at end of file
diff --git a/erpnext/patches/v12_0/add_company_link_to_einvoice_settings.py b/erpnext/patches/v12_0/add_company_link_to_einvoice_settings.py
new file mode 100644
index 0000000..e498b67
--- /dev/null
+++ b/erpnext/patches/v12_0/add_company_link_to_einvoice_settings.py
@@ -0,0 +1,18 @@
+from __future__ import unicode_literals
+
+import frappe
+
+
+def execute():
+ company = frappe.get_all('Company', filters = {'country': 'India'})
+ if not company or not frappe.db.count('E Invoice User'):
+ return
+
+ frappe.reload_doc("regional", "doctype", "e_invoice_user")
+ for creds in frappe.db.get_all('E Invoice User', fields=['name', 'gstin']):
+ company_name = frappe.db.sql("""
+ select dl.link_name from `tabAddress` a, `tabDynamic Link` dl
+ where a.gstin = %s and dl.parent = a.name and dl.link_doctype = 'Company'
+ """, (creds.get('gstin')))
+ if company_name and len(company_name) > 0:
+ frappe.db.set_value('E Invoice User', creds.get('name'), 'company', company_name[0][0])
diff --git a/erpnext/patches/v12_0/add_einvoice_status_field.py b/erpnext/patches/v12_0/add_einvoice_status_field.py
new file mode 100644
index 0000000..aeff9ca
--- /dev/null
+++ b/erpnext/patches/v12_0/add_einvoice_status_field.py
@@ -0,0 +1,72 @@
+from __future__ import unicode_literals
+
+import json
+
+import frappe
+from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
+
+
+def execute():
+ company = frappe.get_all('Company', filters = {'country': 'India'})
+ if not company:
+ return
+
+ # move hidden einvoice fields to a different section
+ custom_fields = {
+ 'Sales Invoice': [
+ dict(fieldname='einvoice_section', label='E-Invoice Fields', fieldtype='Section Break', insert_after='gst_vehicle_type',
+ print_hide=1, hidden=1),
+
+ dict(fieldname='ack_no', label='Ack. No.', fieldtype='Data', read_only=1, hidden=1, insert_after='einvoice_section',
+ no_copy=1, print_hide=1),
+
+ dict(fieldname='ack_date', label='Ack. Date', fieldtype='Data', read_only=1, hidden=1, insert_after='ack_no', no_copy=1, print_hide=1),
+
+ dict(fieldname='irn_cancel_date', label='Cancel Date', fieldtype='Data', read_only=1, hidden=1, insert_after='ack_date',
+ no_copy=1, print_hide=1),
+
+ dict(fieldname='signed_einvoice', label='Signed E-Invoice', fieldtype='Code', options='JSON', hidden=1, insert_after='irn_cancel_date',
+ no_copy=1, print_hide=1, read_only=1),
+
+ dict(fieldname='signed_qr_code', label='Signed QRCode', fieldtype='Code', options='JSON', hidden=1, insert_after='signed_einvoice',
+ no_copy=1, print_hide=1, read_only=1),
+
+ dict(fieldname='qrcode_image', label='QRCode', fieldtype='Attach Image', hidden=1, insert_after='signed_qr_code',
+ no_copy=1, print_hide=1, read_only=1),
+
+ dict(fieldname='einvoice_status', label='E-Invoice Status', fieldtype='Select', insert_after='qrcode_image',
+ options='\nPending\nGenerated\nCancelled\nFailed', default=None, hidden=1, no_copy=1, print_hide=1, read_only=1),
+
+ dict(fieldname='failure_description', label='E-Invoice Failure Description', fieldtype='Code', options='JSON',
+ hidden=1, insert_after='einvoice_status', no_copy=1, print_hide=1, read_only=1)
+ ]
+ }
+ create_custom_fields(custom_fields, update=True)
+
+ if frappe.db.exists('E Invoice Settings') and frappe.db.get_single_value('E Invoice Settings', 'enable'):
+ frappe.db.sql('''
+ UPDATE `tabSales Invoice` SET einvoice_status = 'Pending'
+ WHERE
+ posting_date >= '2021-04-01'
+ AND ifnull(irn, '') = ''
+ AND ifnull(`billing_address_gstin`, '') != ifnull(`company_gstin`, '')
+ AND ifnull(gst_category, '') in ('Registered Regular', 'SEZ', 'Overseas', 'Deemed Export')
+ ''')
+
+ # set appropriate statuses
+ frappe.db.sql('''UPDATE `tabSales Invoice` SET einvoice_status = 'Generated'
+ WHERE ifnull(irn, '') != '' AND ifnull(irn_cancelled, 0) = 0''')
+
+ frappe.db.sql('''UPDATE `tabSales Invoice` SET einvoice_status = 'Cancelled'
+ WHERE ifnull(irn_cancelled, 0) = 1''')
+
+ # set correct acknowledgement in e-invoices
+ einvoices = frappe.get_all('Sales Invoice', {'irn': ['is', 'set']}, ['name', 'signed_einvoice'])
+
+ if einvoices:
+ for inv in einvoices:
+ signed_einvoice = inv.get('signed_einvoice')
+ if signed_einvoice:
+ signed_einvoice = json.loads(signed_einvoice)
+ frappe.db.set_value('Sales Invoice', inv.get('name'), 'ack_no', signed_einvoice.get('AckNo'), update_modified=False)
+ frappe.db.set_value('Sales Invoice', inv.get('name'), 'ack_date', signed_einvoice.get('AckDt'), update_modified=False)
diff --git a/erpnext/patches/v12_0/add_einvoice_summary_report_permissions.py b/erpnext/patches/v12_0/add_einvoice_summary_report_permissions.py
new file mode 100644
index 0000000..e837786
--- /dev/null
+++ b/erpnext/patches/v12_0/add_einvoice_summary_report_permissions.py
@@ -0,0 +1,20 @@
+from __future__ import unicode_literals
+
+import frappe
+
+
+def execute():
+ company = frappe.get_all('Company', filters = {'country': 'India'})
+ if not company:
+ return
+
+ if frappe.db.exists('Report', 'E-Invoice Summary') and \
+ not frappe.db.get_value('Custom Role', dict(report='E-Invoice Summary')):
+ frappe.get_doc(dict(
+ doctype='Custom Role',
+ report='E-Invoice Summary',
+ roles= [
+ dict(role='Accounts User'),
+ dict(role='Accounts Manager')
+ ]
+ )).insert()
diff --git a/erpnext/patches/v12_0/add_ewaybill_validity_field.py b/erpnext/patches/v12_0/add_ewaybill_validity_field.py
new file mode 100644
index 0000000..247140d
--- /dev/null
+++ b/erpnext/patches/v12_0/add_ewaybill_validity_field.py
@@ -0,0 +1,18 @@
+from __future__ import unicode_literals
+
+import frappe
+from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
+
+
+def execute():
+ company = frappe.get_all('Company', filters = {'country': 'India'})
+ if not company:
+ return
+
+ custom_fields = {
+ 'Sales Invoice': [
+ dict(fieldname='eway_bill_validity', label='E-Way Bill Validity', fieldtype='Data', no_copy=1, print_hide=1,
+ depends_on='ewaybill', read_only=1, allow_on_submit=1, insert_after='ewaybill')
+ ]
+ }
+ create_custom_fields(custom_fields, update=True)
diff --git a/erpnext/patches/v12_0/setup_einvoice_fields.py b/erpnext/patches/v12_0/setup_einvoice_fields.py
new file mode 100644
index 0000000..c17666a
--- /dev/null
+++ b/erpnext/patches/v12_0/setup_einvoice_fields.py
@@ -0,0 +1,59 @@
+from __future__ import unicode_literals
+
+import frappe
+from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
+
+from erpnext.regional.india.setup import add_permissions, add_print_formats
+
+
+def execute():
+ company = frappe.get_all('Company', filters = {'country': 'India'})
+ if not company:
+ return
+
+ frappe.reload_doc("custom", "doctype", "custom_field")
+ frappe.reload_doc("regional", "doctype", "e_invoice_settings")
+ custom_fields = {
+ 'Sales Invoice': [
+ dict(fieldname='irn', label='IRN', fieldtype='Data', read_only=1, insert_after='customer', no_copy=1, print_hide=1,
+ depends_on='eval:in_list(["Registered Regular", "SEZ", "Overseas", "Deemed Export"], doc.gst_category) && doc.irn_cancelled === 0'),
+
+ dict(fieldname='ack_no', label='Ack. No.', fieldtype='Data', read_only=1, hidden=1, insert_after='irn', no_copy=1, print_hide=1),
+
+ dict(fieldname='ack_date', label='Ack. Date', fieldtype='Data', read_only=1, hidden=1, insert_after='ack_no', no_copy=1, print_hide=1),
+
+ dict(fieldname='irn_cancelled', label='IRN Cancelled', fieldtype='Check', no_copy=1, print_hide=1,
+ depends_on='eval:(doc.irn_cancelled === 1)', read_only=1, allow_on_submit=1, insert_after='customer'),
+
+ dict(fieldname='eway_bill_cancelled', label='E-Way Bill Cancelled', fieldtype='Check', no_copy=1, print_hide=1,
+ depends_on='eval:(doc.eway_bill_cancelled === 1)', read_only=1, allow_on_submit=1, insert_after='customer'),
+
+ dict(fieldname='signed_einvoice', fieldtype='Code', options='JSON', hidden=1, no_copy=1, print_hide=1, read_only=1),
+
+ dict(fieldname='signed_qr_code', fieldtype='Code', options='JSON', hidden=1, no_copy=1, print_hide=1, read_only=1),
+
+ dict(fieldname='qrcode_image', label='QRCode', fieldtype='Attach Image', hidden=1, no_copy=1, print_hide=1, read_only=1)
+ ]
+ }
+ create_custom_fields(custom_fields, update=True)
+ add_permissions()
+ add_print_formats()
+
+ einvoice_cond = 'in_list(["Registered Regular", "SEZ", "Overseas", "Deemed Export"], doc.gst_category)'
+ t = {
+ 'mode_of_transport': [{'default': None}],
+ 'distance': [{'mandatory_depends_on': f'eval:{einvoice_cond} && doc.transporter'}],
+ 'gst_vehicle_type': [{'mandatory_depends_on': f'eval:{einvoice_cond} && doc.mode_of_transport == "Road"'}],
+ 'lr_date': [{'mandatory_depends_on': f'eval:{einvoice_cond} && in_list(["Air", "Ship", "Rail"], doc.mode_of_transport)'}],
+ 'lr_no': [{'mandatory_depends_on': f'eval:{einvoice_cond} && in_list(["Air", "Ship", "Rail"], doc.mode_of_transport)'}],
+ 'vehicle_no': [{'mandatory_depends_on': f'eval:{einvoice_cond} && doc.mode_of_transport == "Road"'}],
+ 'ewaybill': [
+ {'read_only_depends_on': 'eval:doc.irn && doc.ewaybill'},
+ {'depends_on': 'eval:((doc.docstatus === 1 || doc.ewaybill) && doc.eway_bill_cancelled === 0)'}
+ ]
+ }
+
+ for field, conditions in t.items():
+ for c in conditions:
+ [(prop, value)] = c.items()
+ frappe.db.set_value('Custom Field', { 'fieldname': field }, prop, value)
diff --git a/erpnext/patches/v12_0/show_einvoice_irn_cancelled_field.py b/erpnext/patches/v12_0/show_einvoice_irn_cancelled_field.py
new file mode 100644
index 0000000..3f90a03
--- /dev/null
+++ b/erpnext/patches/v12_0/show_einvoice_irn_cancelled_field.py
@@ -0,0 +1,14 @@
+from __future__ import unicode_literals
+
+import frappe
+
+
+def execute():
+ company = frappe.get_all('Company', filters = {'country': 'India'})
+ if not company:
+ return
+
+ irn_cancelled_field = frappe.db.exists('Custom Field', {'dt': 'Sales Invoice', 'fieldname': 'irn_cancelled'})
+ if irn_cancelled_field:
+ frappe.db.set_value('Custom Field', irn_cancelled_field, 'depends_on', 'eval: doc.irn')
+ frappe.db.set_value('Custom Field', irn_cancelled_field, 'read_only', 0)
diff --git a/erpnext/patches/v13_0/einvoicing_deprecation_warning.py b/erpnext/patches/v13_0/einvoicing_deprecation_warning.py
deleted file mode 100644
index e123a55..0000000
--- a/erpnext/patches/v13_0/einvoicing_deprecation_warning.py
+++ /dev/null
@@ -1,9 +0,0 @@
-import click
-
-
-def execute():
- click.secho(
- "Indian E-Invoicing integration is moved to a separate app and will be removed from ERPNext in version-14.\n"
- "Please install the app to continue using the integration: https://github.com/frappe/erpnext_gst_compliance",
- fg="yellow",
- )
diff --git a/erpnext/patches/v14_0/delete_einvoicing_doctypes.py b/erpnext/patches/v14_0/delete_einvoicing_doctypes.py
deleted file mode 100644
index a3a8149..0000000
--- a/erpnext/patches/v14_0/delete_einvoicing_doctypes.py
+++ /dev/null
@@ -1,10 +0,0 @@
-import frappe
-
-
-def execute():
- frappe.delete_doc('DocType', 'E Invoice Settings', ignore_missing=True)
- frappe.delete_doc('DocType', 'E Invoice User', ignore_missing=True)
- frappe.delete_doc('Report', 'E-Invoice Summary', ignore_missing=True)
- frappe.delete_doc('Print Format', 'GST E-Invoice', ignore_missing=True)
- frappe.delete_doc('Custom Field', 'Sales Invoice-eway_bill_cancelled', ignore_missing=True)
- frappe.delete_doc('Custom Field', 'Sales Invoice-irn_cancelled', ignore_missing=True)
diff --git a/erpnext/patches/v14_0/restore_einvoice_fields.py b/erpnext/patches/v14_0/restore_einvoice_fields.py
new file mode 100644
index 0000000..c4431fb
--- /dev/null
+++ b/erpnext/patches/v14_0/restore_einvoice_fields.py
@@ -0,0 +1,24 @@
+import frappe
+from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
+
+from erpnext.regional.india.setup import add_permissions, add_print_formats
+
+
+def execute():
+ # restores back the 2 custom fields that was deleted while removing e-invoicing from v14
+ company = frappe.get_all('Company', filters = {'country': 'India'})
+ if not company:
+ return
+
+ custom_fields = {
+ 'Sales Invoice': [
+ dict(fieldname='irn_cancelled', label='IRN Cancelled', fieldtype='Check', no_copy=1, print_hide=1,
+ depends_on='eval:(doc.irn_cancelled === 1)', read_only=1, allow_on_submit=1, insert_after='customer'),
+
+ dict(fieldname='eway_bill_cancelled', label='E-Way Bill Cancelled', fieldtype='Check', no_copy=1, print_hide=1,
+ depends_on='eval:(doc.eway_bill_cancelled === 1)', read_only=1, allow_on_submit=1, insert_after='customer'),
+ ]
+ }
+ create_custom_fields(custom_fields, update=True)
+ add_permissions()
+ add_print_formats()
diff --git a/erpnext/projects/doctype/project/project.json b/erpnext/projects/doctype/project/project.json
index 2570df7..1cda0a0 100644
--- a/erpnext/projects/doctype/project/project.json
+++ b/erpnext/projects/doctype/project/project.json
@@ -235,13 +235,13 @@
{
"fieldname": "actual_start_date",
"fieldtype": "Data",
- "label": "Actual Start Date",
+ "label": "Actual Start Date (via Time Sheet)",
"read_only": 1
},
{
"fieldname": "actual_time",
"fieldtype": "Float",
- "label": "Actual Time (in Hours)",
+ "label": "Actual Time (in Hours via Time Sheet)",
"read_only": 1
},
{
@@ -251,7 +251,7 @@
{
"fieldname": "actual_end_date",
"fieldtype": "Date",
- "label": "Actual End Date",
+ "label": "Actual End Date (via Time Sheet)",
"oldfieldname": "act_completion_date",
"oldfieldtype": "Date",
"read_only": 1
@@ -458,10 +458,11 @@
"index_web_pages_for_search": 1,
"links": [],
"max_attachments": 4,
- "modified": "2021-04-28 16:36:11.654632",
+ "modified": "2022-01-29 13:58:27.712714",
"modified_by": "Administrator",
"module": "Projects",
"name": "Project",
+ "naming_rule": "By \"Naming Series\" field",
"owner": "Administrator",
"permissions": [
{
@@ -499,6 +500,7 @@
"show_name_in_global_search": 1,
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"timeline_field": "customer",
"title_field": "project_name",
"track_seen": 1
diff --git a/erpnext/projects/doctype/task/task.json b/erpnext/projects/doctype/task/task.json
index ef4740d..8182208 100644
--- a/erpnext/projects/doctype/task/task.json
+++ b/erpnext/projects/doctype/task/task.json
@@ -249,7 +249,7 @@
{
"fieldname": "actual_time",
"fieldtype": "Float",
- "label": "Actual Time (in hours)",
+ "label": "Actual Time (in Hours via Time Sheet)",
"read_only": 1
},
{
@@ -397,10 +397,11 @@
"is_tree": 1,
"links": [],
"max_attachments": 5,
- "modified": "2021-04-16 12:46:51.556741",
+ "modified": "2022-01-29 13:58:47.005241",
"modified_by": "Administrator",
"module": "Projects",
"name": "Task",
+ "naming_rule": "Expression (old style)",
"nsm_parent_field": "parent_task",
"owner": "Administrator",
"permissions": [
@@ -421,6 +422,7 @@
"show_preview_popup": 1,
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"timeline_field": "project",
"title_field": "subject",
"track_seen": 1
diff --git a/erpnext/public/js/utils/serial_no_batch_selector.js b/erpnext/public/js/utils/serial_no_batch_selector.js
index 597d77c..08270bd 100644
--- a/erpnext/public/js/utils/serial_no_batch_selector.js
+++ b/erpnext/public/js/utils/serial_no_batch_selector.js
@@ -592,6 +592,6 @@
&& doc.fg_completed_qty
&& erpnext.stock.bom
&& erpnext.stock.bom.name === doc.bom_no;
- const itemChecks = !!item;
+ const itemChecks = !!item && !item.allow_alternative_item;
return docChecks && itemChecks;
}
diff --git a/erpnext/patches/v14_0/__init__.py b/erpnext/regional/doctype/e_invoice_request_log/__init__.py
similarity index 100%
copy from erpnext/patches/v14_0/__init__.py
copy to erpnext/regional/doctype/e_invoice_request_log/__init__.py
diff --git a/erpnext/regional/doctype/e_invoice_request_log/e_invoice_request_log.js b/erpnext/regional/doctype/e_invoice_request_log/e_invoice_request_log.js
new file mode 100644
index 0000000..7b7ba96
--- /dev/null
+++ b/erpnext/regional/doctype/e_invoice_request_log/e_invoice_request_log.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on('E Invoice Request Log', {
+ // refresh: function(frm) {
+
+ // }
+});
diff --git a/erpnext/regional/doctype/e_invoice_request_log/e_invoice_request_log.json b/erpnext/regional/doctype/e_invoice_request_log/e_invoice_request_log.json
new file mode 100644
index 0000000..3034370
--- /dev/null
+++ b/erpnext/regional/doctype/e_invoice_request_log/e_invoice_request_log.json
@@ -0,0 +1,102 @@
+{
+ "actions": [],
+ "autoname": "EINV-REQ-.#####",
+ "creation": "2020-12-08 12:54:08.175992",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "user",
+ "url",
+ "headers",
+ "response",
+ "column_break_7",
+ "timestamp",
+ "reference_invoice",
+ "data"
+ ],
+ "fields": [
+ {
+ "fieldname": "user",
+ "fieldtype": "Link",
+ "label": "User",
+ "options": "User"
+ },
+ {
+ "fieldname": "reference_invoice",
+ "fieldtype": "Data",
+ "label": "Reference Invoice"
+ },
+ {
+ "fieldname": "headers",
+ "fieldtype": "Code",
+ "label": "Headers",
+ "options": "JSON"
+ },
+ {
+ "fieldname": "data",
+ "fieldtype": "Code",
+ "label": "Data",
+ "options": "JSON"
+ },
+ {
+ "default": "Now",
+ "fieldname": "timestamp",
+ "fieldtype": "Datetime",
+ "label": "Timestamp"
+ },
+ {
+ "fieldname": "response",
+ "fieldtype": "Code",
+ "label": "Response",
+ "options": "JSON"
+ },
+ {
+ "fieldname": "url",
+ "fieldtype": "Data",
+ "label": "URL"
+ },
+ {
+ "fieldname": "column_break_7",
+ "fieldtype": "Column Break"
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "links": [],
+ "modified": "2021-01-13 12:06:57.253111",
+ "modified_by": "Administrator",
+ "module": "Regional",
+ "name": "E Invoice Request Log",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1
+ },
+ {
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Accounts User",
+ "share": 1
+ },
+ {
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Accounts Manager",
+ "share": 1
+ }
+ ],
+ "sort_field": "modified",
+ "sort_order": "DESC"
+}
\ No newline at end of file
diff --git a/erpnext/regional/doctype/e_invoice_request_log/e_invoice_request_log.py b/erpnext/regional/doctype/e_invoice_request_log/e_invoice_request_log.py
new file mode 100644
index 0000000..c89552d
--- /dev/null
+++ b/erpnext/regional/doctype/e_invoice_request_log/e_invoice_request_log.py
@@ -0,0 +1,10 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class EInvoiceRequestLog(Document):
+ pass
diff --git a/erpnext/regional/doctype/e_invoice_request_log/test_e_invoice_request_log.py b/erpnext/regional/doctype/e_invoice_request_log/test_e_invoice_request_log.py
new file mode 100644
index 0000000..091cc88e
--- /dev/null
+++ b/erpnext/regional/doctype/e_invoice_request_log/test_e_invoice_request_log.py
@@ -0,0 +1,11 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
+# See license.txt
+from __future__ import unicode_literals
+
+# import frappe
+import unittest
+
+
+class TestEInvoiceRequestLog(unittest.TestCase):
+ pass
diff --git a/erpnext/patches/v14_0/__init__.py b/erpnext/regional/doctype/e_invoice_settings/__init__.py
similarity index 100%
copy from erpnext/patches/v14_0/__init__.py
copy to erpnext/regional/doctype/e_invoice_settings/__init__.py
diff --git a/erpnext/regional/doctype/e_invoice_settings/e_invoice_settings.js b/erpnext/regional/doctype/e_invoice_settings/e_invoice_settings.js
new file mode 100644
index 0000000..54e4886
--- /dev/null
+++ b/erpnext/regional/doctype/e_invoice_settings/e_invoice_settings.js
@@ -0,0 +1,11 @@
+// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on('E Invoice Settings', {
+ refresh(frm) {
+ const docs_link = 'https://docs.erpnext.com/docs/v13/user/manual/en/regional/india/setup-e-invoicing';
+ frm.dashboard.set_headline(
+ __("Read {0} for more information on E Invoicing features.", [`<a href='${docs_link}'>documentation</a>`])
+ );
+ }
+});
diff --git a/erpnext/regional/doctype/e_invoice_settings/e_invoice_settings.json b/erpnext/regional/doctype/e_invoice_settings/e_invoice_settings.json
new file mode 100644
index 0000000..16b2963
--- /dev/null
+++ b/erpnext/regional/doctype/e_invoice_settings/e_invoice_settings.json
@@ -0,0 +1,97 @@
+{
+ "actions": [],
+ "creation": "2020-09-24 16:23:16.235722",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "enable",
+ "section_break_2",
+ "sandbox_mode",
+ "applicable_from",
+ "credentials",
+ "advanced_settings_section",
+ "client_id",
+ "column_break_8",
+ "client_secret",
+ "auth_token",
+ "token_expiry"
+ ],
+ "fields": [
+ {
+ "default": "0",
+ "fieldname": "enable",
+ "fieldtype": "Check",
+ "label": "Enable"
+ },
+ {
+ "depends_on": "enable",
+ "fieldname": "section_break_2",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "auth_token",
+ "fieldtype": "Data",
+ "hidden": 1,
+ "read_only": 1
+ },
+ {
+ "fieldname": "token_expiry",
+ "fieldtype": "Datetime",
+ "hidden": 1,
+ "read_only": 1
+ },
+ {
+ "fieldname": "credentials",
+ "fieldtype": "Table",
+ "label": "Credentials",
+ "mandatory_depends_on": "enable",
+ "options": "E Invoice User"
+ },
+ {
+ "default": "0",
+ "fieldname": "sandbox_mode",
+ "fieldtype": "Check",
+ "label": "Sandbox Mode"
+ },
+ {
+ "fieldname": "applicable_from",
+ "fieldtype": "Date",
+ "in_list_view": 1,
+ "label": "Applicable From",
+ "reqd": 1
+ },
+ {
+ "collapsible": 1,
+ "fieldname": "advanced_settings_section",
+ "fieldtype": "Section Break",
+ "label": "Advanced Settings"
+ },
+ {
+ "fieldname": "client_id",
+ "fieldtype": "Data",
+ "label": "Client ID"
+ },
+ {
+ "fieldname": "client_secret",
+ "fieldtype": "Password",
+ "label": "Client Secret"
+ },
+ {
+ "fieldname": "column_break_8",
+ "fieldtype": "Column Break"
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "issingle": 1,
+ "links": [],
+ "modified": "2021-11-16 19:50:28.029517",
+ "modified_by": "Administrator",
+ "module": "Regional",
+ "name": "E Invoice Settings",
+ "owner": "Administrator",
+ "permissions": [],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "track_changes": 1
+}
\ No newline at end of file
diff --git a/erpnext/regional/doctype/e_invoice_settings/e_invoice_settings.py b/erpnext/regional/doctype/e_invoice_settings/e_invoice_settings.py
new file mode 100644
index 0000000..342d583
--- /dev/null
+++ b/erpnext/regional/doctype/e_invoice_settings/e_invoice_settings.py
@@ -0,0 +1,13 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe import _
+from frappe.model.document import Document
+
+
+class EInvoiceSettings(Document):
+ def validate(self):
+ if self.enable and not self.credentials:
+ frappe.throw(_('You must add atleast one credentials to be able to use E Invoicing.'))
diff --git a/erpnext/regional/doctype/e_invoice_settings/test_e_invoice_settings.py b/erpnext/regional/doctype/e_invoice_settings/test_e_invoice_settings.py
new file mode 100644
index 0000000..10770de
--- /dev/null
+++ b/erpnext/regional/doctype/e_invoice_settings/test_e_invoice_settings.py
@@ -0,0 +1,11 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
+# See license.txt
+from __future__ import unicode_literals
+
+# import frappe
+import unittest
+
+
+class TestEInvoiceSettings(unittest.TestCase):
+ pass
diff --git a/erpnext/patches/v14_0/__init__.py b/erpnext/regional/doctype/e_invoice_user/__init__.py
similarity index 100%
copy from erpnext/patches/v14_0/__init__.py
copy to erpnext/regional/doctype/e_invoice_user/__init__.py
diff --git a/erpnext/regional/doctype/e_invoice_user/e_invoice_user.json b/erpnext/regional/doctype/e_invoice_user/e_invoice_user.json
new file mode 100644
index 0000000..a65b1ca
--- /dev/null
+++ b/erpnext/regional/doctype/e_invoice_user/e_invoice_user.json
@@ -0,0 +1,57 @@
+{
+ "actions": [],
+ "creation": "2020-12-22 15:02:46.229474",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "company",
+ "gstin",
+ "username",
+ "password"
+ ],
+ "fields": [
+ {
+ "fieldname": "gstin",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "GSTIN",
+ "reqd": 1
+ },
+ {
+ "fieldname": "username",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Username",
+ "reqd": 1
+ },
+ {
+ "fieldname": "password",
+ "fieldtype": "Password",
+ "in_list_view": 1,
+ "label": "Password",
+ "reqd": 1
+ },
+ {
+ "fieldname": "company",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Company",
+ "options": "Company",
+ "reqd": 1
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2021-03-22 12:16:56.365616",
+ "modified_by": "Administrator",
+ "module": "Regional",
+ "name": "E Invoice User",
+ "owner": "Administrator",
+ "permissions": [],
+ "quick_entry": 1,
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "track_changes": 1
+}
\ No newline at end of file
diff --git a/erpnext/regional/doctype/e_invoice_user/e_invoice_user.py b/erpnext/regional/doctype/e_invoice_user/e_invoice_user.py
new file mode 100644
index 0000000..4e0e89c
--- /dev/null
+++ b/erpnext/regional/doctype/e_invoice_user/e_invoice_user.py
@@ -0,0 +1,10 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class EInvoiceUser(Document):
+ pass
diff --git a/erpnext/patches/v14_0/__init__.py b/erpnext/regional/india/e_invoice/__init__.py
similarity index 100%
rename from erpnext/patches/v14_0/__init__.py
rename to erpnext/regional/india/e_invoice/__init__.py
diff --git a/erpnext/regional/india/e_invoice/einv_item_template.json b/erpnext/regional/india/e_invoice/einv_item_template.json
new file mode 100644
index 0000000..78e5651
--- /dev/null
+++ b/erpnext/regional/india/e_invoice/einv_item_template.json
@@ -0,0 +1,31 @@
+{{
+ "SlNo": "{item.sr_no}",
+ "PrdDesc": "{item.description}",
+ "IsServc": "{item.is_service_item}",
+ "HsnCd": "{item.gst_hsn_code}",
+ "Barcde": "{item.barcode}",
+ "Unit": "{item.uom}",
+ "Qty": "{item.qty}",
+ "FreeQty": "{item.free_qty}",
+ "UnitPrice": "{item.unit_rate}",
+ "TotAmt": "{item.gross_amount}",
+ "Discount": "{item.discount_amount}",
+ "AssAmt": "{item.taxable_value}",
+ "PrdSlNo": "{item.serial_no}",
+ "GstRt": "{item.tax_rate}",
+ "IgstAmt": "{item.igst_amount}",
+ "CgstAmt": "{item.cgst_amount}",
+ "SgstAmt": "{item.sgst_amount}",
+ "CesRt": "{item.cess_rate}",
+ "CesAmt": "{item.cess_amount}",
+ "CesNonAdvlAmt": "{item.cess_nadv_amount}",
+ "StateCesRt": "{item.state_cess_rate}",
+ "StateCesAmt": "{item.state_cess_amount}",
+ "StateCesNonAdvlAmt": "{item.state_cess_nadv_amount}",
+ "OthChrg": "{item.other_charges}",
+ "TotItemVal": "{item.total_value}",
+ "BchDtls": {{
+ "Nm": "{item.batch_no}",
+ "ExpDt": "{item.batch_expiry_date}"
+ }}
+}}
\ No newline at end of file
diff --git a/erpnext/regional/india/e_invoice/einv_template.json b/erpnext/regional/india/e_invoice/einv_template.json
new file mode 100644
index 0000000..c2a28f2
--- /dev/null
+++ b/erpnext/regional/india/e_invoice/einv_template.json
@@ -0,0 +1,110 @@
+{{
+ "Version": "1.1",
+ "TranDtls": {{
+ "TaxSch": "{transaction_details.tax_scheme}",
+ "SupTyp": "{transaction_details.supply_type}",
+ "RegRev": "{transaction_details.reverse_charge}",
+ "EcmGstin": "{transaction_details.ecom_gstin}",
+ "IgstOnIntra": "{transaction_details.igst_on_intra}"
+ }},
+ "DocDtls": {{
+ "Typ": "{doc_details.invoice_type}",
+ "No": "{doc_details.invoice_name}",
+ "Dt": "{doc_details.invoice_date}"
+ }},
+ "SellerDtls": {{
+ "Gstin": "{seller_details.gstin}",
+ "LglNm": "{seller_details.legal_name}",
+ "TrdNm": "{seller_details.trade_name}",
+ "Loc": "{seller_details.location}",
+ "Pin": "{seller_details.pincode}",
+ "Stcd": "{seller_details.state_code}",
+ "Addr1": "{seller_details.address_line1}",
+ "Addr2": "{seller_details.address_line2}",
+ "Ph": "{seller_details.phone}",
+ "Em": "{seller_details.email}"
+ }},
+ "BuyerDtls": {{
+ "Gstin": "{buyer_details.gstin}",
+ "LglNm": "{buyer_details.legal_name}",
+ "TrdNm": "{buyer_details.trade_name}",
+ "Addr1": "{buyer_details.address_line1}",
+ "Addr2": "{buyer_details.address_line2}",
+ "Loc": "{buyer_details.location}",
+ "Pin": "{buyer_details.pincode}",
+ "Stcd": "{buyer_details.state_code}",
+ "Ph": "{buyer_details.phone}",
+ "Em": "{buyer_details.email}",
+ "Pos": "{buyer_details.place_of_supply}"
+ }},
+ "DispDtls": {{
+ "Nm": "{dispatch_details.legal_name}",
+ "Addr1": "{dispatch_details.address_line1}",
+ "Addr2": "{dispatch_details.address_line2}",
+ "Loc": "{dispatch_details.location}",
+ "Pin": "{dispatch_details.pincode}",
+ "Stcd": "{dispatch_details.state_code}"
+ }},
+ "ShipDtls": {{
+ "Gstin": "{shipping_details.gstin}",
+ "LglNm": "{shipping_details.legal_name}",
+ "TrdNm": "{shipping_details.trader_name}",
+ "Addr1": "{shipping_details.address_line1}",
+ "Addr2": "{shipping_details.address_line2}",
+ "Loc": "{shipping_details.location}",
+ "Pin": "{shipping_details.pincode}",
+ "Stcd": "{shipping_details.state_code}"
+ }},
+ "ItemList": [
+ {item_list}
+ ],
+ "ValDtls": {{
+ "AssVal": "{invoice_value_details.base_total}",
+ "CgstVal": "{invoice_value_details.total_cgst_amt}",
+ "SgstVal": "{invoice_value_details.total_sgst_amt}",
+ "IgstVal": "{invoice_value_details.total_igst_amt}",
+ "CesVal": "{invoice_value_details.total_cess_amt}",
+ "Discount": "{invoice_value_details.invoice_discount_amt}",
+ "RndOffAmt": "{invoice_value_details.round_off}",
+ "OthChrg": "{invoice_value_details.total_other_charges}",
+ "TotInvVal": "{invoice_value_details.base_grand_total}",
+ "TotInvValFc": "{invoice_value_details.grand_total}"
+ }},
+ "PayDtls": {{
+ "Nm": "{payment_details.payee_name}",
+ "AccDet": "{payment_details.account_no}",
+ "Mode": "{payment_details.mode_of_payment}",
+ "FinInsBr": "{payment_details.ifsc_code}",
+ "PayTerm": "{payment_details.terms}",
+ "PaidAmt": "{payment_details.paid_amount}",
+ "PaymtDue": "{payment_details.outstanding_amount}"
+ }},
+ "RefDtls": {{
+ "DocPerdDtls": {{
+ "InvStDt": "{period_details.start_date}",
+ "InvEndDt": "{period_details.end_date}"
+ }},
+ "PrecDocDtls": [{{
+ "InvNo": "{prev_doc_details.invoice_name}",
+ "InvDt": "{prev_doc_details.invoice_date}"
+ }}]
+ }},
+ "ExpDtls": {{
+ "ShipBNo": "{export_details.bill_no}",
+ "ShipBDt": "{export_details.bill_date}",
+ "Port": "{export_details.port}",
+ "ForCur": "{export_details.foreign_curr_code}",
+ "CntCode": "{export_details.country_code}",
+ "ExpDuty": "{export_details.export_duty}"
+ }},
+ "EwbDtls": {{
+ "TransId": "{eway_bill_details.gstin}",
+ "TransName": "{eway_bill_details.name}",
+ "TransMode": "{eway_bill_details.mode_of_transport}",
+ "Distance": "{eway_bill_details.distance}",
+ "TransDocNo": "{eway_bill_details.document_name}",
+ "TransDocDt": "{eway_bill_details.document_date}",
+ "VehNo": "{eway_bill_details.vehicle_no}",
+ "VehType": "{eway_bill_details.vehicle_type}"
+ }}
+}}
\ No newline at end of file
diff --git a/erpnext/regional/india/e_invoice/einv_validation.json b/erpnext/regional/india/e_invoice/einv_validation.json
new file mode 100644
index 0000000..f4a3542
--- /dev/null
+++ b/erpnext/regional/india/e_invoice/einv_validation.json
@@ -0,0 +1,957 @@
+{
+ "Version": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 6,
+ "description": "Version of the schema"
+ },
+ "Irn": {
+ "type": "string",
+ "minLength": 64,
+ "maxLength": 64,
+ "description": "Invoice Reference Number"
+ },
+ "TranDtls": {
+ "type": "object",
+ "properties": {
+ "TaxSch": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 10,
+ "enum": ["GST"],
+ "description": "GST- Goods and Services Tax Scheme"
+ },
+ "SupTyp": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 10,
+ "enum": ["B2B", "SEZWP", "SEZWOP", "EXPWP", "EXPWOP", "DEXP"],
+ "description": "Type of Supply: B2B-Business to Business, SEZWP - SEZ with payment, SEZWOP - SEZ without payment, EXPWP - Export with Payment, EXPWOP - Export without payment,DEXP - Deemed Export"
+ },
+ "RegRev": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1,
+ "enum": ["Y", "N"],
+ "description": "Y- whether the tax liability is payable under reverse charge"
+ },
+ "EcmGstin": {
+ "type": "string",
+ "minLength": 15,
+ "maxLength": 15,
+ "pattern": "([0-9]{2}[0-9A-Z]{13})",
+ "description": "E-Commerce GSTIN",
+ "validationMsg": "E-Commerce GSTIN is invalid"
+ },
+ "IgstOnIntra": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1,
+ "enum": ["Y", "N"],
+ "description": "Y- indicates the supply is intra state but chargeable to IGST"
+ }
+ },
+ "required": ["TaxSch", "SupTyp"]
+ },
+ "DocDtls": {
+ "type": "object",
+ "properties": {
+ "Typ": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 3,
+ "enum": ["INV", "CRN", "DBN"],
+ "description": "Document Type"
+ },
+ "No": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 16,
+ "pattern": "^([A-Z1-9]{1}[A-Z0-9/-]{0,15})$",
+ "description": "Document Number",
+ "validationMsg": "Document Number should not be starting with 0, / and -"
+ },
+ "Dt": {
+ "type": "string",
+ "minLength": 10,
+ "maxLength": 10,
+ "pattern": "[0-3][0-9]/[0-1][0-9]/[2][0][1-2][0-9]",
+ "description": "Document Date"
+ }
+ },
+ "required": ["Typ", "No", "Dt"]
+ },
+ "SellerDtls": {
+ "type": "object",
+ "properties": {
+ "Gstin": {
+ "type": "string",
+ "minLength": 15,
+ "maxLength": 15,
+ "pattern": "([0-9]{2}[0-9A-Z]{13})",
+ "description": "Supplier GSTIN",
+ "validationMsg": "Company GSTIN is invalid"
+ },
+ "LglNm": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 100,
+ "description": "Legal Name"
+ },
+ "TrdNm": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 100,
+ "description": "Tradename"
+ },
+ "Addr1": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 100,
+ "description": "Address Line 1"
+ },
+ "Addr2": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 100,
+ "description": "Address Line 2"
+ },
+ "Loc": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 50,
+ "description": "Location"
+ },
+ "Pin": {
+ "type": "number",
+ "minimum": 100000,
+ "maximum": 999999,
+ "description": "Pincode"
+ },
+ "Stcd": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 2,
+ "description": "Supplier State Code"
+ },
+ "Ph": {
+ "type": "string",
+ "minLength": 6,
+ "maxLength": 12,
+ "description": "Phone"
+ },
+ "Em": {
+ "type": "string",
+ "minLength": 6,
+ "maxLength": 100,
+ "description": "Email-Id"
+ }
+ },
+ "required": ["Gstin", "LglNm", "Addr1", "Loc", "Pin", "Stcd"]
+ },
+ "BuyerDtls": {
+ "type": "object",
+ "properties": {
+ "Gstin": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 15,
+ "pattern": "^(([0-9]{2}[0-9A-Z]{13})|URP)$",
+ "description": "Buyer GSTIN",
+ "validationMsg": "Customer GSTIN is invalid"
+ },
+ "LglNm": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 100,
+ "description": "Legal Name"
+ },
+ "TrdNm": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 100,
+ "description": "Trade Name"
+ },
+ "Pos": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 2,
+ "description": "Place of Supply State code"
+ },
+ "Addr1": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 100,
+ "description": "Address Line 1"
+ },
+ "Addr2": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 100,
+ "description": "Address Line 2"
+ },
+ "Loc": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 100,
+ "description": "Location"
+ },
+ "Pin": {
+ "type": "number",
+ "minimum": 100000,
+ "maximum": 999999,
+ "description": "Pincode"
+ },
+ "Stcd": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 2,
+ "description": "Buyer State Code"
+ },
+ "Ph": {
+ "type": "string",
+ "minLength": 6,
+ "maxLength": 12,
+ "description": "Phone"
+ },
+ "Em": {
+ "type": "string",
+ "minLength": 6,
+ "maxLength": 100,
+ "description": "Email-Id"
+ }
+ },
+ "required": ["Gstin", "LglNm", "Pos", "Addr1", "Loc", "Stcd"]
+ },
+ "DispDtls": {
+ "type": "object",
+ "properties": {
+ "Nm": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 100,
+ "description": "Dispatch Address Name"
+ },
+ "Addr1": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 100,
+ "description": "Address Line 1"
+ },
+ "Addr2": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 100,
+ "description": "Address Line 2"
+ },
+ "Loc": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 100,
+ "description": "Location"
+ },
+ "Pin": {
+ "type": "number",
+ "minimum": 100000,
+ "maximum": 999999,
+ "description": "Pincode"
+ },
+ "Stcd": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 2,
+ "description": "State Code"
+ }
+ },
+ "required": ["Nm", "Addr1", "Loc", "Pin", "Stcd"]
+ },
+ "ShipDtls": {
+ "type": "object",
+ "properties": {
+ "Gstin": {
+ "type": "string",
+ "maxLength": 15,
+ "minLength": 3,
+ "pattern": "^(([0-9]{2}[0-9A-Z]{13})|URP)$",
+ "description": "Shipping Address GSTIN",
+ "validationMsg": "Shipping Address GSTIN is invalid"
+ },
+ "LglNm": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 100,
+ "description": "Legal Name"
+ },
+ "TrdNm": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 100,
+ "description": "Trade Name"
+ },
+ "Addr1": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 100,
+ "description": "Address Line 1"
+ },
+ "Addr2": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 100,
+ "description": "Address Line 2"
+ },
+ "Loc": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 100,
+ "description": "Location"
+ },
+ "Pin": {
+ "type": "number",
+ "minimum": 100000,
+ "maximum": 999999,
+ "description": "Pincode"
+ },
+ "Stcd": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 2,
+ "description": "State Code"
+ }
+ },
+ "required": ["LglNm", "Addr1", "Loc", "Pin", "Stcd"]
+ },
+ "ItemList": {
+ "type": "Array",
+ "properties": {
+ "SlNo": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 6,
+ "description": "Serial No. of Item"
+ },
+ "PrdDesc": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 300,
+ "description": "Item Name"
+ },
+ "IsServc": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1,
+ "enum": ["Y", "N"],
+ "description": "Is Service Item"
+ },
+ "HsnCd": {
+ "type": "string",
+ "minLength": 4,
+ "maxLength": 8,
+ "description": "HSN Code"
+ },
+ "Barcde": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 30,
+ "description": "Barcode"
+ },
+ "Qty": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 9999999999.999,
+ "description": "Quantity"
+ },
+ "FreeQty": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 9999999999.999,
+ "description": "Free Quantity"
+ },
+ "Unit": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 8,
+ "description": "UOM"
+ },
+ "UnitPrice": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 999999999999.999,
+ "description": "Rate"
+ },
+ "TotAmt": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 999999999999.99,
+ "description": "Gross Amount"
+ },
+ "Discount": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 999999999999.99,
+ "description": "Discount"
+ },
+ "PreTaxVal": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 999999999999.99,
+ "description": "Pre tax value"
+ },
+ "AssAmt": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 999999999999.99,
+ "description": "Taxable Value"
+ },
+ "GstRt": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 999.999,
+ "description": "GST Rate"
+ },
+ "IgstAmt": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 999999999999.99,
+ "description": "IGST Amount"
+ },
+ "CgstAmt": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 999999999999.99,
+ "description": "CGST Amount"
+ },
+ "SgstAmt": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 999999999999.99,
+ "description": "SGST Amount"
+ },
+ "CesRt": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 999.999,
+ "description": "Cess Rate"
+ },
+ "CesAmt": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 999999999999.99,
+ "description": "Cess Amount (Advalorem)"
+ },
+ "CesNonAdvlAmt": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 999999999999.99,
+ "description": "Cess Amount (Non-Advalorem)"
+ },
+ "StateCesRt": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 999.999,
+ "description": "State CESS Rate"
+ },
+ "StateCesAmt": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 999999999999.99,
+ "description": "State CESS Amount"
+ },
+ "StateCesNonAdvlAmt": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 999999999999.99,
+ "description": "State CESS Amount (Non Advalorem)"
+ },
+ "OthChrg": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 999999999999.99,
+ "description": "Other Charges"
+ },
+ "TotItemVal": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 999999999999.99,
+ "description": "Total Item Value"
+ },
+ "OrdLineRef": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 50,
+ "description": "Order line reference"
+ },
+ "OrgCntry": {
+ "type": "string",
+ "minLength": 2,
+ "maxLength": 2,
+ "description": "Origin Country"
+ },
+ "PrdSlNo": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 20,
+ "description": "Serial number"
+ },
+ "BchDtls": {
+ "type": "object",
+ "properties": {
+ "Nm": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 20,
+ "description": "Batch number"
+ },
+ "ExpDt": {
+ "type": "string",
+ "maxLength": 10,
+ "minLength": 10,
+ "pattern": "[0-3][0-9]/[0-1][0-9]/[2][0][1-2][0-9]",
+ "description": "Batch Expiry Date"
+ },
+ "WrDt": {
+ "type": "string",
+ "maxLength": 10,
+ "minLength": 10,
+ "pattern": "[0-3][0-9]/[0-1][0-9]/[2][0][1-2][0-9]",
+ "description": "Warranty Date"
+ }
+ },
+ "required": ["Nm"]
+ },
+ "AttribDtls": {
+ "type": "Array",
+ "Attribute": {
+ "type": "object",
+ "properties": {
+ "Nm": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 100,
+ "description": "Attribute name of the item"
+ },
+ "Val": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 100,
+ "description": "Attribute value of the item"
+ }
+ }
+ }
+ }
+ },
+ "required": [
+ "SlNo",
+ "IsServc",
+ "HsnCd",
+ "UnitPrice",
+ "TotAmt",
+ "AssAmt",
+ "GstRt",
+ "TotItemVal"
+ ]
+ },
+ "ValDtls": {
+ "type": "object",
+ "properties": {
+ "AssVal": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 99999999999999.99,
+ "description": "Total Assessable value of all items"
+ },
+ "CgstVal": {
+ "type": "number",
+ "maximum": 99999999999999.99,
+ "minimum": 0,
+ "description": "Total CGST value of all items"
+ },
+ "SgstVal": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 99999999999999.99,
+ "description": "Total SGST value of all items"
+ },
+ "IgstVal": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 99999999999999.99,
+ "description": "Total IGST value of all items"
+ },
+ "CesVal": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 99999999999999.99,
+ "description": "Total CESS value of all items"
+ },
+ "StCesVal": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 99999999999999.99,
+ "description": "Total State CESS value of all items"
+ },
+ "Discount": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 99999999999999.99,
+ "description": "Invoice Discount"
+ },
+ "OthChrg": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 99999999999999.99,
+ "description": "Other Charges"
+ },
+ "RndOffAmt": {
+ "type": "number",
+ "minimum": -99.99,
+ "maximum": 99.99,
+ "description": "Rounded off Amount"
+ },
+ "TotInvVal": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 99999999999999.99,
+ "description": "Final Invoice Value "
+ },
+ "TotInvValFc": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 99999999999999.99,
+ "description": "Final Invoice value in Foreign Currency"
+ }
+ },
+ "required": ["AssVal", "TotInvVal"]
+ },
+ "PayDtls": {
+ "type": "object",
+ "properties": {
+ "Nm": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 100,
+ "description": "Payee Name"
+ },
+ "AccDet": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 18,
+ "description": "Bank Account Number of Payee"
+ },
+ "Mode": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 18,
+ "description": "Mode of Payment"
+ },
+ "FinInsBr": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 11,
+ "description": "Branch or IFSC code"
+ },
+ "PayTerm": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 100,
+ "description": "Terms of Payment"
+ },
+ "PayInstr": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 100,
+ "description": "Payment Instruction"
+ },
+ "CrTrn": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 100,
+ "description": "Credit Transfer"
+ },
+ "DirDr": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 100,
+ "description": "Direct Debit"
+ },
+ "CrDay": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 9999,
+ "description": "Credit Days"
+ },
+ "PaidAmt": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 99999999999999.99,
+ "description": "Advance Amount"
+ },
+ "PaymtDue": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 99999999999999.99,
+ "description": "Outstanding Amount"
+ }
+ }
+ },
+ "RefDtls": {
+ "type": "object",
+ "properties": {
+ "InvRm": {
+ "type": "string",
+ "maxLength": 100,
+ "minLength": 3,
+ "pattern": "^[0-9A-Za-z/-]{3,100}$",
+ "description": "Remarks/Note"
+ },
+ "DocPerdDtls": {
+ "type": "object",
+ "properties": {
+ "InvStDt": {
+ "type": "string",
+ "maxLength": 10,
+ "minLength": 10,
+ "pattern": "[0-3][0-9]/[0-1][0-9]/[2][0][1-2][0-9]",
+ "description": "Invoice Period Start Date"
+ },
+ "InvEndDt": {
+ "type": "string",
+ "maxLength": 10,
+ "minLength": 10,
+ "pattern": "[0-3][0-9]/[0-1][0-9]/[2][0][1-2][0-9]",
+ "description": "Invoice Period End Date"
+ }
+ },
+ "required": ["InvStDt ", "InvEndDt "]
+ },
+ "PrecDocDtls": {
+ "type": "object",
+ "properties": {
+ "InvNo": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 16,
+ "pattern": "^[1-9A-Z]{1}[0-9A-Z/-]{1,15}$",
+ "description": "Reference of Original Invoice"
+ },
+ "InvDt": {
+ "type": "string",
+ "maxLength": 10,
+ "minLength": 10,
+ "pattern": "[0-3][0-9]/[0-1][0-9]/[2][0][1-2][0-9]",
+ "description": "Date of Orginal Invoice"
+ },
+ "OthRefNo": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 20,
+ "description": "Other Reference"
+ }
+ }
+ },
+ "required": ["InvNo", "InvDt"],
+ "ContrDtls": {
+ "type": "object",
+ "properties": {
+ "RecAdvRefr": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 20,
+ "pattern": "^([0-9A-Za-z/-]){1,20}$",
+ "description": "Receipt Advice No."
+ },
+ "RecAdvDt": {
+ "type": "string",
+ "minLength": 10,
+ "maxLength": 10,
+ "pattern": "[0-3][0-9]/[0-1][0-9]/[2][0][1-2][0-9]",
+ "description": "Date of receipt advice"
+ },
+ "TendRefr": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 20,
+ "pattern": "^([0-9A-Za-z/-]){1,20}$",
+ "description": "Lot/Batch Reference No."
+ },
+ "ContrRefr": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 20,
+ "pattern": "^([0-9A-Za-z/-]){1,20}$",
+ "description": "Contract Reference Number"
+ },
+ "ExtRefr": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 20,
+ "pattern": "^([0-9A-Za-z/-]){1,20}$",
+ "description": "Any other reference"
+ },
+ "ProjRefr": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 20,
+ "pattern": "^([0-9A-Za-z/-]){1,20}$",
+ "description": "Project Reference Number"
+ },
+ "PORefr": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 16,
+ "pattern": "^([0-9A-Za-z/-]){1,16}$",
+ "description": "PO Reference Number"
+ },
+ "PORefDt": {
+ "type": "string",
+ "minLength": 10,
+ "maxLength": 10,
+ "pattern": "[0-3][0-9]/[0-1][0-9]/[2][0][1-2][0-9]",
+ "description": "PO Reference date"
+ }
+ }
+ }
+ }
+ },
+ "AddlDocDtls": {
+ "type": "Array",
+ "properties": {
+ "Url": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 100,
+ "description": "Supporting document URL"
+ },
+ "Docs": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 1000,
+ "description": "Supporting document in Base64 Format"
+ },
+ "Info": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 1000,
+ "description": "Any additional information"
+ }
+ }
+ },
+
+ "ExpDtls": {
+ "type": "object",
+ "properties": {
+ "ShipBNo": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 20,
+ "description": "Shipping Bill No."
+ },
+ "ShipBDt": {
+ "type": "string",
+ "minLength": 10,
+ "maxLength": 10,
+ "pattern": "[0-3][0-9]/[0-1][0-9]/[2][0][1-2][0-9]",
+ "description": "Shipping Bill Date"
+ },
+ "Port": {
+ "type": "string",
+ "minLength": 2,
+ "maxLength": 10,
+ "pattern": "^[0-9A-Za-z]{2,10}$",
+ "description": "Port Code. Refer the master"
+ },
+ "RefClm": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1,
+ "description": "Claiming Refund. Y/N"
+ },
+ "ForCur": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 16,
+ "description": "Additional Currency Code. Refer the master"
+ },
+ "CntCode": {
+ "type": "string",
+ "minLength": 2,
+ "maxLength": 2,
+ "description": "Country Code. Refer the master"
+ },
+ "ExpDuty": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 999999999999.99,
+ "description": "Export Duty"
+ }
+ }
+ },
+ "EwbDtls": {
+ "type": "object",
+ "properties": {
+ "TransId": {
+ "type": "string",
+ "minLength": 15,
+ "maxLength": 15,
+ "description": "Transporter GSTIN"
+ },
+ "TransName": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 100,
+ "description": "Transporter Name"
+ },
+ "TransMode": {
+ "type": "string",
+ "maxLength": 1,
+ "minLength": 1,
+ "enum": ["1", "2", "3", "4"],
+ "description": "Mode of Transport"
+ },
+ "Distance": {
+ "type": "number",
+ "minimum": 1,
+ "maximum": 9999,
+ "description": "Distance"
+ },
+ "TransDocNo": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 15,
+ "pattern": "^([0-9A-Z/-]){1,15}$",
+ "description": "Tranport Document Number",
+ "validationMsg": "Transport Receipt No is invalid"
+ },
+ "TransDocDt": {
+ "type": "string",
+ "minLength": 10,
+ "maxLength": 10,
+ "pattern": "[0-3][0-9]/[0-1][0-9]/[2][0][1-2][0-9]",
+ "description": "Transport Document Date"
+ },
+ "VehNo": {
+ "type": "string",
+ "minLength": 4,
+ "maxLength": 20,
+ "description": "Vehicle Number"
+ },
+ "VehType": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1,
+ "enum": ["O", "R"],
+ "description": "Vehicle Type"
+ }
+ },
+ "required": ["Distance"]
+ },
+ "required": [
+ "Version",
+ "TranDtls",
+ "DocDtls",
+ "SellerDtls",
+ "BuyerDtls",
+ "ItemList",
+ "ValDtls"
+ ]
+}
diff --git a/erpnext/regional/india/e_invoice/einvoice.js b/erpnext/regional/india/e_invoice/einvoice.js
new file mode 100644
index 0000000..348f0c6
--- /dev/null
+++ b/erpnext/regional/india/e_invoice/einvoice.js
@@ -0,0 +1,292 @@
+erpnext.setup_einvoice_actions = (doctype) => {
+ frappe.ui.form.on(doctype, {
+ async refresh(frm) {
+ if (frm.doc.docstatus == 2) return;
+
+ const res = await frappe.call({
+ method: 'erpnext.regional.india.e_invoice.utils.validate_eligibility',
+ args: { doc: frm.doc }
+ });
+ const invoice_eligible = res.message;
+
+ if (!invoice_eligible) return;
+
+ const { doctype, irn, irn_cancelled, ewaybill, eway_bill_cancelled, name, __unsaved } = frm.doc;
+
+ const add_custom_button = (label, action) => {
+ if (!frm.custom_buttons[label]) {
+ frm.add_custom_button(label, action, __('E Invoicing'));
+ }
+ };
+
+ if (!irn && !__unsaved) {
+ const action = () => {
+ if (frm.doc.__unsaved) {
+ frappe.throw(__('Please save the document to generate IRN.'));
+ }
+ frappe.call({
+ method: 'erpnext.regional.india.e_invoice.utils.get_einvoice',
+ args: { doctype, docname: name },
+ freeze: true,
+ callback: (res) => {
+ const einvoice = res.message;
+ show_einvoice_preview(frm, einvoice);
+ }
+ });
+ };
+
+ add_custom_button(__("Generate IRN"), action);
+ }
+
+ if (irn && !irn_cancelled && !ewaybill) {
+ const fields = [
+ {
+ "label": "Reason",
+ "fieldname": "reason",
+ "fieldtype": "Select",
+ "reqd": 1,
+ "default": "1-Duplicate",
+ "options": ["1-Duplicate", "2-Data Entry Error", "3-Order Cancelled", "4-Other"]
+ },
+ {
+ "label": "Remark",
+ "fieldname": "remark",
+ "fieldtype": "Data",
+ "reqd": 1
+ }
+ ];
+ const action = () => {
+ const d = new frappe.ui.Dialog({
+ title: __("Cancel IRN"),
+ fields: fields,
+ primary_action: function() {
+ const data = d.get_values();
+ frappe.call({
+ method: 'erpnext.regional.india.e_invoice.utils.cancel_irn',
+ args: {
+ doctype,
+ docname: name,
+ irn: irn,
+ reason: data.reason.split('-')[0],
+ remark: data.remark
+ },
+ freeze: true,
+ callback: () => frm.reload_doc() || d.hide(),
+ error: () => d.hide()
+ });
+ },
+ primary_action_label: __('Submit')
+ });
+ d.show();
+ };
+ add_custom_button(__("Cancel IRN"), action);
+ }
+
+ if (irn && !irn_cancelled && !ewaybill) {
+ const action = () => {
+ const d = new frappe.ui.Dialog({
+ title: __('Generate E-Way Bill'),
+ size: "large",
+ fields: get_ewaybill_fields(frm),
+ primary_action: function() {
+ const data = d.get_values();
+ frappe.call({
+ method: 'erpnext.regional.india.e_invoice.utils.generate_eway_bill',
+ args: {
+ doctype,
+ docname: name,
+ irn,
+ ...data
+ },
+ freeze: true,
+ callback: () => frm.reload_doc() || d.hide(),
+ error: () => d.hide()
+ });
+ },
+ primary_action_label: __('Submit')
+ });
+ d.show();
+ };
+
+ add_custom_button(__("Generate E-Way Bill"), action);
+ }
+
+ if (irn && ewaybill && !irn_cancelled && !eway_bill_cancelled) {
+ const action = () => {
+ let message = __('Cancellation of e-way bill is currently not supported.') + ' ';
+ message += '<br><br>';
+ message += __('You must first use the portal to cancel the e-way bill and then update the cancelled status in the ERPNext system.');
+
+ const dialog = frappe.msgprint({
+ title: __('Update E-Way Bill Cancelled Status?'),
+ message: message,
+ indicator: 'orange',
+ primary_action: {
+ action: function() {
+ frappe.call({
+ method: 'erpnext.regional.india.e_invoice.utils.cancel_eway_bill',
+ args: { doctype, docname: name },
+ freeze: true,
+ callback: () => frm.reload_doc() || dialog.hide()
+ });
+ }
+ },
+ primary_action_label: __('Yes')
+ });
+ };
+ add_custom_button(__("Cancel E-Way Bill"), action);
+ }
+ }
+ });
+};
+
+const get_ewaybill_fields = (frm) => {
+ return [
+ {
+ 'fieldname': 'transporter',
+ 'label': 'Transporter',
+ 'fieldtype': 'Link',
+ 'options': 'Supplier',
+ 'default': frm.doc.transporter
+ },
+ {
+ 'fieldname': 'gst_transporter_id',
+ 'label': 'GST Transporter ID',
+ 'fieldtype': 'Data',
+ 'fetch_from': 'transporter.gst_transporter_id',
+ 'default': frm.doc.gst_transporter_id
+ },
+ {
+ 'fieldname': 'driver',
+ 'label': 'Driver',
+ 'fieldtype': 'Link',
+ 'options': 'Driver',
+ 'default': frm.doc.driver
+ },
+ {
+ 'fieldname': 'lr_no',
+ 'label': 'Transport Receipt No',
+ 'fieldtype': 'Data',
+ 'default': frm.doc.lr_no
+ },
+ {
+ 'fieldname': 'vehicle_no',
+ 'label': 'Vehicle No',
+ 'fieldtype': 'Data',
+ 'default': frm.doc.vehicle_no
+ },
+ {
+ 'fieldname': 'distance',
+ 'label': 'Distance (in km)',
+ 'fieldtype': 'Float',
+ 'default': frm.doc.distance
+ },
+ {
+ 'fieldname': 'transporter_col_break',
+ 'fieldtype': 'Column Break',
+ },
+ {
+ 'fieldname': 'transporter_name',
+ 'label': 'Transporter Name',
+ 'fieldtype': 'Data',
+ 'fetch_from': 'transporter.name',
+ 'read_only': 1,
+ 'default': frm.doc.transporter_name
+ },
+ {
+ 'fieldname': 'mode_of_transport',
+ 'label': 'Mode of Transport',
+ 'fieldtype': 'Select',
+ 'options': `\nRoad\nAir\nRail\nShip`,
+ 'default': frm.doc.mode_of_transport
+ },
+ {
+ 'fieldname': 'driver_name',
+ 'label': 'Driver Name',
+ 'fieldtype': 'Data',
+ 'fetch_from': 'driver.full_name',
+ 'read_only': 1,
+ 'default': frm.doc.driver_name
+ },
+ {
+ 'fieldname': 'lr_date',
+ 'label': 'Transport Receipt Date',
+ 'fieldtype': 'Date',
+ 'default': frm.doc.lr_date
+ },
+ {
+ 'fieldname': 'gst_vehicle_type',
+ 'label': 'GST Vehicle Type',
+ 'fieldtype': 'Select',
+ 'options': `Regular\nOver Dimensional Cargo (ODC)`,
+ 'depends_on': 'eval:(doc.mode_of_transport === "Road")',
+ 'default': frm.doc.gst_vehicle_type
+ }
+ ];
+};
+
+const request_irn_generation = (frm) => {
+ frappe.call({
+ method: 'erpnext.regional.india.e_invoice.utils.generate_irn',
+ args: { doctype: frm.doc.doctype, docname: frm.doc.name },
+ freeze: true,
+ callback: () => frm.reload_doc()
+ });
+};
+
+const get_preview_dialog = (frm, action) => {
+ const dialog = new frappe.ui.Dialog({
+ title: __("Preview"),
+ size: "large",
+ fields: [
+ {
+ "label": "Preview",
+ "fieldname": "preview_html",
+ "fieldtype": "HTML"
+ }
+ ],
+ primary_action: () => action(frm) || dialog.hide(),
+ primary_action_label: __('Generate IRN')
+ });
+ return dialog;
+};
+
+const show_einvoice_preview = (frm, einvoice) => {
+ const preview_dialog = get_preview_dialog(frm, request_irn_generation);
+
+ // initialize e-invoice fields
+ einvoice["Irn"] = einvoice["AckNo"] = ''; einvoice["AckDt"] = frappe.datetime.nowdate();
+ frm.doc.signed_einvoice = JSON.stringify(einvoice);
+
+ // initialize preview wrapper
+ const $preview_wrapper = preview_dialog.get_field("preview_html").$wrapper;
+ $preview_wrapper.html(
+ `<div>
+ <div class="print-preview">
+ <div class="print-format"></div>
+ </div>
+ <div class="page-break-message text-muted text-center text-medium margin-top"></div>
+ </div>`
+ );
+
+ frappe.call({
+ method: "frappe.www.printview.get_html_and_style",
+ args: {
+ doc: frm.doc,
+ print_format: "GST E-Invoice",
+ no_letterhead: 1
+ },
+ callback: function (r) {
+ if (!r.exc) {
+ $preview_wrapper.find(".print-format").html(r.message.html);
+ const style = `
+ .print-format { box-shadow: 0px 0px 5px rgba(0,0,0,0.2); padding: 0.30in; min-height: 80vh; }
+ .print-preview { min-height: 0px; }
+ .modal-dialog { width: 720px; }`;
+
+ frappe.dom.set_style(style, "custom-print-style");
+ preview_dialog.show();
+ }
+ }
+ });
+};
diff --git a/erpnext/regional/india/e_invoice/utils.py b/erpnext/regional/india/e_invoice/utils.py
new file mode 100644
index 0000000..e3f7e90
--- /dev/null
+++ b/erpnext/regional/india/e_invoice/utils.py
@@ -0,0 +1,1171 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+import base64
+import io
+import json
+import os
+import re
+import sys
+import traceback
+
+import frappe
+import jwt
+from frappe import _, bold
+from frappe.core.page.background_jobs.background_jobs import get_info
+from frappe.integrations.utils import make_get_request, make_post_request
+from frappe.utils.background_jobs import enqueue
+from frappe.utils.data import (
+ add_to_date,
+ cint,
+ cstr,
+ flt,
+ format_date,
+ get_link_to_form,
+ getdate,
+ now_datetime,
+ time_diff_in_hours,
+ time_diff_in_seconds,
+)
+from frappe.utils.scheduler import is_scheduler_inactive
+from pyqrcode import create as qrcreate
+
+from erpnext.regional.india.utils import get_gst_accounts, get_place_of_supply
+
+
+@frappe.whitelist()
+def validate_eligibility(doc):
+ if isinstance(doc, str):
+ doc = json.loads(doc)
+
+ invalid_doctype = doc.get('doctype') != 'Sales Invoice'
+ if invalid_doctype:
+ return False
+
+ einvoicing_enabled = cint(frappe.db.get_single_value('E Invoice Settings', 'enable'))
+ if not einvoicing_enabled:
+ return False
+
+ einvoicing_eligible_from = frappe.db.get_single_value('E Invoice Settings', 'applicable_from') or '2021-04-01'
+ if getdate(doc.get('posting_date')) < getdate(einvoicing_eligible_from):
+ return False
+
+ invalid_company = not frappe.db.get_value('E Invoice User', { 'company': doc.get('company') })
+ invalid_supply_type = doc.get('gst_category') not in ['Registered Regular', 'SEZ', 'Overseas', 'Deemed Export']
+ company_transaction = doc.get('billing_address_gstin') == doc.get('company_gstin')
+
+ # if export invoice, then taxes can be empty
+ # invoice can only be ineligible if no taxes applied and is not an export invoice
+ no_taxes_applied = not doc.get('taxes') and not doc.get('gst_category') == 'Overseas'
+ has_non_gst_item = any(d for d in doc.get('items', []) if d.get('is_non_gst'))
+
+ if invalid_company or invalid_supply_type or company_transaction or no_taxes_applied or has_non_gst_item:
+ return False
+
+ return True
+
+def validate_einvoice_fields(doc):
+ invoice_eligible = validate_eligibility(doc)
+
+ if not invoice_eligible:
+ return
+
+ if doc.docstatus == 0 and doc._action == 'save':
+ if doc.irn:
+ frappe.throw(_('You cannot edit the invoice after generating IRN'), title=_('Edit Not Allowed'))
+ if len(doc.name) > 16:
+ raise_document_name_too_long_error()
+
+ doc.einvoice_status = 'Pending'
+
+ elif doc.docstatus == 1 and doc._action == 'submit' and not doc.irn:
+ frappe.throw(_('You must generate IRN before submitting the document.'), title=_('Missing IRN'))
+
+ elif doc.irn and doc.docstatus == 2 and doc._action == 'cancel' and not doc.irn_cancelled:
+ frappe.throw(_('You must cancel IRN before cancelling the document.'), title=_('Cancel Not Allowed'))
+
+def raise_document_name_too_long_error():
+ title = _('Document ID Too Long')
+ msg = _('As you have E-Invoicing enabled, to be able to generate IRN for this invoice')
+ msg += ', '
+ msg += _('document id {} exceed 16 letters.').format(bold(_('should not')))
+ msg += '<br><br>'
+ msg += _('You must {} your {} in order to have document id of {} length 16.').format(
+ bold(_('modify')), bold(_('naming series')), bold(_('maximum'))
+ )
+ msg += _('Please account for ammended documents too.')
+ frappe.throw(msg, title=title)
+
+def read_json(name):
+ file_path = os.path.join(os.path.dirname(__file__), '{name}.json'.format(name=name))
+ with open(file_path, 'r') as f:
+ return cstr(f.read())
+
+def get_transaction_details(invoice):
+ supply_type = ''
+ if invoice.gst_category == 'Registered Regular': supply_type = 'B2B'
+ elif invoice.gst_category == 'SEZ': supply_type = 'SEZWOP'
+ elif invoice.gst_category == 'Overseas': supply_type = 'EXPWOP'
+ elif invoice.gst_category == 'Deemed Export': supply_type = 'DEXP'
+
+ if not supply_type:
+ rr, sez, overseas, export = bold('Registered Regular'), bold('SEZ'), bold('Overseas'), bold('Deemed Export')
+ frappe.throw(_('GST category should be one of {}, {}, {}, {}').format(rr, sez, overseas, export),
+ title=_('Invalid Supply Type'))
+
+ return frappe._dict(dict(
+ tax_scheme='GST',
+ supply_type=supply_type,
+ reverse_charge=invoice.reverse_charge
+ ))
+
+def get_doc_details(invoice):
+ if getdate(invoice.posting_date) < getdate('2021-01-01'):
+ frappe.throw(_('IRN generation is not allowed for invoices dated before 1st Jan 2021'), title=_('Not Allowed'))
+
+ invoice_type = 'CRN' if invoice.is_return else 'INV'
+
+ invoice_name = invoice.name
+ invoice_date = format_date(invoice.posting_date, 'dd/mm/yyyy')
+
+ return frappe._dict(dict(
+ invoice_type=invoice_type,
+ invoice_name=invoice_name,
+ invoice_date=invoice_date
+ ))
+
+def validate_address_fields(address, skip_gstin_validation):
+ if ((not address.gstin and not skip_gstin_validation)
+ or not address.city
+ or not address.pincode
+ or not address.address_title
+ or not address.address_line1
+ or not address.gst_state_number):
+
+ frappe.throw(
+ msg=_('Address Lines, City, Pincode, GSTIN are mandatory for address {}. Please set them and try again.').format(address.name),
+ title=_('Missing Address Fields')
+ )
+
+ if address.address_line2 and len(address.address_line2) < 2:
+ # to prevent "The field Address 2 must be a string with a minimum length of 3 and a maximum length of 100"
+ address.address_line2 = ""
+
+def get_party_details(address_name, skip_gstin_validation=False):
+ addr = frappe.get_doc('Address', address_name)
+
+ validate_address_fields(addr, skip_gstin_validation)
+
+ if addr.gst_state_number == 97:
+ # according to einvoice standard
+ addr.pincode = 999999
+
+ party_address_details = frappe._dict(dict(
+ legal_name=sanitize_for_json(addr.address_title),
+ location=sanitize_for_json(addr.city),
+ pincode=addr.pincode, gstin=addr.gstin,
+ state_code=addr.gst_state_number,
+ address_line1=sanitize_for_json(addr.address_line1),
+ address_line2=sanitize_for_json(addr.address_line2)
+ ))
+
+ return party_address_details
+
+def get_overseas_address_details(address_name):
+ address_title, address_line1, address_line2, city = frappe.db.get_value(
+ 'Address', address_name, ['address_title', 'address_line1', 'address_line2', 'city']
+ )
+
+ if not address_title or not address_line1 or not city:
+ frappe.throw(
+ msg=_('Address lines and city is mandatory for address {}. Please set them and try again.').format(
+ get_link_to_form('Address', address_name)
+ ),
+ title=_('Missing Address Fields')
+ )
+
+ return frappe._dict(dict(
+ gstin='URP',
+ legal_name=sanitize_for_json(address_title),
+ location=city,
+ address_line1=sanitize_for_json(address_line1),
+ address_line2=sanitize_for_json(address_line2),
+ pincode=999999, state_code=96, place_of_supply=96
+ ))
+
+def get_item_list(invoice):
+ item_list = []
+
+ for d in invoice.items:
+ einvoice_item_schema = read_json('einv_item_template')
+ item = frappe._dict({})
+ item.update(d.as_dict())
+
+ item.sr_no = d.idx
+ item.description = sanitize_for_json(d.item_name)
+
+ item.qty = abs(item.qty)
+ if flt(item.qty) != 0.0:
+ item.unit_rate = abs(item.taxable_value / item.qty)
+ else:
+ item.unit_rate = abs(item.taxable_value)
+ item.gross_amount = abs(item.taxable_value)
+ item.taxable_value = abs(item.taxable_value)
+ item.discount_amount = 0
+
+ item.batch_expiry_date = frappe.db.get_value('Batch', d.batch_no, 'expiry_date') if d.batch_no else None
+ item.batch_expiry_date = format_date(item.batch_expiry_date, 'dd/mm/yyyy') if item.batch_expiry_date else None
+ item.is_service_item = 'Y' if item.gst_hsn_code and item.gst_hsn_code[:2] == "99" else 'N'
+ item.serial_no = ""
+
+ item = update_item_taxes(invoice, item)
+
+ item.total_value = abs(
+ item.taxable_value + item.igst_amount + item.sgst_amount +
+ item.cgst_amount + item.cess_amount + item.cess_nadv_amount + item.other_charges
+ )
+ einv_item = einvoice_item_schema.format(item=item)
+ item_list.append(einv_item)
+
+ return ', '.join(item_list)
+
+def update_item_taxes(invoice, item):
+ gst_accounts = get_gst_accounts(invoice.company)
+ gst_accounts_list = [d for accounts in gst_accounts.values() for d in accounts if d]
+
+ for attr in [
+ 'tax_rate', 'cess_rate', 'cess_nadv_amount',
+ 'cgst_amount', 'sgst_amount', 'igst_amount',
+ 'cess_amount', 'cess_nadv_amount', 'other_charges'
+ ]:
+ item[attr] = 0
+
+ for t in invoice.taxes:
+ is_applicable = t.tax_amount and t.account_head in gst_accounts_list
+ if is_applicable:
+ # this contains item wise tax rate & tax amount (incl. discount)
+ item_tax_detail = json.loads(t.item_wise_tax_detail).get(item.item_code or item.item_name)
+
+ item_tax_rate = item_tax_detail[0]
+ # item tax amount excluding discount amount
+ item_tax_amount = (item_tax_rate / 100) * item.taxable_value
+
+ if t.account_head in gst_accounts.cess_account:
+ item_tax_amount_after_discount = item_tax_detail[1]
+ if t.charge_type == 'On Item Quantity':
+ item.cess_nadv_amount += abs(item_tax_amount_after_discount)
+ else:
+ item.cess_rate += item_tax_rate
+ item.cess_amount += abs(item_tax_amount_after_discount)
+
+ for tax_type in ['igst', 'cgst', 'sgst']:
+ if t.account_head in gst_accounts[f'{tax_type}_account']:
+ item.tax_rate += item_tax_rate
+ item[f'{tax_type}_amount'] += abs(item_tax_amount)
+ else:
+ # TODO: other charges per item
+ pass
+
+ return item
+
+def get_invoice_value_details(invoice):
+ invoice_value_details = frappe._dict(dict())
+ invoice_value_details.base_total = abs(sum([i.taxable_value for i in invoice.get('items')]))
+ invoice_value_details.invoice_discount_amt = 0
+
+ invoice_value_details.round_off = invoice.base_rounding_adjustment
+ invoice_value_details.base_grand_total = abs(invoice.base_rounded_total) or abs(invoice.base_grand_total)
+ invoice_value_details.grand_total = abs(invoice.rounded_total) or abs(invoice.grand_total)
+
+ invoice_value_details = update_invoice_taxes(invoice, invoice_value_details)
+
+ return invoice_value_details
+
+def update_invoice_taxes(invoice, invoice_value_details):
+ gst_accounts = get_gst_accounts(invoice.company)
+ gst_accounts_list = [d for accounts in gst_accounts.values() for d in accounts if d]
+
+ invoice_value_details.total_cgst_amt = 0
+ invoice_value_details.total_sgst_amt = 0
+ invoice_value_details.total_igst_amt = 0
+ invoice_value_details.total_cess_amt = 0
+ invoice_value_details.total_other_charges = 0
+ considered_rows = []
+
+ for t in invoice.taxes:
+ tax_amount = t.base_tax_amount_after_discount_amount
+ if t.account_head in gst_accounts_list:
+ if t.account_head in gst_accounts.cess_account:
+ # using after discount amt since item also uses after discount amt for cess calc
+ invoice_value_details.total_cess_amt += abs(t.base_tax_amount_after_discount_amount)
+
+ for tax_type in ['igst', 'cgst', 'sgst']:
+ if t.account_head in gst_accounts[f'{tax_type}_account']:
+
+ invoice_value_details[f'total_{tax_type}_amt'] += abs(tax_amount)
+ update_other_charges(t, invoice_value_details, gst_accounts_list, invoice, considered_rows)
+ else:
+ invoice_value_details.total_other_charges += abs(tax_amount)
+
+ return invoice_value_details
+
+def update_other_charges(tax_row, invoice_value_details, gst_accounts_list, invoice, considered_rows):
+ prev_row_id = cint(tax_row.row_id) - 1
+ if tax_row.account_head in gst_accounts_list and prev_row_id not in considered_rows:
+ if tax_row.charge_type == 'On Previous Row Amount':
+ amount = invoice.get('taxes')[prev_row_id].tax_amount_after_discount_amount
+ invoice_value_details.total_other_charges -= abs(amount)
+ considered_rows.append(prev_row_id)
+ if tax_row.charge_type == 'On Previous Row Total':
+ amount = invoice.get('taxes')[prev_row_id].base_total - invoice.base_net_total
+ invoice_value_details.total_other_charges -= abs(amount)
+ considered_rows.append(prev_row_id)
+
+def get_payment_details(invoice):
+ payee_name = invoice.company
+ mode_of_payment = ', '.join([d.mode_of_payment for d in invoice.payments])
+ paid_amount = invoice.base_paid_amount
+ outstanding_amount = invoice.outstanding_amount
+
+ return frappe._dict(dict(
+ payee_name=payee_name, mode_of_payment=mode_of_payment,
+ paid_amount=paid_amount, outstanding_amount=outstanding_amount
+ ))
+
+def get_return_doc_reference(invoice):
+ invoice_date = frappe.db.get_value('Sales Invoice', invoice.return_against, 'posting_date')
+ return frappe._dict(dict(
+ invoice_name=invoice.return_against, invoice_date=format_date(invoice_date, 'dd/mm/yyyy')
+ ))
+
+def get_eway_bill_details(invoice):
+ if invoice.is_return:
+ frappe.throw(_('E-Way Bill cannot be generated for Credit Notes & Debit Notes. Please clear fields in the Transporter Section of the invoice.'),
+ title=_('Invalid Fields'))
+
+
+ mode_of_transport = { '': '', 'Road': '1', 'Air': '2', 'Rail': '3', 'Ship': '4' }
+ vehicle_type = { 'Regular': 'R', 'Over Dimensional Cargo (ODC)': 'O' }
+
+ return frappe._dict(dict(
+ gstin=invoice.gst_transporter_id,
+ name=invoice.transporter_name,
+ mode_of_transport=mode_of_transport[invoice.mode_of_transport],
+ distance=invoice.distance or 0,
+ document_name=invoice.lr_no,
+ document_date=format_date(invoice.lr_date, 'dd/mm/yyyy'),
+ vehicle_no=invoice.vehicle_no,
+ vehicle_type=vehicle_type[invoice.gst_vehicle_type]
+ ))
+
+def validate_mandatory_fields(invoice):
+ if not invoice.company_address:
+ frappe.throw(
+ _('Company Address is mandatory to fetch company GSTIN details. Please set Company Address and try again.'),
+ title=_('Missing Fields')
+ )
+ if not invoice.customer_address:
+ frappe.throw(
+ _('Customer Address is mandatory to fetch customer GSTIN details. Please set Company Address and try again.'),
+ title=_('Missing Fields')
+ )
+ if not frappe.db.get_value('Address', invoice.company_address, 'gstin'):
+ frappe.throw(
+ _('GSTIN is mandatory to fetch company GSTIN details. Please enter GSTIN in selected company address.'),
+ title=_('Missing Fields')
+ )
+ if invoice.gst_category != 'Overseas' and not frappe.db.get_value('Address', invoice.customer_address, 'gstin'):
+ frappe.throw(
+ _('GSTIN is mandatory to fetch customer GSTIN details. Please enter GSTIN in selected customer address.'),
+ title=_('Missing Fields')
+ )
+
+def validate_totals(einvoice):
+ item_list = einvoice['ItemList']
+ value_details = einvoice['ValDtls']
+
+ total_item_ass_value = 0
+ total_item_cgst_value = 0
+ total_item_sgst_value = 0
+ total_item_igst_value = 0
+ total_item_value = 0
+ for item in item_list:
+ total_item_ass_value += flt(item['AssAmt'])
+ total_item_cgst_value += flt(item['CgstAmt'])
+ total_item_sgst_value += flt(item['SgstAmt'])
+ total_item_igst_value += flt(item['IgstAmt'])
+ total_item_value += flt(item['TotItemVal'])
+
+ if abs(flt(item['AssAmt']) * flt(item['GstRt']) / 100) - (flt(item['CgstAmt']) + flt(item['SgstAmt']) + flt(item['IgstAmt'])) > 1:
+ frappe.throw(_('Row #{}: GST rate is invalid. Please remove tax rows with zero tax amount from taxes table.').format(item.idx))
+
+ if abs(flt(value_details['AssVal']) - total_item_ass_value) > 1:
+ frappe.throw(_('Total Taxable Value of the items is not equal to the Invoice Net Total. Please check item taxes / discounts for any correction.'))
+
+ if abs(flt(value_details['CgstVal']) + flt(value_details['SgstVal']) - total_item_cgst_value - total_item_sgst_value) > 1:
+ frappe.throw(_('CGST + SGST value of the items is not equal to total CGST + SGST value. Please review taxes for any correction.'))
+
+ if abs(flt(value_details['IgstVal']) - total_item_igst_value) > 1:
+ frappe.throw(_('IGST value of all items is not equal to total IGST value. Please review taxes for any correction.'))
+
+ if abs(
+ flt(value_details['TotInvVal']) + flt(value_details['Discount']) -
+ flt(value_details['OthChrg']) - flt(value_details['RndOffAmt']) -
+ total_item_value
+ ) > 1:
+ frappe.throw(_('Total Value of the items is not equal to the Invoice Grand Total. Please check item taxes / discounts for any correction.'))
+
+ calculated_invoice_value = \
+ flt(value_details['AssVal']) + flt(value_details['CgstVal']) \
+ + flt(value_details['SgstVal']) + flt(value_details['IgstVal']) \
+ + flt(value_details['OthChrg']) + flt(value_details['RndOffAmt']) - flt(value_details['Discount'])
+
+ if abs(flt(value_details['TotInvVal']) - calculated_invoice_value) > 1:
+ frappe.throw(_('Total Item Value + Taxes - Discount is not equal to the Invoice Grand Total. Please check taxes / discounts for any correction.'))
+
+def make_einvoice(invoice):
+ validate_mandatory_fields(invoice)
+
+ schema = read_json('einv_template')
+
+ transaction_details = get_transaction_details(invoice)
+ item_list = get_item_list(invoice)
+ doc_details = get_doc_details(invoice)
+ invoice_value_details = get_invoice_value_details(invoice)
+ seller_details = get_party_details(invoice.company_address)
+
+ if invoice.gst_category == 'Overseas':
+ buyer_details = get_overseas_address_details(invoice.customer_address)
+ else:
+ buyer_details = get_party_details(invoice.customer_address)
+ place_of_supply = get_place_of_supply(invoice, invoice.doctype)
+ if place_of_supply:
+ place_of_supply = place_of_supply.split('-')[0]
+ else:
+ place_of_supply = sanitize_for_json(invoice.billing_address_gstin)[:2]
+ buyer_details.update(dict(place_of_supply=place_of_supply))
+
+ seller_details.update(dict(legal_name=invoice.company))
+ buyer_details.update(dict(legal_name=invoice.customer_name or invoice.customer))
+
+ shipping_details = payment_details = prev_doc_details = eway_bill_details = frappe._dict({})
+ if invoice.shipping_address_name and invoice.customer_address != invoice.shipping_address_name:
+ if invoice.gst_category == 'Overseas':
+ shipping_details = get_overseas_address_details(invoice.shipping_address_name)
+ else:
+ shipping_details = get_party_details(invoice.shipping_address_name, skip_gstin_validation=True)
+
+ dispatch_details = frappe._dict({})
+ if invoice.dispatch_address_name:
+ dispatch_details = get_party_details(invoice.dispatch_address_name, skip_gstin_validation=True)
+
+ if invoice.is_pos and invoice.base_paid_amount:
+ payment_details = get_payment_details(invoice)
+
+ if invoice.is_return and invoice.return_against:
+ prev_doc_details = get_return_doc_reference(invoice)
+
+ if invoice.transporter and not invoice.is_return:
+ eway_bill_details = get_eway_bill_details(invoice)
+
+ # not yet implemented
+ period_details = export_details = frappe._dict({})
+
+ einvoice = schema.format(
+ transaction_details=transaction_details, doc_details=doc_details, dispatch_details=dispatch_details,
+ seller_details=seller_details, buyer_details=buyer_details, shipping_details=shipping_details,
+ item_list=item_list, invoice_value_details=invoice_value_details, payment_details=payment_details,
+ period_details=period_details, prev_doc_details=prev_doc_details,
+ export_details=export_details, eway_bill_details=eway_bill_details
+ )
+
+ try:
+ einvoice = safe_json_load(einvoice)
+ einvoice = santize_einvoice_fields(einvoice)
+ except Exception:
+ show_link_to_error_log(invoice, einvoice)
+
+ try:
+ validate_totals(einvoice)
+ except Exception:
+ log_error(einvoice)
+ raise
+
+ return einvoice
+
+def show_link_to_error_log(invoice, einvoice):
+ err_log = log_error(einvoice)
+ link_to_error_log = get_link_to_form('Error Log', err_log.name, 'Error Log')
+ frappe.throw(
+ _('An error occurred while creating e-invoice for {}. Please check {} for more information.').format(
+ invoice.name, link_to_error_log),
+ title=_('E Invoice Creation Failed')
+ )
+
+def log_error(data=None):
+ if isinstance(data, str):
+ data = json.loads(data)
+
+ seperator = "--" * 50
+ err_tb = traceback.format_exc()
+ err_msg = str(sys.exc_info()[1])
+ data = json.dumps(data, indent=4)
+
+ message = "\n".join([
+ "Error", err_msg, seperator,
+ "Data:", data, seperator,
+ "Exception:", err_tb
+ ])
+ return frappe.log_error(title=_('E Invoice Request Failed'), message=message)
+
+def santize_einvoice_fields(einvoice):
+ int_fields = ["Pin","Distance","CrDay"]
+ float_fields = ["Qty","FreeQty","UnitPrice","TotAmt","Discount","PreTaxVal","AssAmt","GstRt","IgstAmt","CgstAmt","SgstAmt","CesRt","CesAmt","CesNonAdvlAmt","StateCesRt","StateCesAmt","StateCesNonAdvlAmt","OthChrg","TotItemVal","AssVal","CgstVal","SgstVal","IgstVal","CesVal","StCesVal","Discount","OthChrg","RndOffAmt","TotInvVal","TotInvValFc","PaidAmt","PaymtDue","ExpDuty",]
+ copy = einvoice.copy()
+ for key, value in copy.items():
+ if isinstance(value, list):
+ for idx, d in enumerate(value):
+ santized_dict = santize_einvoice_fields(d)
+ if santized_dict:
+ einvoice[key][idx] = santized_dict
+ else:
+ einvoice[key].pop(idx)
+
+ if not einvoice[key]:
+ einvoice.pop(key, None)
+
+ elif isinstance(value, dict):
+ santized_dict = santize_einvoice_fields(value)
+ if santized_dict:
+ einvoice[key] = santized_dict
+ else:
+ einvoice.pop(key, None)
+
+ elif not value or value == "None":
+ einvoice.pop(key, None)
+
+ elif key in float_fields:
+ einvoice[key] = flt(value, 2)
+
+ elif key in int_fields:
+ einvoice[key] = cint(value)
+
+ return einvoice
+
+def safe_json_load(json_string):
+ try:
+ return json.loads(json_string)
+ except json.JSONDecodeError as e:
+ # print a snippet of 40 characters around the location where error occured
+ pos = e.pos
+ start, end = max(0, pos-20), min(len(json_string)-1, pos+20)
+ snippet = json_string[start:end]
+ frappe.throw(_("Error in input data. Please check for any special characters near following input: <br> {}").format(snippet))
+
+class RequestFailed(Exception):
+ pass
+class CancellationNotAllowed(Exception):
+ pass
+
+class GSPConnector():
+ def __init__(self, doctype=None, docname=None):
+ self.doctype = doctype
+ self.docname = docname
+
+ self.set_invoice()
+ self.set_credentials()
+
+ # authenticate url is same for sandbox & live
+ self.authenticate_url = 'https://gsp.adaequare.com/gsp/authenticate?grant_type=token'
+ self.base_url = 'https://gsp.adaequare.com' if not self.e_invoice_settings.sandbox_mode else 'https://gsp.adaequare.com/test'
+
+ self.cancel_irn_url = self.base_url + '/enriched/ei/api/invoice/cancel'
+ self.irn_details_url = self.base_url + '/enriched/ei/api/invoice/irn'
+ self.generate_irn_url = self.base_url + '/enriched/ei/api/invoice'
+ self.gstin_details_url = self.base_url + '/enriched/ei/api/master/gstin'
+ self.cancel_ewaybill_url = self.base_url + '/enriched/ewb/ewayapi?action=CANEWB'
+ self.generate_ewaybill_url = self.base_url + '/enriched/ei/api/ewaybill'
+
+ def set_invoice(self):
+ self.invoice = None
+ if self.doctype and self.docname:
+ self.invoice = frappe.get_cached_doc(self.doctype, self.docname)
+
+ def set_credentials(self):
+ self.e_invoice_settings = frappe.get_cached_doc('E Invoice Settings')
+
+ if not self.e_invoice_settings.enable:
+ frappe.throw(_("E-Invoicing is disabled. Please enable it from {} to generate e-invoices.").format(get_link_to_form("E Invoice Settings", "E Invoice Settings")))
+
+ if self.invoice:
+ gstin = self.get_seller_gstin()
+ credentials_for_gstin = [d for d in self.e_invoice_settings.credentials if d.gstin == gstin]
+ if credentials_for_gstin:
+ self.credentials = credentials_for_gstin[0]
+ else:
+ frappe.throw(_('Cannot find e-invoicing credentials for selected Company GSTIN. Please check E-Invoice Settings'))
+ else:
+ self.credentials = self.e_invoice_settings.credentials[0] if self.e_invoice_settings.credentials else None
+
+ def get_seller_gstin(self):
+ gstin = frappe.db.get_value('Address', self.invoice.company_address, 'gstin')
+ if not gstin:
+ frappe.throw(_('Cannot retrieve Company GSTIN. Please select company address with valid GSTIN.'))
+ return gstin
+
+ def get_auth_token(self):
+ if time_diff_in_seconds(self.e_invoice_settings.token_expiry, now_datetime()) < 150.0:
+ self.fetch_auth_token()
+
+ return self.e_invoice_settings.auth_token
+
+ def make_request(self, request_type, url, headers=None, data=None):
+ if request_type == 'post':
+ res = make_post_request(url, headers=headers, data=data)
+ else:
+ res = make_get_request(url, headers=headers, data=data)
+
+ self.log_request(url, headers, data, res)
+ return res
+
+ def log_request(self, url, headers, data, res):
+ headers.update({ 'password': self.credentials.password })
+ request_log = frappe.get_doc({
+ "doctype": "E Invoice Request Log",
+ "user": frappe.session.user,
+ "reference_invoice": self.invoice.name if self.invoice else None,
+ "url": url,
+ "headers": json.dumps(headers, indent=4) if headers else None,
+ "data": json.dumps(data, indent=4) if isinstance(data, dict) else data,
+ "response": json.dumps(res, indent=4) if res else None
+ })
+ request_log.save(ignore_permissions=True)
+ frappe.db.commit()
+
+ def get_client_credentials(self):
+ if self.e_invoice_settings.client_id and self.e_invoice_settings.client_secret:
+ return self.e_invoice_settings.client_id, self.e_invoice_settings.get_password('client_secret')
+
+ return frappe.conf.einvoice_client_id, frappe.conf.einvoice_client_secret
+
+ def fetch_auth_token(self):
+ client_id, client_secret = self.get_client_credentials()
+ headers = {
+ 'gspappid': client_id,
+ 'gspappsecret': client_secret
+ }
+ res = {}
+ try:
+ res = self.make_request('post', self.authenticate_url, headers)
+ self.e_invoice_settings.auth_token = "{} {}".format(res.get('token_type'), res.get('access_token'))
+ self.e_invoice_settings.token_expiry = add_to_date(None, seconds=res.get('expires_in'))
+ self.e_invoice_settings.save(ignore_permissions=True)
+ self.e_invoice_settings.reload()
+
+ except Exception:
+ log_error(res)
+ self.raise_error(True)
+
+ def get_headers(self):
+ return {
+ 'content-type': 'application/json',
+ 'user_name': self.credentials.username,
+ 'password': self.credentials.get_password(),
+ 'gstin': self.credentials.gstin,
+ 'authorization': self.get_auth_token(),
+ 'requestid': str(base64.b64encode(os.urandom(18))),
+ }
+
+ def fetch_gstin_details(self, gstin):
+ headers = self.get_headers()
+
+ try:
+ params = '?gstin={gstin}'.format(gstin=gstin)
+ res = self.make_request('get', self.gstin_details_url + params, headers)
+ if res.get('success'):
+ return res.get('result')
+ else:
+ log_error(res)
+ raise RequestFailed
+
+ except RequestFailed:
+ self.raise_error()
+
+ except Exception:
+ log_error()
+ self.raise_error(True)
+ @staticmethod
+ def get_gstin_details(gstin):
+ '''fetch and cache GSTIN details'''
+ if not hasattr(frappe.local, 'gstin_cache'):
+ frappe.local.gstin_cache = {}
+
+ key = gstin
+ gsp_connector = GSPConnector()
+ details = gsp_connector.fetch_gstin_details(gstin)
+
+ frappe.local.gstin_cache[key] = details
+ frappe.cache().hset('gstin_cache', key, details)
+ return details
+
+ def generate_irn(self):
+ data = {}
+ try:
+ headers = self.get_headers()
+ einvoice = make_einvoice(self.invoice)
+ data = json.dumps(einvoice, indent=4)
+ res = self.make_request('post', self.generate_irn_url, headers, data)
+
+ if res.get('success'):
+ self.set_einvoice_data(res.get('result'))
+
+ elif '2150' in res.get('message'):
+ # IRN already generated but not updated in invoice
+ # Extract the IRN from the response description and fetch irn details
+ irn = res.get('result')[0].get('Desc').get('Irn')
+ irn_details = self.get_irn_details(irn)
+ if irn_details:
+ self.set_einvoice_data(irn_details)
+ else:
+ raise RequestFailed('IRN has already been generated for the invoice but cannot fetch details for the it. \
+ Contact ERPNext support to resolve the issue.')
+
+ else:
+ raise RequestFailed
+
+ except RequestFailed:
+ errors = self.sanitize_error_message(res.get('message'))
+ self.set_failed_status(errors=errors)
+ self.raise_error(errors=errors)
+
+ except Exception as e:
+ self.set_failed_status(errors=str(e))
+ log_error(data)
+ self.raise_error(True)
+
+ @staticmethod
+ def bulk_generate_irn(invoices):
+ gsp_connector = GSPConnector()
+ gsp_connector.doctype = 'Sales Invoice'
+
+ failed = []
+
+ for invoice in invoices:
+ try:
+ gsp_connector.docname = invoice
+ gsp_connector.set_invoice()
+ gsp_connector.set_credentials()
+ gsp_connector.generate_irn()
+
+ except Exception as e:
+ failed.append({
+ 'docname': invoice,
+ 'message': str(e)
+ })
+
+ return failed
+
+ def get_irn_details(self, irn):
+ headers = self.get_headers()
+
+ try:
+ params = '?irn={irn}'.format(irn=irn)
+ res = self.make_request('get', self.irn_details_url + params, headers)
+ if res.get('success'):
+ return res.get('result')
+ else:
+ raise RequestFailed
+
+ except RequestFailed:
+ errors = self.sanitize_error_message(res.get('message'))
+ self.raise_error(errors=errors)
+
+ except Exception:
+ log_error()
+ self.raise_error(True)
+
+ def cancel_irn(self, irn, reason, remark):
+ data, res = {}, {}
+ try:
+ # validate cancellation
+ if time_diff_in_hours(now_datetime(), self.invoice.ack_date) > 24:
+ frappe.throw(_('E-Invoice cannot be cancelled after 24 hours of IRN generation.'), title=_('Not Allowed'), exc=CancellationNotAllowed)
+ if not irn:
+ frappe.throw(_('IRN not found. You must generate IRN before cancelling.'), title=_('Not Allowed'), exc=CancellationNotAllowed)
+
+ headers = self.get_headers()
+ data = json.dumps({
+ 'Irn': irn,
+ 'Cnlrsn': reason,
+ 'Cnlrem': remark
+ }, indent=4)
+
+ res = self.make_request('post', self.cancel_irn_url, headers, data)
+ if res.get('success') or '9999' in res.get('message'):
+ self.invoice.irn_cancelled = 1
+ self.invoice.irn_cancel_date = res.get('result')['CancelDate'] if res.get('result') else ""
+ self.invoice.einvoice_status = 'Cancelled'
+ self.invoice.flags.updater_reference = {
+ 'doctype': self.invoice.doctype,
+ 'docname': self.invoice.name,
+ 'label': _('IRN Cancelled - {}').format(remark)
+ }
+ self.update_invoice()
+
+ else:
+ raise RequestFailed
+
+ except RequestFailed:
+ errors = self.sanitize_error_message(res.get('message'))
+ self.set_failed_status(errors=errors)
+ self.raise_error(errors=errors)
+
+ except CancellationNotAllowed as e:
+ self.set_failed_status(errors=str(e))
+ self.raise_error(errors=str(e))
+
+ except Exception as e:
+ self.set_failed_status(errors=str(e))
+ log_error(data)
+ self.raise_error(True)
+
+ @staticmethod
+ def bulk_cancel_irn(invoices, reason, remark):
+ gsp_connector = GSPConnector()
+ gsp_connector.doctype = 'Sales Invoice'
+
+ failed = []
+
+ for invoice in invoices:
+ try:
+ gsp_connector.docname = invoice
+ gsp_connector.set_invoice()
+ gsp_connector.set_credentials()
+ irn = gsp_connector.invoice.irn
+ gsp_connector.cancel_irn(irn, reason, remark)
+
+ except Exception as e:
+ failed.append({
+ 'docname': invoice,
+ 'message': str(e)
+ })
+
+ return failed
+
+ def generate_eway_bill(self, **kwargs):
+ args = frappe._dict(kwargs)
+
+ headers = self.get_headers()
+ eway_bill_details = get_eway_bill_details(args)
+ data = json.dumps({
+ 'Irn': args.irn,
+ 'Distance': cint(eway_bill_details.distance),
+ 'TransMode': eway_bill_details.mode_of_transport,
+ 'TransId': eway_bill_details.gstin,
+ 'TransName': eway_bill_details.transporter,
+ 'TrnDocDt': eway_bill_details.document_date,
+ 'TrnDocNo': eway_bill_details.document_name,
+ 'VehNo': eway_bill_details.vehicle_no,
+ 'VehType': eway_bill_details.vehicle_type
+ }, indent=4)
+
+ try:
+ res = self.make_request('post', self.generate_ewaybill_url, headers, data)
+ if res.get('success'):
+ self.invoice.ewaybill = res.get('result').get('EwbNo')
+ self.invoice.eway_bill_validity = res.get('result').get('EwbValidTill')
+ self.invoice.eway_bill_cancelled = 0
+ self.invoice.update(args)
+ self.invoice.flags.updater_reference = {
+ 'doctype': self.invoice.doctype,
+ 'docname': self.invoice.name,
+ 'label': _('E-Way Bill Generated')
+ }
+ self.update_invoice()
+
+ else:
+ raise RequestFailed
+
+ except RequestFailed:
+ errors = self.sanitize_error_message(res.get('message'))
+ self.raise_error(errors=errors)
+
+ except Exception:
+ log_error(data)
+ self.raise_error(True)
+
+ def cancel_eway_bill(self, eway_bill, reason, remark):
+ headers = self.get_headers()
+ data = json.dumps({
+ 'ewbNo': eway_bill,
+ 'cancelRsnCode': reason,
+ 'cancelRmrk': remark
+ }, indent=4)
+ headers["username"] = headers["user_name"]
+ del headers["user_name"]
+ try:
+ res = self.make_request('post', self.cancel_ewaybill_url, headers, data)
+ if res.get('success'):
+ self.invoice.ewaybill = ''
+ self.invoice.eway_bill_cancelled = 1
+ self.invoice.flags.updater_reference = {
+ 'doctype': self.invoice.doctype,
+ 'docname': self.invoice.name,
+ 'label': _('E-Way Bill Cancelled - {}').format(remark)
+ }
+ self.update_invoice()
+
+ else:
+ raise RequestFailed
+
+ except RequestFailed:
+ errors = self.sanitize_error_message(res.get('message'))
+ self.raise_error(errors=errors)
+
+ except Exception:
+ log_error(data)
+ self.raise_error(True)
+
+ def sanitize_error_message(self, message):
+ '''
+ On validation errors, response message looks something like this:
+ message = '2174 : For inter-state transaction, CGST and SGST amounts are not applicable; only IGST amount is applicable,
+ 3095 : Supplier GSTIN is inactive'
+ we search for string between ':' to extract the error messages
+ errors = [
+ ': For inter-state transaction, CGST and SGST amounts are not applicable; only IGST amount is applicable, 3095 ',
+ ': Test'
+ ]
+ then we trim down the message by looping over errors
+ '''
+ if not message:
+ return []
+
+ errors = re.findall(': [^:]+', message)
+ for idx, e in enumerate(errors):
+ # remove colons
+ errors[idx] = errors[idx].replace(':', '').strip()
+ # if not last
+ if idx != len(errors) - 1:
+ # remove last 7 chars eg: ', 3095 '
+ errors[idx] = errors[idx][:-6]
+
+ return errors
+
+ def raise_error(self, raise_exception=False, errors=None):
+ if errors is None:
+ errors = []
+ title = _('E Invoice Request Failed')
+ if errors:
+ frappe.throw(errors, title=title, as_list=1)
+ else:
+ link_to_error_list = '<a href="/app/error-log" target="_blank">Error Log</a>'
+ frappe.msgprint(
+ _('An error occurred while making e-invoicing request. Please check {} for more information.').format(link_to_error_list),
+ title=title,
+ raise_exception=raise_exception,
+ indicator='red'
+ )
+
+ def set_einvoice_data(self, res):
+ enc_signed_invoice = res.get('SignedInvoice')
+ dec_signed_invoice = jwt.decode(enc_signed_invoice, options={"verify_signature": False})['data']
+
+ self.invoice.irn = res.get('Irn')
+ self.invoice.ewaybill = res.get('EwbNo')
+ self.invoice.eway_bill_validity = res.get('EwbValidTill')
+ self.invoice.ack_no = res.get('AckNo')
+ self.invoice.ack_date = res.get('AckDt')
+ self.invoice.signed_einvoice = dec_signed_invoice
+ self.invoice.ack_no = res.get('AckNo')
+ self.invoice.ack_date = res.get('AckDt')
+ self.invoice.signed_qr_code = res.get('SignedQRCode')
+ self.invoice.einvoice_status = 'Generated'
+
+ self.attach_qrcode_image()
+
+ self.invoice.flags.updater_reference = {
+ 'doctype': self.invoice.doctype,
+ 'docname': self.invoice.name,
+ 'label': _('IRN Generated')
+ }
+ self.update_invoice()
+
+ def attach_qrcode_image(self):
+ qrcode = self.invoice.signed_qr_code
+ doctype = self.invoice.doctype
+ docname = self.invoice.name
+ filename = 'QRCode_{}.png'.format(docname).replace(os.path.sep, "__")
+
+ qr_image = io.BytesIO()
+ url = qrcreate(qrcode, error='L')
+ url.png(qr_image, scale=2, quiet_zone=1)
+ _file = frappe.get_doc({
+ "doctype": "File",
+ "file_name": filename,
+ "attached_to_doctype": doctype,
+ "attached_to_name": docname,
+ "attached_to_field": "qrcode_image",
+ "is_private": 0,
+ "content": qr_image.getvalue()})
+ _file.save()
+ frappe.db.commit()
+ self.invoice.qrcode_image = _file.file_url
+
+ def update_invoice(self):
+ self.invoice.flags.ignore_validate_update_after_submit = True
+ self.invoice.flags.ignore_validate = True
+ self.invoice.save()
+
+ def set_failed_status(self, errors=None):
+ frappe.db.rollback()
+ self.invoice.einvoice_status = 'Failed'
+ self.invoice.failure_description = self.get_failure_message(errors) if errors else ""
+ self.update_invoice()
+ frappe.db.commit()
+
+ def get_failure_message(self, errors):
+ if isinstance(errors, list):
+ errors = ', '.join(errors)
+ return errors
+
+def sanitize_for_json(string):
+ """Escape JSON specific characters from a string."""
+
+ # json.dumps adds double-quotes to the string. Indexing to remove them.
+ return json.dumps(string)[1:-1]
+
+@frappe.whitelist()
+def get_einvoice(doctype, docname):
+ invoice = frappe.get_doc(doctype, docname)
+ return make_einvoice(invoice)
+
+@frappe.whitelist()
+def generate_irn(doctype, docname):
+ gsp_connector = GSPConnector(doctype, docname)
+ gsp_connector.generate_irn()
+
+@frappe.whitelist()
+def cancel_irn(doctype, docname, irn, reason, remark):
+ gsp_connector = GSPConnector(doctype, docname)
+ gsp_connector.cancel_irn(irn, reason, remark)
+
+@frappe.whitelist()
+def generate_eway_bill(doctype, docname, **kwargs):
+ gsp_connector = GSPConnector(doctype, docname)
+ gsp_connector.generate_eway_bill(**kwargs)
+
+@frappe.whitelist()
+def cancel_eway_bill(doctype, docname):
+ # TODO: uncomment when eway_bill api from Adequare is enabled
+ # gsp_connector = GSPConnector(doctype, docname)
+ # gsp_connector.cancel_eway_bill(eway_bill, reason, remark)
+
+ frappe.db.set_value(doctype, docname, 'ewaybill', '')
+ frappe.db.set_value(doctype, docname, 'eway_bill_cancelled', 1)
+
+@frappe.whitelist()
+def generate_einvoices(docnames):
+ docnames = json.loads(docnames) or []
+
+ if len(docnames) < 10:
+ failures = GSPConnector.bulk_generate_irn(docnames)
+ frappe.local.message_log = []
+
+ if failures:
+ show_bulk_action_failure_message(failures)
+
+ success = len(docnames) - len(failures)
+ frappe.msgprint(
+ _('{} e-invoices generated successfully').format(success),
+ title=_('Bulk E-Invoice Generation Complete')
+ )
+
+ else:
+ enqueue_bulk_action(schedule_bulk_generate_irn, docnames=docnames)
+
+def schedule_bulk_generate_irn(docnames):
+ failures = GSPConnector.bulk_generate_irn(docnames)
+ frappe.local.message_log = []
+
+ frappe.publish_realtime("bulk_einvoice_generation_complete", {
+ "user": frappe.session.user,
+ "failures": failures,
+ "invoices": docnames
+ })
+
+def show_bulk_action_failure_message(failures):
+ for doc in failures:
+ docname = '<a href="sales-invoice/{0}">{0}</a>'.format(doc.get('docname'))
+ message = doc.get('message').replace("'", '"')
+ if message[0] == '[':
+ errors = json.loads(message)
+ error_list = ''.join(['<li>{}</li>'.format(err) for err in errors])
+ message = '''{} has following errors:<br>
+ <ul style="padding-left: 20px; padding-top: 5px">{}</ul>'''.format(docname, error_list)
+ else:
+ message = '{} - {}'.format(docname, message)
+
+ frappe.msgprint(
+ message,
+ title=_('Bulk E-Invoice Generation Complete'),
+ indicator='red'
+ )
+
+@frappe.whitelist()
+def cancel_irns(docnames, reason, remark):
+ docnames = json.loads(docnames) or []
+
+ if len(docnames) < 10:
+ failures = GSPConnector.bulk_cancel_irn(docnames, reason, remark)
+ frappe.local.message_log = []
+
+ if failures:
+ show_bulk_action_failure_message(failures)
+
+ success = len(docnames) - len(failures)
+ frappe.msgprint(
+ _('{} e-invoices cancelled successfully').format(success),
+ title=_('Bulk E-Invoice Cancellation Complete')
+ )
+ else:
+ enqueue_bulk_action(schedule_bulk_cancel_irn, docnames=docnames, reason=reason, remark=remark)
+
+def schedule_bulk_cancel_irn(docnames, reason, remark):
+ failures = GSPConnector.bulk_cancel_irn(docnames, reason, remark)
+ frappe.local.message_log = []
+
+ frappe.publish_realtime("bulk_einvoice_cancellation_complete", {
+ "user": frappe.session.user,
+ "failures": failures,
+ "invoices": docnames
+ })
+
+def enqueue_bulk_action(job, **kwargs):
+ check_scheduler_status()
+
+ enqueue(
+ job,
+ **kwargs,
+ queue="long",
+ timeout=10000,
+ event="processing_bulk_einvoice_action",
+ now=frappe.conf.developer_mode or frappe.flags.in_test,
+ )
+
+ if job == schedule_bulk_generate_irn:
+ msg = _('E-Invoices will be generated in a background process.')
+ else:
+ msg = _('E-Invoices will be cancelled in a background process.')
+
+ frappe.msgprint(msg, alert=1)
+
+def check_scheduler_status():
+ if is_scheduler_inactive() and not frappe.flags.in_test:
+ frappe.throw(_("Scheduler is inactive. Cannot enqueue job."), title=_("Scheduler Inactive"))
+
+def job_already_enqueued(job_name):
+ enqueued_jobs = [d.get("job_name") for d in get_info()]
+ if job_name in enqueued_jobs:
+ return True
diff --git a/erpnext/regional/india/setup.py b/erpnext/regional/india/setup.py
index 4b99421..074bd52 100644
--- a/erpnext/regional/india/setup.py
+++ b/erpnext/regional/india/setup.py
@@ -60,7 +60,7 @@
def add_custom_roles_for_reports():
for report_name in ('GST Sales Register', 'GST Purchase Register',
- 'GST Itemised Sales Register', 'GST Itemised Purchase Register', 'Eway Bill'):
+ 'GST Itemised Sales Register', 'GST Itemised Purchase Register', 'Eway Bill', 'E-Invoice Summary'):
if not frappe.db.get_value('Custom Role', dict(report=report_name)):
frappe.get_doc(dict(
@@ -99,7 +99,7 @@
)).insert()
def add_permissions():
- for doctype in ('GST HSN Code', 'GST Settings', 'GSTR 3B Report', 'Lower Deduction Certificate'):
+ for doctype in ('GST HSN Code', 'GST Settings', 'GSTR 3B Report', 'Lower Deduction Certificate', 'E Invoice Settings'):
add_permission(doctype, 'All', 0)
for role in ('Accounts Manager', 'Accounts User', 'System Manager'):
add_permission(doctype, role, 0)
@@ -115,9 +115,11 @@
def add_print_formats():
frappe.reload_doc("regional", "print_format", "gst_tax_invoice")
frappe.reload_doc("accounts", "print_format", "gst_pos_invoice")
+ frappe.reload_doc("accounts", "print_format", "GST E-Invoice")
frappe.db.set_value("Print Format", "GST POS Invoice", "disabled", 0)
frappe.db.set_value("Print Format", "GST Tax Invoice", "disabled", 0)
+ frappe.db.set_value("Print Format", "GST E-Invoice", "disabled", 0)
def make_property_setters(patch=False):
# GST rules do not allow for an invoice no. bigger than 16 characters
@@ -453,7 +455,7 @@
'fieldname': 'ewaybill',
'label': 'E-Way Bill No.',
'fieldtype': 'Data',
- 'depends_on': 'eval:(doc.docstatus === 1)',
+ 'depends_on': 'eval:((doc.docstatus === 1 || doc.ewaybill) && doc.eway_bill_cancelled === 0)',
'allow_on_submit': 1,
'insert_after': 'tax_id',
'translatable': 0,
@@ -481,6 +483,46 @@
fetch_from='customer_address.gstin', print_hide=1, read_only=1)
]
+ si_einvoice_fields = [
+ dict(fieldname='irn', label='IRN', fieldtype='Data', read_only=1, insert_after='customer', no_copy=1, print_hide=1,
+ depends_on='eval:in_list(["Registered Regular", "SEZ", "Overseas", "Deemed Export"], doc.gst_category) && doc.irn_cancelled === 0'),
+
+ dict(fieldname='irn_cancelled', label='IRN Cancelled', fieldtype='Check', no_copy=1, print_hide=1,
+ depends_on='eval: doc.irn', allow_on_submit=1, insert_after='customer'),
+
+ dict(fieldname='eway_bill_validity', label='E-Way Bill Validity', fieldtype='Data', no_copy=1, print_hide=1,
+ depends_on='ewaybill', read_only=1, allow_on_submit=1, insert_after='ewaybill'),
+
+ dict(fieldname='eway_bill_cancelled', label='E-Way Bill Cancelled', fieldtype='Check', no_copy=1, print_hide=1,
+ depends_on='eval:(doc.eway_bill_cancelled === 1)', read_only=1, allow_on_submit=1, insert_after='customer'),
+
+ dict(fieldname='einvoice_section', label='E-Invoice Fields', fieldtype='Section Break', insert_after='gst_vehicle_type',
+ print_hide=1, hidden=1),
+
+ dict(fieldname='ack_no', label='Ack. No.', fieldtype='Data', read_only=1, hidden=1, insert_after='einvoice_section',
+ no_copy=1, print_hide=1),
+
+ dict(fieldname='ack_date', label='Ack. Date', fieldtype='Data', read_only=1, hidden=1, insert_after='ack_no', no_copy=1, print_hide=1),
+
+ dict(fieldname='irn_cancel_date', label='Cancel Date', fieldtype='Data', read_only=1, hidden=1, insert_after='ack_date',
+ no_copy=1, print_hide=1),
+
+ dict(fieldname='signed_einvoice', label='Signed E-Invoice', fieldtype='Code', options='JSON', hidden=1, insert_after='irn_cancel_date',
+ no_copy=1, print_hide=1, read_only=1),
+
+ dict(fieldname='signed_qr_code', label='Signed QRCode', fieldtype='Code', options='JSON', hidden=1, insert_after='signed_einvoice',
+ no_copy=1, print_hide=1, read_only=1),
+
+ dict(fieldname='qrcode_image', label='QRCode', fieldtype='Attach Image', hidden=1, insert_after='signed_qr_code',
+ no_copy=1, print_hide=1, read_only=1),
+
+ dict(fieldname='einvoice_status', label='E-Invoice Status', fieldtype='Select', insert_after='qrcode_image',
+ options='\nPending\nGenerated\nCancelled\nFailed', default=None, hidden=1, no_copy=1, print_hide=1, read_only=1),
+
+ dict(fieldname='failure_description', label='E-Invoice Failure Description', fieldtype='Code', options='JSON',
+ hidden=1, insert_after='einvoice_status', no_copy=1, print_hide=1, read_only=1)
+ ]
+
custom_fields = {
'Address': [
dict(fieldname='gstin', label='Party GSTIN', fieldtype='Data',
@@ -493,7 +535,7 @@
'Purchase Invoice': purchase_invoice_gst_category + invoice_gst_fields + purchase_invoice_itc_fields + purchase_invoice_gst_fields,
'Purchase Order': purchase_invoice_gst_fields,
'Purchase Receipt': purchase_invoice_gst_fields,
- 'Sales Invoice': sales_invoice_gst_category + invoice_gst_fields + sales_invoice_shipping_fields + sales_invoice_gst_fields + si_ewaybill_fields,
+ 'Sales Invoice': sales_invoice_gst_category + invoice_gst_fields + sales_invoice_shipping_fields + sales_invoice_gst_fields + si_ewaybill_fields + si_einvoice_fields,
'POS Invoice': sales_invoice_gst_fields,
'Delivery Note': sales_invoice_gst_fields + ewaybill_fields + sales_invoice_shipping_fields + delivery_note_gst_category,
'Payment Entry': payment_entry_fields,
diff --git a/erpnext/regional/india/utils.py b/erpnext/regional/india/utils.py
index d443f9c..8715ef5 100644
--- a/erpnext/regional/india/utils.py
+++ b/erpnext/regional/india/utils.py
@@ -219,6 +219,7 @@
if not party_details.place_of_supply: return party_details
if not party_details.company_gstin: return party_details
+ if not party_details.supplier_gstin: return party_details
if ((doctype in ("Sales Invoice", "Delivery Note", "Sales Order") and party_details.company_gstin
and party_details.company_gstin[:2] != party_details.place_of_supply[:2]) or (doctype in ("Purchase Invoice",
diff --git a/erpnext/patches/v14_0/__init__.py b/erpnext/regional/report/e_invoice_summary/__init__.py
similarity index 100%
copy from erpnext/patches/v14_0/__init__.py
copy to erpnext/regional/report/e_invoice_summary/__init__.py
diff --git a/erpnext/regional/report/e_invoice_summary/e_invoice_summary.js b/erpnext/regional/report/e_invoice_summary/e_invoice_summary.js
new file mode 100644
index 0000000..4713217
--- /dev/null
+++ b/erpnext/regional/report/e_invoice_summary/e_invoice_summary.js
@@ -0,0 +1,55 @@
+// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+/* eslint-disable */
+
+frappe.query_reports["E-Invoice Summary"] = {
+ "filters": [
+ {
+ "fieldtype": "Link",
+ "options": "Company",
+ "reqd": 1,
+ "fieldname": "company",
+ "label": __("Company"),
+ "default": frappe.defaults.get_user_default("Company"),
+ },
+ {
+ "fieldtype": "Link",
+ "options": "Customer",
+ "fieldname": "customer",
+ "label": __("Customer")
+ },
+ {
+ "fieldtype": "Date",
+ "reqd": 1,
+ "fieldname": "from_date",
+ "label": __("From Date"),
+ "default": frappe.datetime.add_months(frappe.datetime.get_today(), -1),
+ },
+ {
+ "fieldtype": "Date",
+ "reqd": 1,
+ "fieldname": "to_date",
+ "label": __("To Date"),
+ "default": frappe.datetime.get_today(),
+ },
+ {
+ "fieldtype": "Select",
+ "fieldname": "status",
+ "label": __("Status"),
+ "options": "\nPending\nGenerated\nCancelled\nFailed"
+ }
+ ],
+
+ "formatter": function (value, row, column, data, default_formatter) {
+ value = default_formatter(value, row, column, data);
+
+ if (column.fieldname == "einvoice_status" && value) {
+ if (value == 'Pending') value = `<span class="bold" style="color: var(--text-on-orange)">${value}</span>`;
+ else if (value == 'Generated') value = `<span class="bold" style="color: var(--text-on-green)">${value}</span>`;
+ else if (value == 'Cancelled') value = `<span class="bold" style="color: var(--text-on-red)">${value}</span>`;
+ else if (value == 'Failed') value = `<span class="bold" style="color: var(--text-on-red)">${value}</span>`;
+ }
+
+ return value;
+ }
+};
diff --git a/erpnext/regional/report/e_invoice_summary/e_invoice_summary.json b/erpnext/regional/report/e_invoice_summary/e_invoice_summary.json
new file mode 100644
index 0000000..d0000ad
--- /dev/null
+++ b/erpnext/regional/report/e_invoice_summary/e_invoice_summary.json
@@ -0,0 +1,28 @@
+{
+ "add_total_row": 0,
+ "columns": [],
+ "creation": "2021-03-12 11:23:37.312294",
+ "disable_prepared_report": 0,
+ "disabled": 0,
+ "docstatus": 0,
+ "doctype": "Report",
+ "filters": [],
+ "idx": 0,
+ "is_standard": "Yes",
+ "json": "{}",
+ "letter_head": "Logo",
+ "modified": "2021-03-13 12:36:48.689413",
+ "modified_by": "Administrator",
+ "module": "Regional",
+ "name": "E-Invoice Summary",
+ "owner": "Administrator",
+ "prepared_report": 0,
+ "ref_doctype": "Sales Invoice",
+ "report_name": "E-Invoice Summary",
+ "report_type": "Script Report",
+ "roles": [
+ {
+ "role": "Administrator"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/erpnext/regional/report/e_invoice_summary/e_invoice_summary.py b/erpnext/regional/report/e_invoice_summary/e_invoice_summary.py
new file mode 100644
index 0000000..2110c44
--- /dev/null
+++ b/erpnext/regional/report/e_invoice_summary/e_invoice_summary.py
@@ -0,0 +1,110 @@
+# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe import _
+
+
+def execute(filters=None):
+ validate_filters(filters)
+
+ columns = get_columns()
+ data = get_data(filters)
+
+ return columns, data
+
+def validate_filters(filters=None):
+ if filters is None:
+ filters = {}
+ filters = frappe._dict(filters)
+
+ if not filters.company:
+ frappe.throw(_('{} is mandatory for generating E-Invoice Summary Report').format(_('Company')), title=_('Invalid Filter'))
+ if filters.company:
+ # validate if company has e-invoicing enabled
+ pass
+ if not filters.from_date or not filters.to_date:
+ frappe.throw(_('From Date & To Date is mandatory for generating E-Invoice Summary Report'), title=_('Invalid Filter'))
+ if filters.from_date > filters.to_date:
+ frappe.throw(_('From Date must be before To Date'), title=_('Invalid Filter'))
+
+def get_data(filters=None):
+ if filters is None:
+ filters = {}
+ query_filters = {
+ 'posting_date': ['between', [filters.from_date, filters.to_date]],
+ 'einvoice_status': ['is', 'set'],
+ 'company': filters.company
+ }
+ if filters.customer:
+ query_filters['customer'] = filters.customer
+ if filters.status:
+ query_filters['einvoice_status'] = filters.status
+
+ data = frappe.get_all(
+ 'Sales Invoice',
+ filters=query_filters,
+ fields=[d.get('fieldname') for d in get_columns()]
+ )
+
+ return data
+
+def get_columns():
+ return [
+ {
+ "fieldtype": "Date",
+ "fieldname": "posting_date",
+ "label": _("Posting Date"),
+ "width": 0
+ },
+ {
+ "fieldtype": "Link",
+ "fieldname": "name",
+ "label": _("Sales Invoice"),
+ "options": "Sales Invoice",
+ "width": 140
+ },
+ {
+ "fieldtype": "Data",
+ "fieldname": "einvoice_status",
+ "label": _("Status"),
+ "width": 100
+ },
+ {
+ "fieldtype": "Link",
+ "fieldname": "customer",
+ "options": "Customer",
+ "label": _("Customer")
+ },
+ {
+ "fieldtype": "Check",
+ "fieldname": "is_return",
+ "label": _("Is Return"),
+ "width": 85
+ },
+ {
+ "fieldtype": "Data",
+ "fieldname": "ack_no",
+ "label": "Ack. No.",
+ "width": 145
+ },
+ {
+ "fieldtype": "Data",
+ "fieldname": "ack_date",
+ "label": "Ack. Date",
+ "width": 165
+ },
+ {
+ "fieldtype": "Data",
+ "fieldname": "irn",
+ "label": _("IRN No."),
+ "width": 250
+ },
+ {
+ "fieldtype": "Currency",
+ "options": "Company:company:default_currency",
+ "fieldname": "base_grand_total",
+ "label": _("Grand Total"),
+ "width": 120
+ }
+ ]
diff --git a/erpnext/selling/doctype/selling_settings/selling_settings.py b/erpnext/selling/doctype/selling_settings/selling_settings.py
index fb86e61..e1ef635 100644
--- a/erpnext/selling/doctype/selling_settings/selling_settings.py
+++ b/erpnext/selling/doctype/selling_settings/selling_settings.py
@@ -16,7 +16,7 @@
self.toggle_editable_rate_for_bundle_items()
def validate(self):
- for key in ["cust_master_name", "campaign_naming_by", "customer_group", "territory",
+ for key in ["cust_master_name", "customer_group", "territory",
"maintain_same_sales_rate", "editable_price_list_rate", "selling_price_list"]:
frappe.db.set_default(key, self.get(key, ""))
diff --git a/erpnext/selling/sales_common.js b/erpnext/selling/sales_common.js
index 540aca2..bd3b41f 100644
--- a/erpnext/selling/sales_common.js
+++ b/erpnext/selling/sales_common.js
@@ -486,7 +486,7 @@
"options": "Competitor Detail"
},
{
- "fieldtype": "Text",
+ "fieldtype": "Small Text",
"label": __("Detailed Reason"),
"fieldname": "detailed_reason"
},
diff --git a/erpnext/stock/doctype/serial_no/serial_no.py b/erpnext/stock/doctype/serial_no/serial_no.py
index ee55af3..ee08e38 100644
--- a/erpnext/stock/doctype/serial_no/serial_no.py
+++ b/erpnext/stock/doctype/serial_no/serial_no.py
@@ -465,6 +465,13 @@
return [s.strip() for s in cstr(serial_no).strip().upper().replace(',', '\n').split('\n')
if s.strip()]
+def clean_serial_no_string(serial_no: str) -> str:
+ if not serial_no:
+ return ""
+
+ serial_no_list = get_serial_nos(serial_no)
+ return "\n".join(serial_no_list)
+
def update_args_for_serial_no(serial_no_doc, serial_no, args, is_new=False):
for field in ["item_code", "work_order", "company", "batch_no", "supplier", "location"]:
if args.get(field):