Merge pull request #29836 from rohitwaghchaure/minor-item-name-in-excel-sheet
feat (minor): added item name in the excel sheet
diff --git a/.github/stale.yml b/.github/stale.yml
index 8b7cb9b..1c2dcf3 100644
--- a/.github/stale.yml
+++ b/.github/stale.yml
@@ -30,6 +30,7 @@
exemptLabels:
- valid
- to-validate
+ - QA
markComment: >
This issue has been automatically marked as inactive because it has not had
recent activity and it wasn't validated by maintainer team. It will be
diff --git a/erpnext/__init__.py b/erpnext/__init__.py
index 0b4696c..bef6661 100644
--- a/erpnext/__init__.py
+++ b/erpnext/__init__.py
@@ -2,8 +2,6 @@
import frappe
-from erpnext.hooks import regional_overrides
-
__version__ = '14.0.0-dev'
def get_default_company(user=None):
@@ -121,14 +119,17 @@
@erpnext.allow_regional
def myfunction():
pass'''
+
def caller(*args, **kwargs):
- region = get_region()
- fn_name = inspect.getmodule(fn).__name__ + '.' + fn.__name__
- if region in regional_overrides and fn_name in regional_overrides[region]:
- return frappe.get_attr(regional_overrides[region][fn_name])(*args, **kwargs)
- else:
+ overrides = frappe.get_hooks("regional_overrides", {}).get(get_region())
+ function_path = f"{inspect.getmodule(fn).__name__}.{fn.__name__}"
+
+ if not overrides or function_path not in overrides:
return fn(*args, **kwargs)
+ # Priority given to last installed app
+ return frappe.get_attr(overrides[function_path][-1])(*args, **kwargs)
+
return caller
def get_last_membership(member):
diff --git a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js
index dbf3622..46ba27c 100644
--- a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js
+++ b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js
@@ -64,6 +64,7 @@
"account_currency",
(r) => {
frm.currency = r.account_currency;
+ frm.trigger("render_chart");
}
);
}
@@ -128,7 +129,7 @@
}
},
- render_chart(frm) {
+ render_chart: frappe.utils.debounce((frm) => {
frm.cards_manager = new erpnext.accounts.bank_reconciliation.NumberCardManager(
{
$reconciliation_tool_cards: frm.get_field(
@@ -140,7 +141,7 @@
currency: frm.currency,
}
);
- },
+ }, 500),
render(frm) {
if (frm.doc.bank_account) {
diff --git a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py
index 19d8d49..ade7f81 100644
--- a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py
+++ b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py
@@ -167,7 +167,8 @@
"is_pos": 0,
"doctype": "Sales Invoice" if self.invoice_type == "Sales" else "Purchase Invoice",
"update_stock": 0,
- "invoice_number": row.invoice_number
+ "invoice_number": row.invoice_number,
+ "disable_rounded_total": 1
})
accounting_dimension = get_accounting_dimensions()
diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py
index 97d34e0..5229d87 100644
--- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py
+++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py
@@ -172,9 +172,10 @@
frappe.throw(error_msg, title=_("Invalid Item"), as_list=True)
def validate_stock_availablility(self):
+ from erpnext.stock.stock_ledger import is_negative_stock_allowed
+
if self.is_return or self.docstatus != 1:
return
- allow_negative_stock = frappe.db.get_single_value('Stock Settings', 'allow_negative_stock')
for d in self.get('items'):
is_service_item = not (frappe.db.get_value('Item', d.get('item_code'), 'is_stock_item'))
if is_service_item:
@@ -186,7 +187,7 @@
elif d.batch_no:
self.validate_pos_reserved_batch_qty(d)
else:
- if allow_negative_stock:
+ if is_negative_stock_allowed(item_code=d.item_code):
return
available_stock, is_stock_item = get_stock_availability(d.item_code, d.warehouse)
diff --git a/erpnext/accounts/general_ledger.py b/erpnext/accounts/general_ledger.py
index 55bc967..d24d56b 100644
--- a/erpnext/accounts/general_ledger.py
+++ b/erpnext/accounts/general_ledger.py
@@ -319,13 +319,18 @@
"""
if not gl_entries:
- gl_entries = frappe.get_all("GL Entry",
- fields = ["*"],
- filters = {
- "voucher_type": voucher_type,
- "voucher_no": voucher_no,
- "is_cancelled": 0
- })
+ gl_entry = frappe.qb.DocType("GL Entry")
+ gl_entries = (frappe.qb.from_(
+ gl_entry
+ ).select(
+ '*'
+ ).where(
+ gl_entry.voucher_type == voucher_type
+ ).where(
+ gl_entry.voucher_no == voucher_no
+ ).where(
+ gl_entry.is_cancelled == 0
+ ).for_update()).run(as_dict=1)
if gl_entries:
validate_accounting_period(gl_entries)
@@ -333,23 +338,24 @@
set_as_cancel(gl_entries[0]['voucher_type'], gl_entries[0]['voucher_no'])
for entry in gl_entries:
- entry['name'] = None
- debit = entry.get('debit', 0)
- credit = entry.get('credit', 0)
+ new_gle = copy.deepcopy(entry)
+ new_gle['name'] = None
+ debit = new_gle.get('debit', 0)
+ credit = new_gle.get('credit', 0)
- debit_in_account_currency = entry.get('debit_in_account_currency', 0)
- credit_in_account_currency = entry.get('credit_in_account_currency', 0)
+ debit_in_account_currency = new_gle.get('debit_in_account_currency', 0)
+ credit_in_account_currency = new_gle.get('credit_in_account_currency', 0)
- entry['debit'] = credit
- entry['credit'] = debit
- entry['debit_in_account_currency'] = credit_in_account_currency
- entry['credit_in_account_currency'] = debit_in_account_currency
+ new_gle['debit'] = credit
+ new_gle['credit'] = debit
+ new_gle['debit_in_account_currency'] = credit_in_account_currency
+ new_gle['credit_in_account_currency'] = debit_in_account_currency
- entry['remarks'] = "On cancellation of " + entry['voucher_no']
- entry['is_cancelled'] = 1
+ new_gle['remarks'] = "On cancellation of " + new_gle['voucher_no']
+ new_gle['is_cancelled'] = 1
- if entry['debit'] or entry['credit']:
- make_entry(entry, adv_adj, "Yes")
+ if new_gle['debit'] or new_gle['credit']:
+ make_entry(new_gle, adv_adj, "Yes")
def check_freezing_date(posting_date, adv_adj=False):
diff --git a/erpnext/accounts/print_format/gst_pos_invoice/gst_pos_invoice.json b/erpnext/accounts/print_format/gst_pos_invoice/gst_pos_invoice.json
deleted file mode 100644
index 1aa1c02..0000000
--- a/erpnext/accounts/print_format/gst_pos_invoice/gst_pos_invoice.json
+++ /dev/null
@@ -1,23 +0,0 @@
-{
- "align_labels_right": 0,
- "creation": "2017-08-08 12:33:04.773099",
- "custom_format": 1,
- "disabled": 0,
- "doc_type": "Sales Invoice",
- "docstatus": 0,
- "doctype": "Print Format",
- "font": "Default",
- "html": "<style>\n\t.print-format table, .print-format tr, \n\t.print-format td, .print-format div, .print-format p {\n\t\tfont-family: Tahoma, sans-serif;\n\t\tline-height: 150%;\n\t\tvertical-align: middle;\n\t}\n\t@media screen {\n\t\t.print-format {\n\t\t\twidth: 4in;\n\t\t\tpadding: 0.25in;\n\t\t\tmin-height: 8in;\n\t\t}\n\t}\n</style>\n\n{% if letter_head %}\n {{ letter_head }}\n{% endif %}\n<p class=\"text-center\">\n\t{{ doc.company }}<br>\n\t{% if doc.company_address_display %}\n\t\t{% set company_address = doc.company_address_display.replace(\"\\n\", \" \").replace(\"<br>\", \" \") %}\n\t\t{% if \"GSTIN\" not in company_address %}\n\t\t\t{{ company_address }}\n\t\t\t<b>{{ _(\"GSTIN\") }}:</b>{{ doc.company_gstin }}\n\t\t{% else %}\n\t\t\t{{ company_address.replace(\"GSTIN\", \"<br>GSTIN\") }}\n\t\t{% endif %}\n\t{% endif %}\n\t<br>\n\t{% if doc.docstatus == 0 %}\n\t\t<b>{{ doc.status + \" \"+ (doc.select_print_heading or _(\"Invoice\")) }}</b><br>\n\t{% else %}\n\t\t<b>{{ doc.select_print_heading or _(\"Invoice\") }}</b><br>\n\t{% endif %}\n</p>\n<p>\n\t<b>{{ _(\"Receipt No\") }}:</b> {{ doc.name }}<br>\n\t<b>{{ _(\"Date\") }}:</b> {{ doc.get_formatted(\"posting_date\") }}<br>\n\t{% if doc.grand_total > 50000 %}\n\t\t{% set customer_address = doc.address_display.replace(\"\\n\", \" \").replace(\"<br>\", \" \") %}\n\t\t<b>{{ _(\"Customer\") }}:</b><br>\n\t\t{{ doc.customer_name }}<br>\n\t\t{{ customer_address }}\n\t{% endif %}\n</p>\n\n<hr>\n<table class=\"table table-condensed cart no-border\">\n\t<thead>\n\t\t<tr>\n\t\t\t<th width=\"50%\">{{ _(\"Item\") }}</b></th>\n\t\t\t<th width=\"25%\" class=\"text-right\">{{ _(\"Qty\") }}</th>\n\t\t\t<th width=\"25%\" class=\"text-right\">{{ _(\"Amount\") }}</th>\n\t\t</tr>\n\t</thead>\n\t<tbody>\n\t\t{%- for item in doc.items -%}\n\t\t<tr>\n\t\t\t<td>\n\t\t\t\t{{ item.item_code }}\n\t\t\t\t{%- if item.item_name != item.item_code -%}\n\t\t\t\t\t<br>{{ item.item_name }}\n\t\t\t\t{%- endif -%}\n\t\t\t\t{%- if item.gst_hsn_code -%}\n\t\t\t\t\t<br><b>{{ _(\"HSN/SAC\") }}:</b> {{ item.gst_hsn_code }}\n\t\t\t\t{%- endif -%}\n\t\t\t\t{%- if item.serial_no -%}\n\t\t\t\t\t<br><b>{{ _(\"Serial No\") }}:</b> {{ item.serial_no }}\n\t\t\t\t{%- endif -%}\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">{{ item.qty }}<br>@ {{ item.rate }}</td>\n\t\t\t<td class=\"text-right\">{{ item.get_formatted(\"amount\") }}</td>\n\t\t</tr>\n\t\t{%- endfor -%}\n\t</tbody>\n</table>\n<table class=\"table table-condensed no-border\">\n\t<tbody>\n\t\t<tr>\n\t\t\t{% if doc.flags.show_inclusive_tax_in_print %}\n\t\t\t\t<td class=\"text-right\" style=\"width: 70%\">\n\t\t\t\t\t{{ _(\"Total Excl. Tax\") }}\n\t\t\t\t</td>\n\t\t\t\t<td class=\"text-right\">\n\t\t\t\t\t{{ doc.get_formatted(\"net_total\", doc) }}\n\t\t\t\t</td>\n\t\t\t{% else %}\n\t\t\t\t<td class=\"text-right\" style=\"width: 70%\">\n\t\t\t\t\t{{ _(\"Total\") }}\n\t\t\t\t</td>\n\t\t\t\t<td class=\"text-right\">\n\t\t\t\t\t{{ doc.get_formatted(\"total\", doc) }}\n\t\t\t\t</td>\n\t\t\t{% endif %}\n\t\t</tr>\n\t\t{%- for row in doc.taxes -%}\n\t\t {%- if (not row.included_in_print_rate or doc.flags.show_inclusive_tax_in_print) and row.tax_amount != 0 -%}\n\t\t\t<tr>\n\t\t\t\t<td class=\"text-right\" style=\"width: 70%\">\n\t\t\t\t\t{{ row.description }}\n\t\t\t\t</td>\n\t\t\t\t<td class=\"text-right\">\n\t\t\t\t\t{{ row.get_formatted(\"tax_amount\", doc) }}\n\t\t\t\t</td>\n\t\t\t<tr>\n\t\t {%- endif -%}\n\t\t{%- endfor -%}\n\t\t{%- if doc.discount_amount -%}\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 75%\">\n\t\t\t\t{{ _(\"Discount\") }}\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ doc.get_formatted(\"discount_amount\") }}\n\t\t\t</td>\n\t\t</tr>\n\t\t{%- endif -%}\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 75%\">\n\t\t\t\t<b>{{ _(\"Grand Total\") }}</b>\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ doc.get_formatted(\"grand_total\") }}\n\t\t\t</td>\n\t\t</tr>\n\t\t{%- if doc.rounded_total -%}\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 75%\">\n\t\t\t\t<b>{{ _(\"Rounded Total\") }}</b>\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ doc.get_formatted(\"rounded_total\") }}\n\t\t\t</td>\n\t\t</tr>\n\t\t{%- endif -%}\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 75%\">\n\t\t\t\t<b>{{ _(\"Paid Amount\") }}</b>\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ doc.get_formatted(\"paid_amount\") }}\n\t\t\t</td>\n\t\t</tr>\n\t{%- if doc.change_amount -%}\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 75%\">\n\t\t\t\t<b>{{ _(\"Change Amount\") }}</b>\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ doc.get_formatted(\"change_amount\") }}\n\t\t\t</td>\n\t\t</tr>\n\t{%- endif -%}\n\t</tbody>\n</table>\n<p>{{ doc.terms or \"\" }}</p>\n<p class=\"text-center\">{{ _(\"Thank you, please visit again.\") }}</p>",
- "idx": 0,
- "line_breaks": 0,
- "modified": "2020-04-29 16:39:12.936215",
- "modified_by": "Administrator",
- "module": "Accounts",
- "name": "GST POS Invoice",
- "owner": "Administrator",
- "print_format_builder": 0,
- "print_format_type": "Jinja",
- "raw_printing": 0,
- "show_section_headings": 0,
- "standard": "Yes"
-}
\ No newline at end of file
diff --git a/erpnext/accounts/report/gross_profit/gross_profit.js b/erpnext/accounts/report/gross_profit/gross_profit.js
index 685f2d6..2ba649d 100644
--- a/erpnext/accounts/report/gross_profit/gross_profit.js
+++ b/erpnext/accounts/report/gross_profit/gross_profit.js
@@ -42,6 +42,11 @@
"parent_field": "parent_invoice",
"initial_depth": 3,
"formatter": function(value, row, column, data, default_formatter) {
+ if (column.fieldname == "sales_invoice" && column.options == "Item" && data.indent == 0) {
+ column._options = "Sales Invoice";
+ } else {
+ column._options = "Item";
+ }
value = default_formatter(value, row, column, data);
if (data && (data.indent == 0.0 || row[1].content == "Total")) {
diff --git a/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py b/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py
index caee1a1..57f7974 100644
--- a/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py
+++ b/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py
@@ -23,7 +23,7 @@
def get_result(filters, tds_docs, tds_accounts, tax_category_map):
supplier_map = get_supplier_pan_map()
tax_rate_map = get_tax_rate_map(filters)
- gle_map = get_gle_map(filters, tds_docs)
+ gle_map = get_gle_map(tds_docs)
out = []
for name, details in gle_map.items():
@@ -78,7 +78,7 @@
return supplier_map
-def get_gle_map(filters, documents):
+def get_gle_map(documents):
# create gle_map of the form
# {"purchase_invoice": list of dict of all gle created for this invoice}
gle_map = {}
@@ -86,7 +86,7 @@
gle = frappe.db.get_all('GL Entry',
{
"voucher_no": ["in", documents],
- "credit": (">", 0)
+ "is_cancelled": 0
},
["credit", "debit", "account", "voucher_no", "posting_date", "voucher_type", "against", "party"],
)
@@ -184,21 +184,28 @@
payment_entries = []
journal_entries = []
tax_category_map = {}
+ or_filters = {}
+ bank_accounts = frappe.get_all('Account', {'is_group': 0, 'account_type': 'Bank'}, pluck="name")
tds_accounts = frappe.get_all("Tax Withholding Account", {'company': filters.get('company')},
pluck="account")
query_filters = {
- "credit": ('>', 0),
"account": ("in", tds_accounts),
"posting_date": ("between", [filters.get("from_date"), filters.get("to_date")]),
- "is_cancelled": 0
+ "is_cancelled": 0,
+ "against": ("not in", bank_accounts)
}
- if filters.get('supplier'):
- query_filters.update({'against': filters.get('supplier')})
+ if filters.get("supplier"):
+ del query_filters["account"]
+ del query_filters["against"]
+ or_filters = {
+ "against": filters.get('supplier'),
+ "party": filters.get('supplier')
+ }
- tds_docs = frappe.get_all("GL Entry", query_filters, ["voucher_no", "voucher_type", "against", "party"])
+ tds_docs = frappe.get_all("GL Entry", filters=query_filters, or_filters=or_filters, fields=["voucher_no", "voucher_type", "against", "party"])
for d in tds_docs:
if d.voucher_type == "Purchase Invoice":
diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py
index 9a63afc..645e97e 100644
--- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py
+++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py
@@ -682,17 +682,18 @@
bin1 = frappe.db.get_value("Bin",
filters={"warehouse": "_Test Warehouse - _TC", "item_code": "_Test Item"},
- fieldname=["reserved_qty_for_sub_contract", "projected_qty"], as_dict=1)
+ fieldname=["reserved_qty_for_sub_contract", "projected_qty", "modified"], as_dict=1)
# Submit PO
po = create_purchase_order(item_code="_Test FG Item", is_subcontracted="Yes")
bin2 = frappe.db.get_value("Bin",
filters={"warehouse": "_Test Warehouse - _TC", "item_code": "_Test Item"},
- fieldname=["reserved_qty_for_sub_contract", "projected_qty"], as_dict=1)
+ fieldname=["reserved_qty_for_sub_contract", "projected_qty", "modified"], as_dict=1)
self.assertEqual(bin2.reserved_qty_for_sub_contract, bin1.reserved_qty_for_sub_contract + 10)
self.assertEqual(bin2.projected_qty, bin1.projected_qty - 10)
+ self.assertNotEqual(bin1.modified, bin2.modified)
# Create stock transfer
rm_item = [{"item_code":"_Test FG Item","rm_item_code":"_Test Item","item_name":"_Test Item",
diff --git a/erpnext/buying/doctype/supplier_scorecard/test_supplier_scorecard.py b/erpnext/buying/doctype/supplier_scorecard/test_supplier_scorecard.py
index 49e3351..7908c35 100644
--- a/erpnext/buying/doctype/supplier_scorecard/test_supplier_scorecard.py
+++ b/erpnext/buying/doctype/supplier_scorecard/test_supplier_scorecard.py
@@ -49,7 +49,7 @@
"min_grade":0.0,"name":"Very Poor",
"prevent_rfqs":1,
"notify_supplier":0,
- "doctype":"Supplier Scorecard Standing",
+ "doctype":"Supplier Scorecard Scoring Standing",
"max_grade":30.0,
"prevent_pos":1,
"warn_pos":0,
@@ -65,7 +65,7 @@
"name":"Poor",
"prevent_rfqs":1,
"notify_supplier":0,
- "doctype":"Supplier Scorecard Standing",
+ "doctype":"Supplier Scorecard Scoring Standing",
"max_grade":50.0,
"prevent_pos":0,
"warn_pos":0,
@@ -81,7 +81,7 @@
"name":"Average",
"prevent_rfqs":0,
"notify_supplier":0,
- "doctype":"Supplier Scorecard Standing",
+ "doctype":"Supplier Scorecard Scoring Standing",
"max_grade":80.0,
"prevent_pos":0,
"warn_pos":0,
@@ -97,7 +97,7 @@
"name":"Excellent",
"prevent_rfqs":0,
"notify_supplier":0,
- "doctype":"Supplier Scorecard Standing",
+ "doctype":"Supplier Scorecard Scoring Standing",
"max_grade":100.0,
"prevent_pos":0,
"warn_pos":0,
diff --git a/erpnext/crm/doctype/opportunity_lost_reason/opportunity_lost_reason.json b/erpnext/crm/doctype/opportunity_lost_reason/opportunity_lost_reason.json
index 8a8d425..0cfcf0e 100644
--- a/erpnext/crm/doctype/opportunity_lost_reason/opportunity_lost_reason.json
+++ b/erpnext/crm/doctype/opportunity_lost_reason/opportunity_lost_reason.json
@@ -3,7 +3,7 @@
"allow_events_in_timeline": 0,
"allow_guest_to_view": 0,
"allow_import": 0,
- "allow_rename": 0,
+ "allow_rename": 1,
"autoname": "field:lost_reason",
"beta": 0,
"creation": "2018-12-28 14:48:51.044975",
@@ -57,7 +57,7 @@
"issingle": 0,
"istable": 0,
"max_attachments": 0,
- "modified": "2018-12-28 14:49:43.336437",
+ "modified": "2022-02-16 10:49:43.336437",
"modified_by": "Administrator",
"module": "CRM",
"name": "Opportunity Lost Reason",
@@ -150,4 +150,4 @@
"track_changes": 0,
"track_seen": 0,
"track_views": 0
-}
\ No newline at end of file
+}
diff --git a/erpnext/e_commerce/variant_selector/item_variants_cache.py b/erpnext/e_commerce/variant_selector/item_variants_cache.py
index bb6b3ef..3107c01 100644
--- a/erpnext/e_commerce/variant_selector/item_variants_cache.py
+++ b/erpnext/e_commerce/variant_selector/item_variants_cache.py
@@ -66,26 +66,24 @@
)
]
- # join with Website Item
- item_variants_data = frappe.get_all(
- 'Item Variant Attribute',
- {'variant_of': parent_item_code},
- ['parent', 'attribute', 'attribute_value'],
- order_by='name',
- as_list=1
+ # Get Variants and tehir Attributes that are not disabled
+ iva = frappe.qb.DocType("Item Variant Attribute")
+ item = frappe.qb.DocType("Item")
+ query = (
+ frappe.qb.from_(iva)
+ .join(item).on(item.name == iva.parent)
+ .select(
+ iva.parent, iva.attribute, iva.attribute_value
+ ).where(
+ (iva.variant_of == parent_item_code)
+ & (item.disabled == 0)
+ ).orderby(iva.name)
)
-
- disabled_items = set(
- [i.name for i in frappe.db.get_all('Item', {'disabled': 1})]
- )
+ item_variants_data = query.run()
attribute_value_item_map = frappe._dict()
item_attribute_value_map = frappe._dict()
- # dont consider variants that are disabled
- # pull all other variants
- item_variants_data = [r for r in item_variants_data if r[0] not in disabled_items]
-
for row in item_variants_data:
item_code, attribute, attribute_value = row
# (attr, value) => [item1, item2]
@@ -124,4 +122,7 @@
def enqueue_build_cache(item_code):
if frappe.cache().hget('item_cache_build_in_progress', item_code):
return
- frappe.enqueue(build_cache, item_code=item_code, queue='long')
+ frappe.enqueue(
+ "erpnext.e_commerce.variant_selector.item_variants_cache.build_cache",
+ item_code=item_code, queue='long'
+ )
diff --git a/erpnext/e_commerce/variant_selector/test_variant_selector.py b/erpnext/e_commerce/variant_selector/test_variant_selector.py
index b83961e..4d907c6 100644
--- a/erpnext/e_commerce/variant_selector/test_variant_selector.py
+++ b/erpnext/e_commerce/variant_selector/test_variant_selector.py
@@ -104,6 +104,8 @@
})
make_web_item_price(item_code="Test-Tshirt-Temp-S-R", price_list_rate=100)
+
+ frappe.local.shopping_cart_settings = None # clear cached settings values
next_values = get_next_attribute_and_values(
"Test-Tshirt-Temp",
selected_attributes={"Test Size": "Small", "Test Colour": "Red"}
diff --git a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/__init__.py b/erpnext/erpnext_integrations/doctype/amazon_mws_settings/__init__.py
deleted file mode 100644
index e69de29..0000000
--- a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/__init__.py
+++ /dev/null
diff --git a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_methods.py b/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_methods.py
deleted file mode 100644
index 29bc36f..0000000
--- a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_methods.py
+++ /dev/null
@@ -1,524 +0,0 @@
-# Copyright (c) 2018, Frappe Technologies and contributors
-# For license information, please see license.txt
-
-
-import csv
-import math
-import time
-from io import StringIO
-
-import dateutil
-import frappe
-from frappe import _
-
-import erpnext.erpnext_integrations.doctype.amazon_mws_settings.amazon_mws_api as mws
-
-
-#Get and Create Products
-def get_products_details():
- products = get_products_instance()
- reports = get_reports_instance()
-
- mws_settings = frappe.get_doc("Amazon MWS Settings")
- market_place_list = return_as_list(mws_settings.market_place_id)
-
- for marketplace in market_place_list:
- report_id = request_and_fetch_report_id("_GET_FLAT_FILE_OPEN_LISTINGS_DATA_", None, None, market_place_list)
-
- if report_id:
- listings_response = reports.get_report(report_id=report_id)
-
- #Get ASIN Codes
- string_io = StringIO(frappe.safe_decode(listings_response.original))
- csv_rows = list(csv.reader(string_io, delimiter='\t'))
- asin_list = list(set([row[1] for row in csv_rows[1:]]))
- #break into chunks of 10
- asin_chunked_list = list(chunks(asin_list, 10))
-
- #Map ASIN Codes to SKUs
- sku_asin = [{"asin":row[1],"sku":row[0]} for row in csv_rows[1:]]
-
- #Fetch Products List from ASIN
- for asin_list in asin_chunked_list:
- products_response = call_mws_method(products.get_matching_product,marketplaceid=marketplace,
- asins=asin_list)
-
- matching_products_list = products_response.parsed
- for product in matching_products_list:
- skus = [row["sku"] for row in sku_asin if row["asin"]==product.ASIN]
- for sku in skus:
- create_item_code(product, sku)
-
-def get_products_instance():
- mws_settings = frappe.get_doc("Amazon MWS Settings")
- products = mws.Products(
- account_id = mws_settings.seller_id,
- access_key = mws_settings.aws_access_key_id,
- secret_key = mws_settings.secret_key,
- region = mws_settings.region,
- domain = mws_settings.domain
- )
-
- return products
-
-def get_reports_instance():
- mws_settings = frappe.get_doc("Amazon MWS Settings")
- reports = mws.Reports(
- account_id = mws_settings.seller_id,
- access_key = mws_settings.aws_access_key_id,
- secret_key = mws_settings.secret_key,
- region = mws_settings.region,
- domain = mws_settings.domain
- )
-
- return reports
-
-#returns list as expected by amazon API
-def return_as_list(input_value):
- if isinstance(input_value, list):
- return input_value
- else:
- return [input_value]
-
-#function to chunk product data
-def chunks(l, n):
- for i in range(0, len(l), n):
- yield l[i:i+n]
-
-def request_and_fetch_report_id(report_type, start_date=None, end_date=None, marketplaceids=None):
- reports = get_reports_instance()
- report_response = reports.request_report(report_type=report_type,
- start_date=start_date,
- end_date=end_date,
- marketplaceids=marketplaceids)
-
- report_request_id = report_response.parsed["ReportRequestInfo"]["ReportRequestId"]["value"]
- generated_report_id = None
- #poll to get generated report
- for x in range(1,10):
- report_request_list_response = reports.get_report_request_list(requestids=[report_request_id])
- report_status = report_request_list_response.parsed["ReportRequestInfo"]["ReportProcessingStatus"]["value"]
-
- if report_status == "_SUBMITTED_" or report_status == "_IN_PROGRESS_":
- #add time delay to wait for amazon to generate report
- time.sleep(15)
- continue
- elif report_status == "_CANCELLED_":
- break
- elif report_status == "_DONE_NO_DATA_":
- break
- elif report_status == "_DONE_":
- generated_report_id = report_request_list_response.parsed["ReportRequestInfo"]["GeneratedReportId"]["value"]
- break
- return generated_report_id
-
-def call_mws_method(mws_method, *args, **kwargs):
-
- mws_settings = frappe.get_doc("Amazon MWS Settings")
- max_retries = mws_settings.max_retry_limit
-
- for x in range(0, max_retries):
- try:
- response = mws_method(*args, **kwargs)
- return response
- except Exception as e:
- delay = math.pow(4, x) * 125
- frappe.log_error(message=e, title=f'Method "{mws_method.__name__}" failed')
- time.sleep(delay)
- continue
-
- mws_settings.enable_sync = 0
- mws_settings.save()
-
- frappe.throw(_("Sync has been temporarily disabled because maximum retries have been exceeded"))
-
-def create_item_code(amazon_item_json, sku):
- if frappe.db.get_value("Item", sku):
- return
-
- item = frappe.new_doc("Item")
-
- new_manufacturer = create_manufacturer(amazon_item_json)
- new_brand = create_brand(amazon_item_json)
-
- mws_settings = frappe.get_doc("Amazon MWS Settings")
-
- item.item_code = sku
- item.amazon_item_code = amazon_item_json.ASIN
- item.item_group = mws_settings.item_group
- item.description = amazon_item_json.Product.AttributeSets.ItemAttributes.Title
- item.brand = new_brand
- item.manufacturer = new_manufacturer
-
- item.image = amazon_item_json.Product.AttributeSets.ItemAttributes.SmallImage.URL
-
- temp_item_group = amazon_item_json.Product.AttributeSets.ItemAttributes.ProductGroup
-
- item_group = frappe.db.get_value("Item Group",filters={"item_group_name": temp_item_group})
-
- if not item_group:
- igroup = frappe.new_doc("Item Group")
- igroup.item_group_name = temp_item_group
- igroup.parent_item_group = mws_settings.item_group
- igroup.insert()
-
- item.append("item_defaults", {'company':mws_settings.company})
-
- item.insert(ignore_permissions=True)
- create_item_price(amazon_item_json, item.item_code)
-
- return item.name
-
-def create_manufacturer(amazon_item_json):
- if not amazon_item_json.Product.AttributeSets.ItemAttributes.Manufacturer:
- return None
-
- existing_manufacturer = frappe.db.get_value("Manufacturer",
- filters={"short_name":amazon_item_json.Product.AttributeSets.ItemAttributes.Manufacturer})
-
- if not existing_manufacturer:
- manufacturer = frappe.new_doc("Manufacturer")
- manufacturer.short_name = amazon_item_json.Product.AttributeSets.ItemAttributes.Manufacturer
- manufacturer.insert()
- return manufacturer.short_name
- else:
- return existing_manufacturer
-
-def create_brand(amazon_item_json):
- if not amazon_item_json.Product.AttributeSets.ItemAttributes.Brand:
- return None
-
- existing_brand = frappe.db.get_value("Brand",
- filters={"brand":amazon_item_json.Product.AttributeSets.ItemAttributes.Brand})
- if not existing_brand:
- brand = frappe.new_doc("Brand")
- brand.brand = amazon_item_json.Product.AttributeSets.ItemAttributes.Brand
- brand.insert()
- return brand.brand
- else:
- return existing_brand
-
-def create_item_price(amazon_item_json, item_code):
- item_price = frappe.new_doc("Item Price")
- item_price.price_list = frappe.db.get_value("Amazon MWS Settings", "Amazon MWS Settings", "price_list")
- if not("ListPrice" in amazon_item_json.Product.AttributeSets.ItemAttributes):
- item_price.price_list_rate = 0
- else:
- item_price.price_list_rate = amazon_item_json.Product.AttributeSets.ItemAttributes.ListPrice.Amount
-
- item_price.item_code = item_code
- item_price.insert()
-
-#Get and create Orders
-def get_orders(after_date):
- try:
- orders = get_orders_instance()
- statuses = ["PartiallyShipped", "Unshipped", "Shipped", "Canceled"]
- mws_settings = frappe.get_doc("Amazon MWS Settings")
- market_place_list = return_as_list(mws_settings.market_place_id)
-
- orders_response = call_mws_method(orders.list_orders, marketplaceids=market_place_list,
- fulfillment_channels=["MFN", "AFN"],
- lastupdatedafter=after_date,
- orderstatus=statuses,
- max_results='50')
-
- while True:
- orders_list = []
-
- if "Order" in orders_response.parsed.Orders:
- orders_list = return_as_list(orders_response.parsed.Orders.Order)
-
- if len(orders_list) == 0:
- break
-
- for order in orders_list:
- create_sales_order(order, after_date)
-
- if not "NextToken" in orders_response.parsed:
- break
-
- next_token = orders_response.parsed.NextToken
- orders_response = call_mws_method(orders.list_orders_by_next_token, next_token)
-
- except Exception as e:
- frappe.log_error(title="get_orders", message=e)
-
-def get_orders_instance():
- mws_settings = frappe.get_doc("Amazon MWS Settings")
- orders = mws.Orders(
- account_id = mws_settings.seller_id,
- access_key = mws_settings.aws_access_key_id,
- secret_key = mws_settings.secret_key,
- region= mws_settings.region,
- domain= mws_settings.domain,
- version="2013-09-01"
- )
-
- return orders
-
-def create_sales_order(order_json,after_date):
- customer_name = create_customer(order_json)
- create_address(order_json, customer_name)
-
- market_place_order_id = order_json.AmazonOrderId
-
- so = frappe.db.get_value("Sales Order",
- filters={"amazon_order_id": market_place_order_id},
- fieldname="name")
-
- taxes_and_charges = frappe.db.get_value("Amazon MWS Settings", "Amazon MWS Settings", "taxes_charges")
-
- if so:
- return
-
- if not so:
- items = get_order_items(market_place_order_id)
- delivery_date = dateutil.parser.parse(order_json.LatestShipDate).strftime("%Y-%m-%d")
- transaction_date = dateutil.parser.parse(order_json.PurchaseDate).strftime("%Y-%m-%d")
-
- so = frappe.get_doc({
- "doctype": "Sales Order",
- "naming_series": "SO-",
- "amazon_order_id": market_place_order_id,
- "marketplace_id": order_json.MarketplaceId,
- "customer": customer_name,
- "delivery_date": delivery_date,
- "transaction_date": transaction_date,
- "items": items,
- "company": frappe.db.get_value("Amazon MWS Settings", "Amazon MWS Settings", "company")
- })
-
- try:
- if taxes_and_charges:
- charges_and_fees = get_charges_and_fees(market_place_order_id)
- for charge in charges_and_fees.get("charges"):
- so.append('taxes', charge)
-
- for fee in charges_and_fees.get("fees"):
- so.append('taxes', fee)
-
- so.insert(ignore_permissions=True)
- so.submit()
-
- except Exception as e:
- import traceback
- frappe.log_error(message=traceback.format_exc(), title="Create Sales Order")
-
-def create_customer(order_json):
- order_customer_name = ""
-
- if not("BuyerName" in order_json):
- order_customer_name = "Buyer - " + order_json.AmazonOrderId
- else:
- order_customer_name = order_json.BuyerName
-
- existing_customer_name = frappe.db.get_value("Customer",
- filters={"name": order_customer_name}, fieldname="name")
-
- if existing_customer_name:
- filters = [
- ["Dynamic Link", "link_doctype", "=", "Customer"],
- ["Dynamic Link", "link_name", "=", existing_customer_name],
- ["Dynamic Link", "parenttype", "=", "Contact"]
- ]
-
- existing_contacts = frappe.get_list("Contact", filters)
-
- if existing_contacts:
- pass
- else:
- new_contact = frappe.new_doc("Contact")
- new_contact.first_name = order_customer_name
- new_contact.append('links', {
- "link_doctype": "Customer",
- "link_name": existing_customer_name
- })
- new_contact.insert()
-
- return existing_customer_name
- else:
- mws_customer_settings = frappe.get_doc("Amazon MWS Settings")
- new_customer = frappe.new_doc("Customer")
- new_customer.customer_name = order_customer_name
- new_customer.customer_group = mws_customer_settings.customer_group
- new_customer.territory = mws_customer_settings.territory
- new_customer.customer_type = mws_customer_settings.customer_type
- new_customer.save()
-
- new_contact = frappe.new_doc("Contact")
- new_contact.first_name = order_customer_name
- new_contact.append('links', {
- "link_doctype": "Customer",
- "link_name": new_customer.name
- })
-
- new_contact.insert()
-
- return new_customer.name
-
-def create_address(amazon_order_item_json, customer_name):
-
- filters = [
- ["Dynamic Link", "link_doctype", "=", "Customer"],
- ["Dynamic Link", "link_name", "=", customer_name],
- ["Dynamic Link", "parenttype", "=", "Address"]
- ]
-
- existing_address = frappe.get_list("Address", filters)
-
- if not("ShippingAddress" in amazon_order_item_json):
- return None
- else:
- make_address = frappe.new_doc("Address")
-
- if "AddressLine1" in amazon_order_item_json.ShippingAddress:
- make_address.address_line1 = amazon_order_item_json.ShippingAddress.AddressLine1
- else:
- make_address.address_line1 = "Not Provided"
-
- if "City" in amazon_order_item_json.ShippingAddress:
- make_address.city = amazon_order_item_json.ShippingAddress.City
- else:
- make_address.city = "Not Provided"
-
- if "StateOrRegion" in amazon_order_item_json.ShippingAddress:
- make_address.state = amazon_order_item_json.ShippingAddress.StateOrRegion
-
- if "PostalCode" in amazon_order_item_json.ShippingAddress:
- make_address.pincode = amazon_order_item_json.ShippingAddress.PostalCode
-
- for address in existing_address:
- address_doc = frappe.get_doc("Address", address["name"])
- if (address_doc.address_line1 == make_address.address_line1 and
- address_doc.pincode == make_address.pincode):
- return address
-
- make_address.append("links", {
- "link_doctype": "Customer",
- "link_name": customer_name
- })
- make_address.address_type = "Shipping"
- make_address.insert()
-
-def get_order_items(market_place_order_id):
- mws_orders = get_orders_instance()
-
- order_items_response = call_mws_method(mws_orders.list_order_items, amazon_order_id=market_place_order_id)
- final_order_items = []
-
- order_items_list = return_as_list(order_items_response.parsed.OrderItems.OrderItem)
-
- warehouse = frappe.db.get_value("Amazon MWS Settings", "Amazon MWS Settings", "warehouse")
-
- while True:
- for order_item in order_items_list:
-
- if not "ItemPrice" in order_item:
- price = 0
- else:
- price = order_item.ItemPrice.Amount
-
- final_order_items.append({
- "item_code": get_item_code(order_item),
- "item_name": order_item.SellerSKU,
- "description": order_item.Title,
- "rate": price,
- "qty": order_item.QuantityOrdered,
- "stock_uom": "Nos",
- "warehouse": warehouse,
- "conversion_factor": "1.0"
- })
-
- if not "NextToken" in order_items_response.parsed:
- break
-
- next_token = order_items_response.parsed.NextToken
-
- order_items_response = call_mws_method(mws_orders.list_order_items_by_next_token, next_token)
- order_items_list = return_as_list(order_items_response.parsed.OrderItems.OrderItem)
-
- return final_order_items
-
-def get_item_code(order_item):
- sku = order_item.SellerSKU
- item_code = frappe.db.get_value("Item", {"item_code": sku}, "item_code")
- if item_code:
- return item_code
-
-def get_charges_and_fees(market_place_order_id):
- finances = get_finances_instance()
-
- charges_fees = {"charges":[], "fees":[]}
-
- response = call_mws_method(finances.list_financial_events, amazon_order_id=market_place_order_id)
-
- shipment_event_list = return_as_list(response.parsed.FinancialEvents.ShipmentEventList)
-
- for shipment_event in shipment_event_list:
- if shipment_event:
- shipment_item_list = return_as_list(shipment_event.ShipmentEvent.ShipmentItemList.ShipmentItem)
-
- for shipment_item in shipment_item_list:
- charges, fees = [], []
-
- if 'ItemChargeList' in shipment_item.keys():
- charges = return_as_list(shipment_item.ItemChargeList.ChargeComponent)
-
- if 'ItemFeeList' in shipment_item.keys():
- fees = return_as_list(shipment_item.ItemFeeList.FeeComponent)
-
- for charge in charges:
- if(charge.ChargeType != "Principal") and float(charge.ChargeAmount.CurrencyAmount) != 0:
- charge_account = get_account(charge.ChargeType)
- charges_fees.get("charges").append({
- "charge_type":"Actual",
- "account_head": charge_account,
- "tax_amount": charge.ChargeAmount.CurrencyAmount,
- "description": charge.ChargeType + " for " + shipment_item.SellerSKU
- })
-
- for fee in fees:
- if float(fee.FeeAmount.CurrencyAmount) != 0:
- fee_account = get_account(fee.FeeType)
- charges_fees.get("fees").append({
- "charge_type":"Actual",
- "account_head": fee_account,
- "tax_amount": fee.FeeAmount.CurrencyAmount,
- "description": fee.FeeType + " for " + shipment_item.SellerSKU
- })
-
- return charges_fees
-
-def get_finances_instance():
-
- mws_settings = frappe.get_doc("Amazon MWS Settings")
-
- finances = mws.Finances(
- account_id = mws_settings.seller_id,
- access_key = mws_settings.aws_access_key_id,
- secret_key = mws_settings.secret_key,
- region= mws_settings.region,
- domain= mws_settings.domain,
- version="2015-05-01"
- )
-
- return finances
-
-def get_account(name):
- existing_account = frappe.db.get_value("Account", {"account_name": "Amazon {0}".format(name)})
- account_name = existing_account
- mws_settings = frappe.get_doc("Amazon MWS Settings")
-
- if not existing_account:
- try:
- new_account = frappe.new_doc("Account")
- new_account.account_name = "Amazon {0}".format(name)
- new_account.company = mws_settings.company
- new_account.parent_account = mws_settings.market_place_account_group
- new_account.insert(ignore_permissions=True)
- account_name = new_account.name
- except Exception as e:
- frappe.log_error(message=e, title="Create Account")
-
- return account_name
diff --git a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_api.py b/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_api.py
deleted file mode 100755
index 4caf137..0000000
--- a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_api.py
+++ /dev/null
@@ -1,651 +0,0 @@
-#!/usr/bin/env python
-#
-# Basic interface to Amazon MWS
-# Based on http://code.google.com/p/amazon-mws-python
-# Extended to include finances object
-
-import base64
-import hashlib
-import hmac
-import re
-from urllib.parse import quote
-
-from erpnext.erpnext_integrations.doctype.amazon_mws_settings import xml_utils
-
-try:
- from xml.etree.ElementTree import ParseError as XMLError
-except ImportError:
- from xml.parsers.expat import ExpatError as XMLError
-
-from time import gmtime, strftime
-
-from requests import request
-from requests.exceptions import HTTPError
-
-__all__ = [
- 'Feeds',
- 'Inventory',
- 'MWSError',
- 'Reports',
- 'Orders',
- 'Products',
- 'Recommendations',
- 'Sellers',
- 'Finances'
-]
-
-# See https://images-na.ssl-images-amazon.com/images/G/01/mwsportal/doc/en_US/bde/MWSDeveloperGuide._V357736853_.pdf page 8
-# for a list of the end points and marketplace IDs
-
-MARKETPLACES = {
- "CA": "https://mws.amazonservices.ca", #A2EUQ1WTGCTBG2
- "US": "https://mws.amazonservices.com", #ATVPDKIKX0DER",
- "DE": "https://mws-eu.amazonservices.com", #A1PA6795UKMFR9
- "ES": "https://mws-eu.amazonservices.com", #A1RKKUPIHCS9HS
- "FR": "https://mws-eu.amazonservices.com", #A13V1IB3VIYZZH
- "IN": "https://mws.amazonservices.in", #A21TJRUUN4KGV
- "IT": "https://mws-eu.amazonservices.com", #APJ6JRA9NG5V4
- "UK": "https://mws-eu.amazonservices.com", #A1F83G8C2ARO7P
- "JP": "https://mws.amazonservices.jp", #A1VC38T7YXB528
- "CN": "https://mws.amazonservices.com.cn", #AAHKV2X7AFYLW
- "AE": " https://mws.amazonservices.ae", #A2VIGQ35RCS4UG
- "MX": "https://mws.amazonservices.com.mx", #A1AM78C64UM0Y8
- "BR": "https://mws.amazonservices.com", #A2Q3Y263D00KWC
-}
-
-
-class MWSError(Exception):
- """
- Main MWS Exception class
- """
- # Allows quick access to the response object.
- # Do not rely on this attribute, always check if its not None.
- response = None
-
-def calc_md5(string):
- """Calculates the MD5 encryption for the given string
- """
- md = hashlib.md5()
- md.update(string)
- return base64.encodebytes(md.digest()).decode().strip()
-
-
-
-def remove_empty(d):
- """
- Helper function that removes all keys from a dictionary (d),
- that have an empty value.
- """
- for key in list(d):
- if not d[key]:
- del d[key]
- return d
-
-def remove_namespace(xml):
- xml = xml.decode('utf-8')
- regex = re.compile(' xmlns(:ns2)?="[^"]+"|(ns2:)|(xml:)')
- return regex.sub('', xml)
-
-class DictWrapper(object):
- def __init__(self, xml, rootkey=None):
- self.original = xml
- self._rootkey = rootkey
- self._mydict = xml_utils.xml2dict().fromstring(remove_namespace(xml))
- self._response_dict = self._mydict.get(list(self._mydict)[0], self._mydict)
-
- @property
- def parsed(self):
- if self._rootkey:
- return self._response_dict.get(self._rootkey)
- else:
- return self._response_dict
-
-class DataWrapper(object):
- """
- Text wrapper in charge of validating the hash sent by Amazon.
- """
- def __init__(self, data, header):
- self.original = data
- if 'content-md5' in header:
- hash_ = calc_md5(self.original)
- if header['content-md5'] != hash_:
- raise MWSError("Wrong Contentlength, maybe amazon error...")
-
- @property
- def parsed(self):
- return self.original
-
-class MWS(object):
- """ Base Amazon API class """
-
- # This is used to post/get to the different uris used by amazon per api
- # ie. /Orders/2011-01-01
- # All subclasses must define their own URI only if needed
- URI = "/"
-
- # The API version varies in most amazon APIs
- VERSION = "2009-01-01"
-
- # There seem to be some xml namespace issues. therefore every api subclass
- # is recommended to define its namespace, so that it can be referenced
- # like so AmazonAPISubclass.NS.
- # For more information see http://stackoverflow.com/a/8719461/389453
- NS = ''
-
- # Some APIs are available only to either a "Merchant" or "Seller"
- # the type of account needs to be sent in every call to the amazon MWS.
- # This constant defines the exact name of the parameter Amazon expects
- # for the specific API being used.
- # All subclasses need to define this if they require another account type
- # like "Merchant" in which case you define it like so.
- # ACCOUNT_TYPE = "Merchant"
- # Which is the name of the parameter for that specific account type.
- ACCOUNT_TYPE = "SellerId"
-
- def __init__(self, access_key, secret_key, account_id, region='US', domain='', uri="", version=""):
- self.access_key = access_key
- self.secret_key = secret_key
- self.account_id = account_id
- self.version = version or self.VERSION
- self.uri = uri or self.URI
-
- if domain:
- self.domain = domain
- elif region in MARKETPLACES:
- self.domain = MARKETPLACES[region]
- else:
- error_msg = "Incorrect region supplied ('%(region)s'). Must be one of the following: %(marketplaces)s" % {
- "marketplaces" : ', '.join(MARKETPLACES.keys()),
- "region" : region,
- }
- raise MWSError(error_msg)
-
- def make_request(self, extra_data, method="GET", **kwargs):
- """Make request to Amazon MWS API with these parameters
- """
-
- # Remove all keys with an empty value because
- # Amazon's MWS does not allow such a thing.
- extra_data = remove_empty(extra_data)
-
- params = {
- 'AWSAccessKeyId': self.access_key,
- self.ACCOUNT_TYPE: self.account_id,
- 'SignatureVersion': '2',
- 'Timestamp': self.get_timestamp(),
- 'Version': self.version,
- 'SignatureMethod': 'HmacSHA256',
- }
- params.update(extra_data)
- request_description = '&'.join(['%s=%s' % (k, quote(params[k], safe='-_.~')) for k in sorted(params)])
- signature = self.calc_signature(method, request_description)
- url = '%s%s?%s&Signature=%s' % (self.domain, self.uri, request_description, quote(signature))
- headers = {'User-Agent': 'python-amazon-mws/0.0.1 (Language=Python)'}
- headers.update(kwargs.get('extra_headers', {}))
-
- try:
- # Some might wonder as to why i don't pass the params dict as the params argument to request.
- # My answer is, here i have to get the url parsed string of params in order to sign it, so
- # if i pass the params dict as params to request, request will repeat that step because it will need
- # to convert the dict to a url parsed string, so why do it twice if i can just pass the full url :).
- response = request(method, url, data=kwargs.get('body', ''), headers=headers)
- response.raise_for_status()
- # When retrieving data from the response object,
- # be aware that response.content returns the content in bytes while response.text calls
- # response.content and converts it to unicode.
- data = response.content
-
- # I do not check the headers to decide which content structure to server simply because sometimes
- # Amazon's MWS API returns XML error responses with "text/plain" as the Content-Type.
- try:
- parsed_response = DictWrapper(data, extra_data.get("Action") + "Result")
- except XMLError:
- parsed_response = DataWrapper(data, response.headers)
-
- except HTTPError as e:
- error = MWSError(str(e))
- error.response = e.response
- raise error
-
- # Store the response object in the parsed_response for quick access
- parsed_response.response = response
- return parsed_response
-
- def get_service_status(self):
- """
- Returns a GREEN, GREEN_I, YELLOW or RED status.
- Depending on the status/availability of the API its being called from.
- """
-
- return self.make_request(extra_data=dict(Action='GetServiceStatus'))
-
- def calc_signature(self, method, request_description):
- """Calculate MWS signature to interface with Amazon
- """
- sig_data = method + '\n' + self.domain.replace('https://', '').lower() + '\n' + self.uri + '\n' + request_description
- sig_data = sig_data.encode('utf-8')
- secret_key = self.secret_key.encode('utf-8')
- digest = hmac.new(secret_key, sig_data, hashlib.sha256).digest()
- return base64.b64encode(digest).decode('utf-8')
-
- def get_timestamp(self):
- """
- Returns the current timestamp in proper format.
- """
- return strftime("%Y-%m-%dT%H:%M:%SZ", gmtime())
-
- def enumerate_param(self, param, values):
- """
- Builds a dictionary of an enumerated parameter.
- Takes any iterable and returns a dictionary.
- ie.
- enumerate_param('MarketplaceIdList.Id', (123, 345, 4343))
- returns
- {
- MarketplaceIdList.Id.1: 123,
- MarketplaceIdList.Id.2: 345,
- MarketplaceIdList.Id.3: 4343
- }
- """
- params = {}
- if values is not None:
- if not param.endswith('.'):
- param = "%s." % param
- for num, value in enumerate(values):
- params['%s%d' % (param, (num + 1))] = value
- return params
-
-
-class Feeds(MWS):
- """ Amazon MWS Feeds API """
-
- ACCOUNT_TYPE = "Merchant"
-
- def submit_feed(self, feed, feed_type, marketplaceids=None,
- content_type="text/xml", purge='false'):
- """
- Uploads a feed ( xml or .tsv ) to the seller's inventory.
- Can be used for creating/updating products on Amazon.
- """
- data = dict(Action='SubmitFeed',
- FeedType=feed_type,
- PurgeAndReplace=purge)
- data.update(self.enumerate_param('MarketplaceIdList.Id.', marketplaceids))
- md = calc_md5(feed)
- return self.make_request(data, method="POST", body=feed,
- extra_headers={'Content-MD5': md, 'Content-Type': content_type})
-
- def get_feed_submission_list(self, feedids=None, max_count=None, feedtypes=None,
- processingstatuses=None, fromdate=None, todate=None):
- """
- Returns a list of all feed submissions submitted in the previous 90 days.
- That match the query parameters.
- """
-
- data = dict(Action='GetFeedSubmissionList',
- MaxCount=max_count,
- SubmittedFromDate=fromdate,
- SubmittedToDate=todate,)
- data.update(self.enumerate_param('FeedSubmissionIdList.Id', feedids))
- data.update(self.enumerate_param('FeedTypeList.Type.', feedtypes))
- data.update(self.enumerate_param('FeedProcessingStatusList.Status.', processingstatuses))
- return self.make_request(data)
-
- def get_submission_list_by_next_token(self, token):
- data = dict(Action='GetFeedSubmissionListByNextToken', NextToken=token)
- return self.make_request(data)
-
- def get_feed_submission_count(self, feedtypes=None, processingstatuses=None, fromdate=None, todate=None):
- data = dict(Action='GetFeedSubmissionCount',
- SubmittedFromDate=fromdate,
- SubmittedToDate=todate)
- data.update(self.enumerate_param('FeedTypeList.Type.', feedtypes))
- data.update(self.enumerate_param('FeedProcessingStatusList.Status.', processingstatuses))
- return self.make_request(data)
-
- def cancel_feed_submissions(self, feedids=None, feedtypes=None, fromdate=None, todate=None):
- data = dict(Action='CancelFeedSubmissions',
- SubmittedFromDate=fromdate,
- SubmittedToDate=todate)
- data.update(self.enumerate_param('FeedSubmissionIdList.Id.', feedids))
- data.update(self.enumerate_param('FeedTypeList.Type.', feedtypes))
- return self.make_request(data)
-
- def get_feed_submission_result(self, feedid):
- data = dict(Action='GetFeedSubmissionResult', FeedSubmissionId=feedid)
- return self.make_request(data)
-
-class Reports(MWS):
- """ Amazon MWS Reports API """
-
- ACCOUNT_TYPE = "Merchant"
-
- ## REPORTS ###
-
- def get_report(self, report_id):
- data = dict(Action='GetReport', ReportId=report_id)
- return self.make_request(data)
-
- def get_report_count(self, report_types=(), acknowledged=None, fromdate=None, todate=None):
- data = dict(Action='GetReportCount',
- Acknowledged=acknowledged,
- AvailableFromDate=fromdate,
- AvailableToDate=todate)
- data.update(self.enumerate_param('ReportTypeList.Type.', report_types))
- return self.make_request(data)
-
- def get_report_list(self, requestids=(), max_count=None, types=(), acknowledged=None,
- fromdate=None, todate=None):
- data = dict(Action='GetReportList',
- Acknowledged=acknowledged,
- AvailableFromDate=fromdate,
- AvailableToDate=todate,
- MaxCount=max_count)
- data.update(self.enumerate_param('ReportRequestIdList.Id.', requestids))
- data.update(self.enumerate_param('ReportTypeList.Type.', types))
- return self.make_request(data)
-
- def get_report_list_by_next_token(self, token):
- data = dict(Action='GetReportListByNextToken', NextToken=token)
- return self.make_request(data)
-
- def get_report_request_count(self, report_types=(), processingstatuses=(), fromdate=None, todate=None):
- data = dict(Action='GetReportRequestCount',
- RequestedFromDate=fromdate,
- RequestedToDate=todate)
- data.update(self.enumerate_param('ReportTypeList.Type.', report_types))
- data.update(self.enumerate_param('ReportProcessingStatusList.Status.', processingstatuses))
- return self.make_request(data)
-
- def get_report_request_list(self, requestids=(), types=(), processingstatuses=(),
- max_count=None, fromdate=None, todate=None):
- data = dict(Action='GetReportRequestList',
- MaxCount=max_count,
- RequestedFromDate=fromdate,
- RequestedToDate=todate)
- data.update(self.enumerate_param('ReportRequestIdList.Id.', requestids))
- data.update(self.enumerate_param('ReportTypeList.Type.', types))
- data.update(self.enumerate_param('ReportProcessingStatusList.Status.', processingstatuses))
- return self.make_request(data)
-
- def get_report_request_list_by_next_token(self, token):
- data = dict(Action='GetReportRequestListByNextToken', NextToken=token)
- return self.make_request(data)
-
- def request_report(self, report_type, start_date=None, end_date=None, marketplaceids=()):
- data = dict(Action='RequestReport',
- ReportType=report_type,
- StartDate=start_date,
- EndDate=end_date)
- data.update(self.enumerate_param('MarketplaceIdList.Id.', marketplaceids))
- return self.make_request(data)
-
- ### ReportSchedule ###
-
- def get_report_schedule_list(self, types=()):
- data = dict(Action='GetReportScheduleList')
- data.update(self.enumerate_param('ReportTypeList.Type.', types))
- return self.make_request(data)
-
- def get_report_schedule_count(self, types=()):
- data = dict(Action='GetReportScheduleCount')
- data.update(self.enumerate_param('ReportTypeList.Type.', types))
- return self.make_request(data)
-
-
-class Orders(MWS):
- """ Amazon Orders API """
-
- URI = "/Orders/2013-09-01"
- VERSION = "2013-09-01"
- NS = '{https://mws.amazonservices.com/Orders/2011-01-01}'
-
- def list_orders(self, marketplaceids, created_after=None, created_before=None, lastupdatedafter=None,
- lastupdatedbefore=None, orderstatus=(), fulfillment_channels=(),
- payment_methods=(), buyer_email=None, seller_orderid=None, max_results='100'):
-
- data = dict(Action='ListOrders',
- CreatedAfter=created_after,
- CreatedBefore=created_before,
- LastUpdatedAfter=lastupdatedafter,
- LastUpdatedBefore=lastupdatedbefore,
- BuyerEmail=buyer_email,
- SellerOrderId=seller_orderid,
- MaxResultsPerPage=max_results,
- )
- data.update(self.enumerate_param('OrderStatus.Status.', orderstatus))
- data.update(self.enumerate_param('MarketplaceId.Id.', marketplaceids))
- data.update(self.enumerate_param('FulfillmentChannel.Channel.', fulfillment_channels))
- data.update(self.enumerate_param('PaymentMethod.Method.', payment_methods))
- return self.make_request(data)
-
- def list_orders_by_next_token(self, token):
- data = dict(Action='ListOrdersByNextToken', NextToken=token)
- return self.make_request(data)
-
- def get_order(self, amazon_order_ids):
- data = dict(Action='GetOrder')
- data.update(self.enumerate_param('AmazonOrderId.Id.', amazon_order_ids))
- return self.make_request(data)
-
- def list_order_items(self, amazon_order_id):
- data = dict(Action='ListOrderItems', AmazonOrderId=amazon_order_id)
- return self.make_request(data)
-
- def list_order_items_by_next_token(self, token):
- data = dict(Action='ListOrderItemsByNextToken', NextToken=token)
- return self.make_request(data)
-
-
-class Products(MWS):
- """ Amazon MWS Products API """
-
- URI = '/Products/2011-10-01'
- VERSION = '2011-10-01'
- NS = '{http://mws.amazonservices.com/schema/Products/2011-10-01}'
-
- def list_matching_products(self, marketplaceid, query, contextid=None):
- """ Returns a list of products and their attributes, ordered by
- relevancy, based on a search query that you specify.
- Your search query can be a phrase that describes the product
- or it can be a product identifier such as a UPC, EAN, ISBN, or JAN.
- """
- data = dict(Action='ListMatchingProducts',
- MarketplaceId=marketplaceid,
- Query=query,
- QueryContextId=contextid)
- return self.make_request(data)
-
- def get_matching_product(self, marketplaceid, asins):
- """ Returns a list of products and their attributes, based on a list of
- ASIN values that you specify.
- """
- data = dict(Action='GetMatchingProduct', MarketplaceId=marketplaceid)
- data.update(self.enumerate_param('ASINList.ASIN.', asins))
- return self.make_request(data)
-
- def get_matching_product_for_id(self, marketplaceid, type, id):
- """ Returns a list of products and their attributes, based on a list of
- product identifier values (asin, sellersku, upc, ean, isbn and JAN)
- Added in Fourth Release, API version 2011-10-01
- """
- data = dict(Action='GetMatchingProductForId',
- MarketplaceId=marketplaceid,
- IdType=type)
- data.update(self.enumerate_param('IdList.Id', id))
- return self.make_request(data)
-
- def get_competitive_pricing_for_sku(self, marketplaceid, skus):
- """ Returns the current competitive pricing of a product,
- based on the SellerSKU and MarketplaceId that you specify.
- """
- data = dict(Action='GetCompetitivePricingForSKU', MarketplaceId=marketplaceid)
- data.update(self.enumerate_param('SellerSKUList.SellerSKU.', skus))
- return self.make_request(data)
-
- def get_competitive_pricing_for_asin(self, marketplaceid, asins):
- """ Returns the current competitive pricing of a product,
- based on the ASIN and MarketplaceId that you specify.
- """
- data = dict(Action='GetCompetitivePricingForASIN', MarketplaceId=marketplaceid)
- data.update(self.enumerate_param('ASINList.ASIN.', asins))
- return self.make_request(data)
-
- def get_lowest_offer_listings_for_sku(self, marketplaceid, skus, condition="Any", excludeme="False"):
- data = dict(Action='GetLowestOfferListingsForSKU',
- MarketplaceId=marketplaceid,
- ItemCondition=condition,
- ExcludeMe=excludeme)
- data.update(self.enumerate_param('SellerSKUList.SellerSKU.', skus))
- return self.make_request(data)
-
- def get_lowest_offer_listings_for_asin(self, marketplaceid, asins, condition="Any", excludeme="False"):
- data = dict(Action='GetLowestOfferListingsForASIN',
- MarketplaceId=marketplaceid,
- ItemCondition=condition,
- ExcludeMe=excludeme)
- data.update(self.enumerate_param('ASINList.ASIN.', asins))
- return self.make_request(data)
-
- def get_product_categories_for_sku(self, marketplaceid, sku):
- data = dict(Action='GetProductCategoriesForSKU',
- MarketplaceId=marketplaceid,
- SellerSKU=sku)
- return self.make_request(data)
-
- def get_product_categories_for_asin(self, marketplaceid, asin):
- data = dict(Action='GetProductCategoriesForASIN',
- MarketplaceId=marketplaceid,
- ASIN=asin)
- return self.make_request(data)
-
- def get_my_price_for_sku(self, marketplaceid, skus, condition=None):
- data = dict(Action='GetMyPriceForSKU',
- MarketplaceId=marketplaceid,
- ItemCondition=condition)
- data.update(self.enumerate_param('SellerSKUList.SellerSKU.', skus))
- return self.make_request(data)
-
- def get_my_price_for_asin(self, marketplaceid, asins, condition=None):
- data = dict(Action='GetMyPriceForASIN',
- MarketplaceId=marketplaceid,
- ItemCondition=condition)
- data.update(self.enumerate_param('ASINList.ASIN.', asins))
- return self.make_request(data)
-
-
-class Sellers(MWS):
- """ Amazon MWS Sellers API """
-
- URI = '/Sellers/2011-07-01'
- VERSION = '2011-07-01'
- NS = '{http://mws.amazonservices.com/schema/Sellers/2011-07-01}'
-
- def list_marketplace_participations(self):
- """
- Returns a list of marketplaces a seller can participate in and
- a list of participations that include seller-specific information in that marketplace.
- The operation returns only those marketplaces where the seller's account is in an active state.
- """
-
- data = dict(Action='ListMarketplaceParticipations')
- return self.make_request(data)
-
- def list_marketplace_participations_by_next_token(self, token):
- """
- Takes a "NextToken" and returns the same information as "list_marketplace_participations".
- Based on the "NextToken".
- """
- data = dict(Action='ListMarketplaceParticipations', NextToken=token)
- return self.make_request(data)
-
-#### Fulfillment APIs ####
-
-class InboundShipments(MWS):
- URI = "/FulfillmentInboundShipment/2010-10-01"
- VERSION = '2010-10-01'
-
- # To be completed
-
-
-class Inventory(MWS):
- """ Amazon MWS Inventory Fulfillment API """
-
- URI = '/FulfillmentInventory/2010-10-01'
- VERSION = '2010-10-01'
- NS = "{http://mws.amazonaws.com/FulfillmentInventory/2010-10-01}"
-
- def list_inventory_supply(self, skus=(), datetime=None, response_group='Basic'):
- """ Returns information on available inventory """
-
- data = dict(Action='ListInventorySupply',
- QueryStartDateTime=datetime,
- ResponseGroup=response_group,
- )
- data.update(self.enumerate_param('SellerSkus.member.', skus))
- return self.make_request(data, "POST")
-
- def list_inventory_supply_by_next_token(self, token):
- data = dict(Action='ListInventorySupplyByNextToken', NextToken=token)
- return self.make_request(data, "POST")
-
-
-class OutboundShipments(MWS):
- URI = "/FulfillmentOutboundShipment/2010-10-01"
- VERSION = "2010-10-01"
- # To be completed
-
-
-class Recommendations(MWS):
-
- """ Amazon MWS Recommendations API """
-
- URI = '/Recommendations/2013-04-01'
- VERSION = '2013-04-01'
- NS = "{https://mws.amazonservices.com/Recommendations/2013-04-01}"
-
- def get_last_updated_time_for_recommendations(self, marketplaceid):
- """
- Checks whether there are active recommendations for each category for the given marketplace, and if there are,
- returns the time when recommendations were last updated for each category.
- """
-
- data = dict(Action='GetLastUpdatedTimeForRecommendations',
- MarketplaceId=marketplaceid)
- return self.make_request(data, "POST")
-
- def list_recommendations(self, marketplaceid, recommendationcategory=None):
- """
- Returns your active recommendations for a specific category or for all categories for a specific marketplace.
- """
-
- data = dict(Action="ListRecommendations",
- MarketplaceId=marketplaceid,
- RecommendationCategory=recommendationcategory)
- return self.make_request(data, "POST")
-
- def list_recommendations_by_next_token(self, token):
- """
- Returns the next page of recommendations using the NextToken parameter.
- """
-
- data = dict(Action="ListRecommendationsByNextToken",
- NextToken=token)
- return self.make_request(data, "POST")
-
-class Finances(MWS):
- """ Amazon Finances API"""
- URI = '/Finances/2015-05-01'
- VERSION = '2015-05-01'
- NS = "{https://mws.amazonservices.com/Finances/2015-05-01}"
-
- def list_financial_events(self , posted_after=None, posted_before=None,
- amazon_order_id=None, max_results='100'):
-
- data = dict(Action='ListFinancialEvents',
- PostedAfter=posted_after,
- PostedBefore=posted_before,
- AmazonOrderId=amazon_order_id,
- MaxResultsPerPage=max_results,
- )
- return self.make_request(data)
diff --git a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_settings.js b/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_settings.js
deleted file mode 100644
index f5ea804..0000000
--- a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_settings.js
+++ /dev/null
@@ -1,2 +0,0 @@
-// Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors
-// For license information, please see license.txt
diff --git a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_settings.json b/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_settings.json
deleted file mode 100644
index 5a678e7..0000000
--- a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_settings.json
+++ /dev/null
@@ -1,237 +0,0 @@
-{
- "actions": [],
- "creation": "2018-07-31 05:51:41.357047",
- "doctype": "DocType",
- "editable_grid": 1,
- "engine": "InnoDB",
- "field_order": [
- "enable_amazon",
- "mws_credentials",
- "seller_id",
- "aws_access_key_id",
- "mws_auth_token",
- "secret_key",
- "column_break_4",
- "market_place_id",
- "region",
- "domain",
- "section_break_13",
- "company",
- "warehouse",
- "item_group",
- "price_list",
- "column_break_17",
- "customer_group",
- "territory",
- "customer_type",
- "market_place_account_group",
- "section_break_12",
- "after_date",
- "taxes_charges",
- "sync_products",
- "sync_orders",
- "column_break_10",
- "enable_sync",
- "max_retry_limit"
- ],
- "fields": [
- {
- "default": "0",
- "fieldname": "enable_amazon",
- "fieldtype": "Check",
- "label": "Enable Amazon"
- },
- {
- "fieldname": "mws_credentials",
- "fieldtype": "Section Break",
- "label": "MWS Credentials"
- },
- {
- "fieldname": "seller_id",
- "fieldtype": "Data",
- "in_list_view": 1,
- "label": "Seller ID",
- "reqd": 1
- },
- {
- "fieldname": "aws_access_key_id",
- "fieldtype": "Data",
- "in_list_view": 1,
- "label": "AWS Access Key ID",
- "reqd": 1
- },
- {
- "fieldname": "mws_auth_token",
- "fieldtype": "Data",
- "in_list_view": 1,
- "label": "MWS Auth Token",
- "reqd": 1
- },
- {
- "fieldname": "secret_key",
- "fieldtype": "Data",
- "in_list_view": 1,
- "label": "Secret Key",
- "reqd": 1
- },
- {
- "fieldname": "column_break_4",
- "fieldtype": "Column Break"
- },
- {
- "fieldname": "market_place_id",
- "fieldtype": "Data",
- "label": "Market Place ID",
- "reqd": 1
- },
- {
- "fieldname": "region",
- "fieldtype": "Select",
- "label": "Region",
- "options": "\nAE\nAU\nBR\nCA\nCN\nDE\nES\nFR\nIN\nJP\nIT\nMX\nUK\nUS",
- "reqd": 1
- },
- {
- "fieldname": "domain",
- "fieldtype": "Data",
- "label": "Domain",
- "reqd": 1
- },
- {
- "fieldname": "section_break_13",
- "fieldtype": "Section Break"
- },
- {
- "fieldname": "company",
- "fieldtype": "Link",
- "label": "Company",
- "options": "Company",
- "reqd": 1
- },
- {
- "fieldname": "warehouse",
- "fieldtype": "Link",
- "label": "Warehouse",
- "options": "Warehouse",
- "reqd": 1
- },
- {
- "fieldname": "item_group",
- "fieldtype": "Link",
- "label": "Item Group",
- "options": "Item Group",
- "reqd": 1
- },
- {
- "fieldname": "price_list",
- "fieldtype": "Link",
- "label": "Price List",
- "options": "Price List",
- "reqd": 1
- },
- {
- "fieldname": "column_break_17",
- "fieldtype": "Column Break"
- },
- {
- "fieldname": "customer_group",
- "fieldtype": "Link",
- "label": "Customer Group",
- "options": "Customer Group",
- "reqd": 1
- },
- {
- "fieldname": "territory",
- "fieldtype": "Link",
- "label": "Territory",
- "options": "Territory",
- "reqd": 1
- },
- {
- "fieldname": "customer_type",
- "fieldtype": "Select",
- "label": "Customer Type",
- "options": "Individual\nCompany",
- "reqd": 1
- },
- {
- "fieldname": "market_place_account_group",
- "fieldtype": "Link",
- "label": "Market Place Account Group",
- "options": "Account",
- "reqd": 1
- },
- {
- "fieldname": "section_break_12",
- "fieldtype": "Section Break"
- },
- {
- "description": "Amazon will synch data updated after this date",
- "fieldname": "after_date",
- "fieldtype": "Datetime",
- "label": "After Date",
- "reqd": 1
- },
- {
- "default": "0",
- "description": "Get financial breakup of Taxes and charges data by Amazon ",
- "fieldname": "taxes_charges",
- "fieldtype": "Check",
- "label": "Sync Taxes and Charges"
- },
- {
- "fieldname": "column_break_10",
- "fieldtype": "Column Break"
- },
- {
- "default": "3",
- "fieldname": "max_retry_limit",
- "fieldtype": "Int",
- "label": "Max Retry Limit"
- },
- {
- "description": "Always sync your products from Amazon MWS before synching the Orders details",
- "fieldname": "sync_products",
- "fieldtype": "Button",
- "label": "Sync Products",
- "options": "get_products_details"
- },
- {
- "description": "Click this button to pull your Sales Order data from Amazon MWS.",
- "fieldname": "sync_orders",
- "fieldtype": "Button",
- "label": "Sync Orders",
- "options": "get_order_details"
- },
- {
- "default": "0",
- "description": "Check this to enable a scheduled Daily synchronization routine via scheduler",
- "fieldname": "enable_sync",
- "fieldtype": "Check",
- "label": "Enable Scheduled Sync"
- }
- ],
- "issingle": 1,
- "links": [],
- "modified": "2020-04-07 14:26:20.174848",
- "modified_by": "Administrator",
- "module": "ERPNext Integrations",
- "name": "Amazon MWS Settings",
- "owner": "Administrator",
- "permissions": [
- {
- "create": 1,
- "delete": 1,
- "email": 1,
- "print": 1,
- "read": 1,
- "role": "System Manager",
- "share": 1,
- "write": 1
- }
- ],
- "quick_entry": 1,
- "sort_field": "modified",
- "sort_order": "DESC",
- "track_changes": 1
-}
\ No newline at end of file
diff --git a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_settings.py b/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_settings.py
deleted file mode 100644
index c1f460f..0000000
--- a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_settings.py
+++ /dev/null
@@ -1,46 +0,0 @@
-# Copyright (c) 2018, Frappe Technologies and contributors
-# For license information, please see license.txt
-
-
-import dateutil
-import frappe
-from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
-from frappe.model.document import Document
-
-from erpnext.erpnext_integrations.doctype.amazon_mws_settings.amazon_methods import get_orders
-
-
-class AmazonMWSSettings(Document):
- def validate(self):
- if self.enable_amazon == 1:
- self.enable_sync = 1
- setup_custom_fields()
- else:
- self.enable_sync = 0
-
- @frappe.whitelist()
- def get_products_details(self):
- if self.enable_amazon == 1:
- frappe.enqueue('erpnext.erpnext_integrations.doctype.amazon_mws_settings.amazon_methods.get_products_details')
-
- @frappe.whitelist()
- def get_order_details(self):
- if self.enable_amazon == 1:
- after_date = dateutil.parser.parse(self.after_date).strftime("%Y-%m-%d")
- frappe.enqueue('erpnext.erpnext_integrations.doctype.amazon_mws_settings.amazon_methods.get_orders', after_date=after_date)
-
-def schedule_get_order_details():
- mws_settings = frappe.get_doc("Amazon MWS Settings")
- if mws_settings.enable_sync and mws_settings.enable_amazon:
- after_date = dateutil.parser.parse(mws_settings.after_date).strftime("%Y-%m-%d")
- get_orders(after_date = after_date)
-
-def setup_custom_fields():
- custom_fields = {
- "Item": [dict(fieldname='amazon_item_code', label='Amazon Item Code',
- fieldtype='Data', insert_after='series', read_only=1, print_hide=1)],
- "Sales Order": [dict(fieldname='amazon_order_id', label='Amazon Order ID',
- fieldtype='Data', insert_after='title', read_only=1, print_hide=1)]
- }
-
- create_custom_fields(custom_fields)
diff --git a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/test_amazon_mws_settings.py b/erpnext/erpnext_integrations/doctype/amazon_mws_settings/test_amazon_mws_settings.py
deleted file mode 100644
index 4be7960..0000000
--- a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/test_amazon_mws_settings.py
+++ /dev/null
@@ -1,8 +0,0 @@
-# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
-# See license.txt
-
-import unittest
-
-
-class TestAmazonMWSSettings(unittest.TestCase):
- pass
diff --git a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/xml_utils.py b/erpnext/erpnext_integrations/doctype/amazon_mws_settings/xml_utils.py
deleted file mode 100644
index d9dfc6f..0000000
--- a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/xml_utils.py
+++ /dev/null
@@ -1,104 +0,0 @@
-"""
-Created on Tue Jun 26 15:42:07 2012
-
-Borrowed from https://github.com/timotheus/ebaysdk-python
-
-@author: pierre
-"""
-
-import re
-import xml.etree.ElementTree as ET
-
-
-class object_dict(dict):
- """object view of dict, you can
- >>> a = object_dict()
- >>> a.fish = 'fish'
- >>> a['fish']
- 'fish'
- >>> a['water'] = 'water'
- >>> a.water
- 'water'
- >>> a.test = {'value': 1}
- >>> a.test2 = object_dict({'name': 'test2', 'value': 2})
- >>> a.test, a.test2.name, a.test2.value
- (1, 'test2', 2)
- """
- def __init__(self, initd=None):
- if initd is None:
- initd = {}
- dict.__init__(self, initd)
-
- def __getattr__(self, item):
-
- try:
- d = self.__getitem__(item)
- except KeyError:
- return None
-
- if isinstance(d, dict) and 'value' in d and len(d) == 1:
- return d['value']
- else:
- return d
-
- # if value is the only key in object, you can omit it
- def __setstate__(self, item):
- return False
-
- def __setattr__(self, item, value):
- self.__setitem__(item, value)
-
- def getvalue(self, item, value=None):
- return self.get(item, {}).get('value', value)
-
-
-class xml2dict(object):
-
- def __init__(self):
- pass
-
- def _parse_node(self, node):
- node_tree = object_dict()
- # Save attrs and text, hope there will not be a child with same name
- if node.text:
- node_tree.value = node.text
- for (k, v) in node.attrib.items():
- k, v = self._namespace_split(k, object_dict({'value':v}))
- node_tree[k] = v
- #Save childrens
- for child in node.getchildren():
- tag, tree = self._namespace_split(child.tag,
- self._parse_node(child))
- if tag not in node_tree: # the first time, so store it in dict
- node_tree[tag] = tree
- continue
- old = node_tree[tag]
- if not isinstance(old, list):
- node_tree.pop(tag)
- node_tree[tag] = [old] # multi times, so change old dict to a list
- node_tree[tag].append(tree) # add the new one
-
- return node_tree
-
- def _namespace_split(self, tag, value):
- """
- Split the tag '{http://cs.sfsu.edu/csc867/myscheduler}patients'
- ns = http://cs.sfsu.edu/csc867/myscheduler
- name = patients
- """
- result = re.compile(r"\{(.*)\}(.*)").search(tag)
- if result:
- value.namespace, tag = result.groups()
-
- return (tag, value)
-
- def parse(self, file):
- """parse a xml file to a dict"""
- f = open(file, 'r')
- return self.fromstring(f.read())
-
- def fromstring(self, s):
- """parse a string"""
- t = ET.fromstring(s)
- root_tag, root_tree = self._namespace_split(t.tag, self._parse_node(t))
- return object_dict({root_tag: root_tree})
diff --git a/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.py b/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.py
index a8119ac..f02f76e 100644
--- a/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.py
+++ b/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.py
@@ -13,7 +13,7 @@
class GoCardlessSettings(Document):
- supported_currencies = ["EUR", "DKK", "GBP", "SEK"]
+ supported_currencies = ["EUR", "DKK", "GBP", "SEK", "AUD", "NZD", "CAD", "USD"]
def validate(self):
self.initialize_client()
@@ -80,7 +80,7 @@
def validate_transaction_currency(self, currency):
if currency not in self.supported_currencies:
- frappe.throw(_("Please select another payment method. Stripe does not support transactions in currency '{0}'").format(currency))
+ frappe.throw(_("Please select another payment method. Go Cardless does not support transactions in currency '{0}'").format(currency))
def get_payment_url(self, **kwargs):
return get_url("./integrations/gocardless_checkout?{0}".format(urlencode(kwargs)))
diff --git a/erpnext/erpnext_integrations/workspace/erpnext_integrations/erpnext_integrations.json b/erpnext/erpnext_integrations/workspace/erpnext_integrations/erpnext_integrations.json
index 45077aa..1f2619b 100644
--- a/erpnext/erpnext_integrations/workspace/erpnext_integrations/erpnext_integrations.json
+++ b/erpnext/erpnext_integrations/workspace/erpnext_integrations/erpnext_integrations.json
@@ -30,17 +30,6 @@
"type": "Link"
},
{
- "dependencies": "",
- "hidden": 0,
- "is_query_report": 0,
- "label": "Amazon MWS Settings",
- "link_count": 0,
- "link_to": "Amazon MWS Settings",
- "link_type": "DocType",
- "onboard": 0,
- "type": "Link"
- },
- {
"hidden": 0,
"is_query_report": 0,
"label": "Payments",
diff --git a/erpnext/hooks.py b/erpnext/hooks.py
index d99f23e..38fa691 100644
--- a/erpnext/hooks.py
+++ b/erpnext/hooks.py
@@ -333,7 +333,6 @@
"hourly": [
'erpnext.hr.doctype.daily_work_summary_group.daily_work_summary_group.trigger_emails',
"erpnext.accounts.doctype.subscription.subscription.process_all",
- "erpnext.erpnext_integrations.doctype.amazon_mws_settings.amazon_mws_settings.schedule_get_order_details",
"erpnext.accounts.doctype.gl_entry.gl_entry.rename_gle_sle_docs",
"erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings.automatic_synchronization",
"erpnext.projects.doctype.project.project.hourly_reminder",
diff --git a/erpnext/hr/doctype/employee_group_table/employee_group_table.json b/erpnext/hr/doctype/employee_group_table/employee_group_table.json
index 4e0045c..54eb8c6 100644
--- a/erpnext/hr/doctype/employee_group_table/employee_group_table.json
+++ b/erpnext/hr/doctype/employee_group_table/employee_group_table.json
@@ -27,12 +27,13 @@
"fetch_from": "employee.user_id",
"fieldname": "user_id",
"fieldtype": "Data",
+ "in_list_view": 1,
"label": "ERPNext User ID",
"read_only": 1
}
],
"istable": 1,
- "modified": "2019-06-06 10:41:20.313756",
+ "modified": "2022-02-13 19:44:21.302938",
"modified_by": "Administrator",
"module": "HR",
"name": "Employee Group Table",
@@ -42,4 +43,4 @@
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
-}
\ No newline at end of file
+}
diff --git a/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py b/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py
index 41a9558..c11a821 100644
--- a/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py
+++ b/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py
@@ -8,11 +8,10 @@
import frappe
from frappe import _, bold
from frappe.model.document import Document
-from frappe.utils import date_diff, flt, formatdate, get_datetime, get_last_day, getdate
+from frappe.utils import date_diff, flt, formatdate, get_last_day, getdate
class LeavePolicyAssignment(Document):
-
def validate(self):
self.validate_policy_assignment_overlap()
self.set_dates()
@@ -94,10 +93,12 @@
new_leaves_allocated = 0
elif leave_type_details.get(leave_type).is_earned_leave == 1:
- if self.assignment_based_on == "Leave Period":
- new_leaves_allocated = self.get_leaves_for_passed_months(leave_type, new_leaves_allocated, leave_type_details, date_of_joining)
- else:
+ if not self.assignment_based_on:
new_leaves_allocated = 0
+ else:
+ # get leaves for past months if assignment is based on Leave Period / Joining Date
+ new_leaves_allocated = self.get_leaves_for_passed_months(leave_type, new_leaves_allocated, leave_type_details, date_of_joining)
+
# Calculate leaves at pro-rata basis for employees joining after the beginning of the given leave period
elif getdate(date_of_joining) > getdate(self.effective_from):
remaining_period = ((date_diff(self.effective_to, date_of_joining) + 1) / (date_diff(self.effective_to, self.effective_from) + 1))
@@ -108,25 +109,24 @@
def get_leaves_for_passed_months(self, leave_type, new_leaves_allocated, leave_type_details, date_of_joining):
from erpnext.hr.utils import get_monthly_earned_leave
- current_month = get_datetime(frappe.flags.current_date).month or get_datetime().month
- current_year = get_datetime(frappe.flags.current_date).year or get_datetime().year
+ current_date = frappe.flags.current_date or getdate()
+ if current_date > getdate(self.effective_to):
+ current_date = getdate(self.effective_to)
- from_date = frappe.db.get_value("Leave Period", self.leave_period, "from_date")
- if getdate(date_of_joining) > getdate(from_date):
- from_date = date_of_joining
-
- from_date_month = get_datetime(from_date).month
- from_date_year = get_datetime(from_date).year
+ from_date = getdate(self.effective_from)
+ if getdate(date_of_joining) > from_date:
+ from_date = getdate(date_of_joining)
months_passed = 0
+ based_on_doj = leave_type_details.get(leave_type).based_on_date_of_joining
- if current_year == from_date_year and current_month > from_date_month:
- months_passed = current_month - from_date_month
- months_passed = add_current_month_if_applicable(months_passed)
+ if current_date.year == from_date.year and current_date.month >= from_date.month:
+ months_passed = current_date.month - from_date.month
+ months_passed = add_current_month_if_applicable(months_passed, date_of_joining, based_on_doj)
- elif current_year > from_date_year:
- months_passed = (12 - from_date_month) + current_month
- months_passed = add_current_month_if_applicable(months_passed)
+ elif current_date.year > from_date.year:
+ months_passed = (12 - from_date.month) + current_date.month
+ months_passed = add_current_month_if_applicable(months_passed, date_of_joining, based_on_doj)
if months_passed > 0:
monthly_earned_leave = get_monthly_earned_leave(new_leaves_allocated,
@@ -138,13 +138,19 @@
return new_leaves_allocated
-def add_current_month_if_applicable(months_passed):
+def add_current_month_if_applicable(months_passed, date_of_joining, based_on_doj):
date = getdate(frappe.flags.current_date) or getdate()
- last_day_of_month = get_last_day(date)
- # if its the last day of the month, then that month should also be considered
- if last_day_of_month == date:
- months_passed += 1
+ if based_on_doj:
+ # if leave type allocation is based on DOJ, and the date of assignment creation is same as DOJ,
+ # then the month should be considered
+ if date.day == date_of_joining.day:
+ months_passed += 1
+ else:
+ last_day_of_month = get_last_day(date)
+ # if its the last day of the month, then that month should be considered
+ if last_day_of_month == date:
+ months_passed += 1
return months_passed
@@ -183,7 +189,7 @@
def get_leave_type_details():
leave_type_details = frappe._dict()
leave_types = frappe.get_all("Leave Type",
- fields=["name", "is_lwp", "is_earned_leave", "is_compensatory",
+ fields=["name", "is_lwp", "is_earned_leave", "is_compensatory", "based_on_date_of_joining",
"is_carry_forward", "expire_carry_forwarded_leaves_after_days", "earned_leave_frequency", "rounding"])
for d in leave_types:
leave_type_details.setdefault(d.name, d)
diff --git a/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py b/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py
index 8c76ca1..a19ddce 100644
--- a/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py
+++ b/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py
@@ -20,36 +20,31 @@
class TestLeavePolicyAssignment(unittest.TestCase):
def setUp(self):
for doctype in ["Leave Period", "Leave Application", "Leave Allocation", "Leave Policy Assignment", "Leave Ledger Entry"]:
- frappe.db.sql("delete from `tab{0}`".format(doctype)) #nosec
+ frappe.db.delete(doctype)
+
+ employee = get_employee()
+ self.original_doj = employee.date_of_joining
+ self.employee = employee
def test_grant_leaves(self):
leave_period = get_leave_period()
- employee = get_employee()
-
- # create the leave policy with leave type "_Test Leave Type", allocation = 10
+ # allocation = 10
leave_policy = create_leave_policy()
leave_policy.submit()
-
data = {
"assignment_based_on": "Leave Period",
"leave_policy": leave_policy.name,
"leave_period": leave_period.name
}
-
- leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data))
-
- leave_policy_assignment_doc = frappe.get_doc("Leave Policy Assignment", leave_policy_assignments[0])
- leave_policy_assignment_doc.reload()
-
- self.assertEqual(leave_policy_assignment_doc.leaves_allocated, 1)
+ leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
+ self.assertEqual(frappe.db.get_value("Leave Policy Assignment", leave_policy_assignments[0], "leaves_allocated"), 1)
leave_allocation = frappe.get_list("Leave Allocation", filters={
- "employee": employee.name,
+ "employee": self.employee.name,
"leave_policy":leave_policy.name,
"leave_policy_assignment": leave_policy_assignments[0],
"docstatus": 1})[0]
-
leave_alloc_doc = frappe.get_doc("Leave Allocation", leave_allocation)
self.assertEqual(leave_alloc_doc.new_leaves_allocated, 10)
@@ -61,63 +56,46 @@
def test_allow_to_grant_all_leave_after_cancellation_of_every_leave_allocation(self):
leave_period = get_leave_period()
- employee = get_employee()
-
# create the leave policy with leave type "_Test Leave Type", allocation = 10
leave_policy = create_leave_policy()
leave_policy.submit()
-
data = {
"assignment_based_on": "Leave Period",
"leave_policy": leave_policy.name,
"leave_period": leave_period.name
}
-
- leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data))
-
- leave_policy_assignment_doc = frappe.get_doc("Leave Policy Assignment", leave_policy_assignments[0])
- leave_policy_assignment_doc.reload()
-
+ leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
# every leave is allocated no more leave can be granted now
- self.assertEqual(leave_policy_assignment_doc.leaves_allocated, 1)
-
+ self.assertEqual(frappe.db.get_value("Leave Policy Assignment", leave_policy_assignments[0], "leaves_allocated"), 1)
leave_allocation = frappe.get_list("Leave Allocation", filters={
- "employee": employee.name,
+ "employee": self.employee.name,
"leave_policy":leave_policy.name,
"leave_policy_assignment": leave_policy_assignments[0],
"docstatus": 1})[0]
leave_alloc_doc = frappe.get_doc("Leave Allocation", leave_allocation)
-
- # User all allowed to grant leave when there is no allocation against assignment
leave_alloc_doc.cancel()
leave_alloc_doc.delete()
-
- leave_policy_assignment_doc.reload()
-
-
- # User are now allowed to grant leave
- self.assertEqual(leave_policy_assignment_doc.leaves_allocated, 0)
+ self.assertEqual(frappe.db.get_value("Leave Policy Assignment", leave_policy_assignments[0], "leaves_allocated"), 0)
def test_earned_leave_allocation(self):
leave_period = create_leave_period("Test Earned Leave Period")
- employee = get_employee()
leave_type = create_earned_leave_type("Test Earned Leave")
leave_policy = frappe.get_doc({
"doctype": "Leave Policy",
"title": "Test Leave Policy",
"leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 6}]
- }).insert()
+ }).submit()
data = {
"assignment_based_on": "Leave Period",
"leave_policy": leave_policy.name,
"leave_period": leave_period.name
}
- leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data))
+ leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
# leaves allocated should be 0 since it is an earned leave and allocation happens via scheduler based on set frequency
leaves_allocated = frappe.db.get_value("Leave Allocation", {
@@ -125,16 +103,8 @@
}, "total_leaves_allocated")
self.assertEqual(leaves_allocated, 0)
- def test_earned_leave_allocation_for_passed_months(self):
- employee = get_employee()
- leave_type = create_earned_leave_type("Test Earned Leave")
- leave_period = create_leave_period("Test Earned Leave Period",
- start_date=get_first_day(add_months(getdate(), -1)))
- leave_policy = frappe.get_doc({
- "doctype": "Leave Policy",
- "title": "Test Leave Policy",
- "leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 12}]
- }).insert()
+ def test_earned_leave_alloc_for_passed_months_based_on_leave_period(self):
+ leave_period, leave_policy = setup_leave_period_and_policy(get_first_day(add_months(getdate(), -1)))
# Case 1: assignment created one month after the leave period, should allocate 1 leave
frappe.flags.current_date = get_first_day(getdate())
@@ -143,24 +113,15 @@
"leave_policy": leave_policy.name,
"leave_period": leave_period.name
}
- leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data))
+ leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
leaves_allocated = frappe.db.get_value("Leave Allocation", {
"leave_policy_assignment": leave_policy_assignments[0]
}, "total_leaves_allocated")
self.assertEqual(leaves_allocated, 1)
- def test_earned_leave_allocation_for_passed_months_on_month_end(self):
- employee = get_employee()
- leave_type = create_earned_leave_type("Test Earned Leave")
- leave_period = create_leave_period("Test Earned Leave Period",
- start_date=get_first_day(add_months(getdate(), -2)))
- leave_policy = frappe.get_doc({
- "doctype": "Leave Policy",
- "title": "Test Leave Policy",
- "leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 12}]
- }).insert()
-
+ def test_earned_leave_alloc_for_passed_months_on_month_end_based_on_leave_period(self):
+ leave_period, leave_policy = setup_leave_period_and_policy(get_first_day(add_months(getdate(), -2)))
# Case 2: assignment created on the last day of the leave period's latter month
# should allocate 1 leave for current month even though the month has not ended
# since the daily job might have already executed
@@ -171,7 +132,7 @@
"leave_policy": leave_policy.name,
"leave_period": leave_period.name
}
- leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data))
+ leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
leaves_allocated = frappe.db.get_value("Leave Allocation", {
"leave_policy_assignment": leave_policy_assignments[0]
@@ -188,33 +149,17 @@
}, "total_leaves_allocated")
self.assertEqual(leaves_allocated, 3)
- def test_earned_leave_allocation_for_passed_months_with_carry_forwarded_leaves(self):
+ def test_earned_leave_alloc_for_passed_months_with_cf_leaves_based_on_leave_period(self):
from erpnext.hr.doctype.leave_allocation.test_leave_allocation import create_leave_allocation
- employee = get_employee()
- leave_type = create_earned_leave_type("Test Earned Leave")
- leave_period = create_leave_period("Test Earned Leave Period",
- start_date=get_first_day(add_months(getdate(), -2)))
- leave_policy = frappe.get_doc({
- "doctype": "Leave Policy",
- "title": "Test Leave Policy",
- "leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 12}]
- }).insert()
-
+ leave_period, leave_policy = setup_leave_period_and_policy(get_first_day(add_months(getdate(), -2)))
# initial leave allocation = 5
- leave_allocation = create_leave_allocation(
- employee=employee.name,
- employee_name=employee.employee_name,
- leave_type=leave_type.name,
- from_date=add_months(getdate(), -12),
- to_date=add_months(getdate(), -3),
- new_leaves_allocated=5,
- carry_forward=0)
+ leave_allocation = create_leave_allocation(employee=self.employee.name, employee_name=self.employee.employee_name, leave_type="Test Earned Leave",
+ from_date=add_months(getdate(), -12), to_date=add_months(getdate(), -3), new_leaves_allocated=5, carry_forward=0)
leave_allocation.submit()
# Case 3: assignment created on the last day of the leave period's latter month with carry forwarding
frappe.flags.current_date = get_last_day(add_months(getdate(), -1))
-
data = {
"assignment_based_on": "Leave Period",
"leave_policy": leave_policy.name,
@@ -222,7 +167,7 @@
"carry_forward": 1
}
# carry forwarded leaves = 5, 3 leaves allocated for passed months
- leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data))
+ leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
details = frappe.db.get_value("Leave Allocation", {
"leave_policy_assignment": leave_policy_assignments[0]
@@ -236,15 +181,122 @@
from erpnext.hr.utils import is_earned_leave_already_allocated
frappe.flags.current_date = get_last_day(getdate())
- allocation = frappe.get_doc('Leave Allocation', details.name)
+ allocation = frappe.get_doc("Leave Allocation", details.name)
# 1 leave is still pending to be allocated, irrespective of carry forwarded leaves
self.assertFalse(is_earned_leave_already_allocated(allocation, leave_policy.leave_policy_details[0].annual_allocation))
+ def test_earned_leave_alloc_for_passed_months_based_on_joining_date(self):
+ # tests leave alloc for earned leaves for assignment based on joining date in policy assignment
+ leave_type = create_earned_leave_type("Test Earned Leave")
+ leave_policy = frappe.get_doc({
+ "doctype": "Leave Policy",
+ "title": "Test Leave Policy",
+ "leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 12}]
+ }).submit()
+
+ # joining date set to 2 months back
+ self.employee.date_of_joining = get_first_day(add_months(getdate(), -2))
+ self.employee.save()
+
+ # assignment created on the last day of the current month
+ frappe.flags.current_date = get_last_day(getdate())
+ data = {
+ "assignment_based_on": "Joining Date",
+ "leave_policy": leave_policy.name
+ }
+ leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
+ leaves_allocated = frappe.db.get_value("Leave Allocation", {"leave_policy_assignment": leave_policy_assignments[0]},
+ "total_leaves_allocated")
+ effective_from = frappe.db.get_value("Leave Policy Assignment", leave_policy_assignments[0], "effective_from")
+ self.assertEqual(effective_from, self.employee.date_of_joining)
+ self.assertEqual(leaves_allocated, 3)
+
+ # to ensure leave is not already allocated to avoid duplication
+ from erpnext.hr.utils import allocate_earned_leaves
+ frappe.flags.current_date = get_last_day(getdate())
+ allocate_earned_leaves()
+
+ leaves_allocated = frappe.db.get_value("Leave Allocation", {"leave_policy_assignment": leave_policy_assignments[0]},
+ "total_leaves_allocated")
+ self.assertEqual(leaves_allocated, 3)
+
+ def test_grant_leaves_on_doj_for_earned_leaves_based_on_leave_period(self):
+ # tests leave alloc based on leave period for earned leaves with "based on doj" configuration in leave type
+ leave_period, leave_policy = setup_leave_period_and_policy(get_first_day(add_months(getdate(), -2)), based_on_doj=True)
+
+ # joining date set to 2 months back
+ self.employee.date_of_joining = get_first_day(add_months(getdate(), -2))
+ self.employee.save()
+
+ # assignment created on the same day of the current month, should allocate leaves including the current month
+ frappe.flags.current_date = get_first_day(getdate())
+
+ data = {
+ "assignment_based_on": "Leave Period",
+ "leave_policy": leave_policy.name,
+ "leave_period": leave_period.name
+ }
+ leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
+
+ leaves_allocated = frappe.db.get_value("Leave Allocation", {
+ "leave_policy_assignment": leave_policy_assignments[0]
+ }, "total_leaves_allocated")
+ self.assertEqual(leaves_allocated, 3)
+
+ # if the daily job is not completed yet, there is another check present
+ # to ensure leave is not already allocated to avoid duplication
+ from erpnext.hr.utils import allocate_earned_leaves
+ frappe.flags.current_date = get_first_day(getdate())
+ allocate_earned_leaves()
+
+ leaves_allocated = frappe.db.get_value("Leave Allocation", {
+ "leave_policy_assignment": leave_policy_assignments[0]
+ }, "total_leaves_allocated")
+ self.assertEqual(leaves_allocated, 3)
+
+ def test_grant_leaves_on_doj_for_earned_leaves_based_on_joining_date(self):
+ # tests leave alloc based on joining date for earned leaves with "based on doj" configuration in leave type
+ leave_type = create_earned_leave_type("Test Earned Leave", based_on_doj=True)
+ leave_policy = frappe.get_doc({
+ "doctype": "Leave Policy",
+ "title": "Test Leave Policy",
+ "leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 12}]
+ }).submit()
+
+ # joining date set to 2 months back
+ # leave should be allocated for current month too since this day is same as the joining day
+ self.employee.date_of_joining = get_first_day(add_months(getdate(), -2))
+ self.employee.save()
+
+ # assignment created on the first day of the current month
+ frappe.flags.current_date = get_first_day(getdate())
+ data = {
+ "assignment_based_on": "Joining Date",
+ "leave_policy": leave_policy.name
+ }
+ leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
+ leaves_allocated = frappe.db.get_value("Leave Allocation", {"leave_policy_assignment": leave_policy_assignments[0]},
+ "total_leaves_allocated")
+ effective_from = frappe.db.get_value("Leave Policy Assignment", leave_policy_assignments[0], "effective_from")
+ self.assertEqual(effective_from, self.employee.date_of_joining)
+ self.assertEqual(leaves_allocated, 3)
+
+ # to ensure leave is not already allocated to avoid duplication
+ from erpnext.hr.utils import allocate_earned_leaves
+ frappe.flags.current_date = get_first_day(getdate())
+ allocate_earned_leaves()
+
+ leaves_allocated = frappe.db.get_value("Leave Allocation", {"leave_policy_assignment": leave_policy_assignments[0]},
+ "total_leaves_allocated")
+ self.assertEqual(leaves_allocated, 3)
+
def tearDown(self):
frappe.db.rollback()
+ frappe.db.set_value("Employee", self.employee.name, "date_of_joining", self.original_doj)
+ frappe.flags.current_date = None
-def create_earned_leave_type(leave_type):
+def create_earned_leave_type(leave_type, based_on_doj=False):
frappe.delete_doc_if_exists("Leave Type", leave_type, force=1)
return frappe.get_doc(dict(
@@ -253,7 +305,8 @@
is_earned_leave=1,
earned_leave_frequency="Monthly",
rounding=0.5,
- is_carry_forward=1
+ is_carry_forward=1,
+ based_on_date_of_joining=based_on_doj
)).insert()
@@ -269,4 +322,17 @@
to_date=add_months(start_date, 12),
company="_Test Company",
is_active=1
- )).insert()
\ No newline at end of file
+ )).insert()
+
+
+def setup_leave_period_and_policy(start_date, based_on_doj=False):
+ leave_type = create_earned_leave_type("Test Earned Leave", based_on_doj)
+ leave_period = create_leave_period("Test Earned Leave Period",
+ start_date=start_date)
+ leave_policy = frappe.get_doc({
+ "doctype": "Leave Policy",
+ "title": "Test Leave Policy",
+ "leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 12}]
+ }).insert()
+
+ return leave_period, leave_policy
\ No newline at end of file
diff --git a/erpnext/hr/utils.py b/erpnext/hr/utils.py
index 7fd3a98..c174047 100644
--- a/erpnext/hr/utils.py
+++ b/erpnext/hr/utils.py
@@ -261,10 +261,10 @@
from_date=allocation.from_date
- if e_leave_type.based_on_date_of_joining_date:
+ if e_leave_type.based_on_date_of_joining:
from_date = frappe.db.get_value("Employee", allocation.employee, "date_of_joining")
- if check_effective_date(from_date, today, e_leave_type.earned_leave_frequency, e_leave_type.based_on_date_of_joining_date):
+ if check_effective_date(from_date, today, e_leave_type.earned_leave_frequency, e_leave_type.based_on_date_of_joining):
update_previous_leave_allocation(allocation, annual_allocation, e_leave_type, ignore_duplicates)
def update_previous_leave_allocation(allocation, annual_allocation, e_leave_type, ignore_duplicates=False):
@@ -343,7 +343,7 @@
allocation.unused_leaves = 0
allocation.create_leave_ledger_entry()
-def check_effective_date(from_date, to_date, frequency, based_on_date_of_joining_date):
+def check_effective_date(from_date, to_date, frequency, based_on_date_of_joining):
import calendar
from dateutil import relativedelta
@@ -354,7 +354,7 @@
#last day of month
last_day = calendar.monthrange(to_date.year, to_date.month)[1]
- if (from_date.day == to_date.day and based_on_date_of_joining_date) or (not based_on_date_of_joining_date and to_date.day == last_day):
+ if (from_date.day == to_date.day and based_on_date_of_joining) or (not based_on_date_of_joining and to_date.day == last_day):
if frequency == "Monthly":
return True
elif frequency == "Quarterly" and rd.months % 3:
diff --git a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py
index acf3a65..f3ed611 100644
--- a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py
+++ b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py
@@ -345,7 +345,7 @@
gle_map.append(
self.get_gl_dict({
"account": loan_details.penalty_income_account,
- "against": payment_account,
+ "against": loan_details.loan_account,
"credit": self.total_penalty_paid,
"credit_in_account_currency": self.total_penalty_paid,
"against_voucher_type": "Loan",
@@ -367,7 +367,9 @@
"against_voucher": self.against_loan,
"remarks": remarks,
"cost_center": self.cost_center,
- "posting_date": getdate(self.posting_date)
+ "posting_date": getdate(self.posting_date),
+ "party_type": loan_details.applicant_type if self.repay_from_salary else '',
+ "party": loan_details.applicant if self.repay_from_salary else ''
})
)
diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py
index 676481a..b1c86bc 100644
--- a/erpnext/manufacturing/doctype/production_plan/production_plan.py
+++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py
@@ -28,9 +28,24 @@
class ProductionPlan(Document):
def validate(self):
+ self.set_pending_qty_in_row_without_reference()
self.calculate_total_planned_qty()
self.set_status()
+ def set_pending_qty_in_row_without_reference(self):
+ "Set Pending Qty in independent rows (not from SO or MR)."
+ if self.docstatus > 0: # set only to initialise value before submit
+ return
+
+ for item in self.po_items:
+ if not item.get("sales_order") or not item.get("material_request"):
+ item.pending_qty = item.planned_qty
+
+ def calculate_total_planned_qty(self):
+ self.total_planned_qty = 0
+ for d in self.po_items:
+ self.total_planned_qty += flt(d.planned_qty)
+
def validate_data(self):
for d in self.get('po_items'):
if not d.bom_no:
@@ -263,11 +278,6 @@
'qty': so_detail['qty']
})
- def calculate_total_planned_qty(self):
- self.total_planned_qty = 0
- for d in self.po_items:
- self.total_planned_qty += flt(d.planned_qty)
-
def calculate_total_produced_qty(self):
self.total_produced_qty = 0
for d in self.po_items:
@@ -275,10 +285,11 @@
self.db_set("total_produced_qty", self.total_produced_qty, update_modified=False)
- def update_produced_qty(self, produced_qty, production_plan_item):
+ def update_produced_pending_qty(self, produced_qty, production_plan_item):
for data in self.po_items:
if data.name == production_plan_item:
data.produced_qty = produced_qty
+ data.pending_qty = flt(data.planned_qty - produced_qty)
data.db_update()
self.calculate_total_produced_qty()
@@ -341,6 +352,7 @@
def get_production_items(self):
item_dict = {}
+
for d in self.po_items:
item_details = {
"production_item" : d.item_code,
@@ -357,12 +369,12 @@
"production_plan" : self.name,
"production_plan_item" : d.name,
"product_bundle_item" : d.product_bundle_item,
- "planned_start_date" : d.planned_start_date
+ "planned_start_date" : d.planned_start_date,
+ "project" : self.project
}
- item_details.update({
- "project": self.project or frappe.db.get_value("Sales Order", d.sales_order, "project")
- })
+ if not item_details['project'] and d.sales_order:
+ item_details['project'] = frappe.get_cached_value("Sales Order", d.sales_order, "project")
if self.get_items_from == "Material Request":
item_details.update({
@@ -380,39 +392,59 @@
@frappe.whitelist()
def make_work_order(self):
+ from erpnext.manufacturing.doctype.work_order.work_order import get_default_warehouse
+
wo_list, po_list = [], []
subcontracted_po = {}
+ default_warehouses = get_default_warehouse()
- self.validate_data()
- self.make_work_order_for_finished_goods(wo_list)
- self.make_work_order_for_subassembly_items(wo_list, subcontracted_po)
+ self.make_work_order_for_finished_goods(wo_list, default_warehouses)
+ self.make_work_order_for_subassembly_items(wo_list, subcontracted_po, default_warehouses)
self.make_subcontracted_purchase_order(subcontracted_po, po_list)
self.show_list_created_message('Work Order', wo_list)
self.show_list_created_message('Purchase Order', po_list)
- def make_work_order_for_finished_goods(self, wo_list):
+ def make_work_order_for_finished_goods(self, wo_list, default_warehouses):
items_data = self.get_production_items()
for key, item in items_data.items():
if self.sub_assembly_items:
item['use_multi_level_bom'] = 0
+ set_default_warehouses(item, default_warehouses)
work_order = self.create_work_order(item)
if work_order:
wo_list.append(work_order)
- def make_work_order_for_subassembly_items(self, wo_list, subcontracted_po):
+ def make_work_order_for_subassembly_items(self, wo_list, subcontracted_po, default_warehouses):
for row in self.sub_assembly_items:
if row.type_of_manufacturing == 'Subcontract':
subcontracted_po.setdefault(row.supplier, []).append(row)
continue
- args = {}
- self.prepare_args_for_sub_assembly_items(row, args)
- work_order = self.create_work_order(args)
+ work_order_data = {
+ 'wip_warehouse': default_warehouses.get('wip_warehouse'),
+ 'fg_warehouse': default_warehouses.get('fg_warehouse')
+ }
+
+ self.prepare_data_for_sub_assembly_items(row, work_order_data)
+ work_order = self.create_work_order(work_order_data)
if work_order:
wo_list.append(work_order)
+ def prepare_data_for_sub_assembly_items(self, row, wo_data):
+ for field in ["production_item", "item_name", "qty", "fg_warehouse",
+ "description", "bom_no", "stock_uom", "bom_level",
+ "production_plan_item", "schedule_date"]:
+ if row.get(field):
+ wo_data[field] = row.get(field)
+
+ wo_data.update({
+ "use_multi_level_bom": 0,
+ "production_plan": self.name,
+ "production_plan_sub_assembly_item": row.name
+ })
+
def make_subcontracted_purchase_order(self, subcontracted_po, purchase_orders):
if not subcontracted_po:
return
@@ -423,7 +455,7 @@
po.schedule_date = getdate(po_list[0].schedule_date) if po_list[0].schedule_date else nowdate()
po.is_subcontracted = 'Yes'
for row in po_list:
- args = {
+ po_data = {
'item_code': row.production_item,
'warehouse': row.fg_warehouse,
'production_plan_sub_assembly_item': row.name,
@@ -433,9 +465,9 @@
for field in ['schedule_date', 'qty', 'uom', 'stock_uom', 'item_name',
'description', 'production_plan_item']:
- args[field] = row.get(field)
+ po_data[field] = row.get(field)
- po.append('items', args)
+ po.append('items', po_data)
po.set_missing_values()
po.flags.ignore_mandatory = True
@@ -452,24 +484,9 @@
doc_list = [get_link_to_form(doctype, p) for p in doc_list]
msgprint(_("{0} created").format(comma_and(doc_list)))
- def prepare_args_for_sub_assembly_items(self, row, args):
- for field in ["production_item", "item_name", "qty", "fg_warehouse",
- "description", "bom_no", "stock_uom", "bom_level",
- "production_plan_item", "schedule_date"]:
- args[field] = row.get(field)
-
- args.update({
- "use_multi_level_bom": 0,
- "production_plan": self.name,
- "production_plan_sub_assembly_item": row.name
- })
-
def create_work_order(self, item):
- from erpnext.manufacturing.doctype.work_order.work_order import (
- OverProductionError,
- get_default_warehouse,
- )
- warehouse = get_default_warehouse()
+ from erpnext.manufacturing.doctype.work_order.work_order import OverProductionError
+
wo = frappe.new_doc("Work Order")
wo.update(item)
wo.planned_start_date = item.get('planned_start_date') or item.get('schedule_date')
@@ -478,11 +495,11 @@
wo.fg_warehouse = item.get("warehouse")
wo.set_work_order_operations()
+ wo.set_required_items()
- if not wo.fg_warehouse:
- wo.fg_warehouse = warehouse.get('fg_warehouse')
try:
wo.flags.ignore_mandatory = True
+ wo.flags.ignore_validate = True
wo.insert()
return wo.name
except OverProductionError:
@@ -1025,3 +1042,8 @@
if d.value:
get_sub_assembly_items(d.value, bom_data, stock_qty, indent=indent+1)
+
+def set_default_warehouses(row, default_warehouses):
+ for field in ['wip_warehouse', 'fg_warehouse']:
+ if not row.get(field):
+ row[field] = default_warehouses.get(field)
\ No newline at end of file
diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py
index 276e708..afa1501 100644
--- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py
+++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py
@@ -11,6 +11,7 @@
)
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.stock.doctype.item.test_item import create_item
+from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
create_stock_reconciliation,
)
@@ -36,15 +37,21 @@
if not frappe.db.get_value('BOM', {'item': item}):
make_bom(item = item, raw_materials = raw_materials)
- def test_production_plan(self):
+ def test_production_plan_mr_creation(self):
+ "Test if MRs are created for unavailable raw materials."
pln = create_production_plan(item_code='Test Production Item 1')
self.assertTrue(len(pln.mr_items), 2)
- pln.make_material_request()
- pln = frappe.get_doc('Production Plan', pln.name)
+ pln.make_material_request()
+ pln.reload()
self.assertTrue(pln.status, 'Material Requested')
- material_requests = frappe.get_all('Material Request Item', fields = ['distinct parent'],
- filters = {'production_plan': pln.name}, as_list=1)
+
+ material_requests = frappe.get_all(
+ 'Material Request Item',
+ fields = ['distinct parent'],
+ filters = {'production_plan': pln.name},
+ as_list=1
+ )
self.assertTrue(len(material_requests), 2)
@@ -66,27 +73,42 @@
pln.cancel()
def test_production_plan_start_date(self):
+ "Test if Work Order has same Planned Start Date as Prod Plan."
planned_date = add_to_date(date=None, days=3)
- plan = create_production_plan(item_code='Test Production Item 1', planned_start_date=planned_date)
+ plan = create_production_plan(
+ item_code='Test Production Item 1',
+ planned_start_date=planned_date
+ )
plan.make_work_order()
- work_orders = frappe.get_all('Work Order', fields = ['name', 'planned_start_date'],
- filters = {'production_plan': plan.name})
+ work_orders = frappe.get_all(
+ 'Work Order',
+ fields = ['name', 'planned_start_date'],
+ filters = {'production_plan': plan.name}
+ )
self.assertEqual(work_orders[0].planned_start_date, planned_date)
for wo in work_orders:
frappe.delete_doc('Work Order', wo.name)
- frappe.get_doc('Production Plan', plan.name).cancel()
+ plan.reload()
+ plan.cancel()
def test_production_plan_for_existing_ordered_qty(self):
+ """
+ - Enable 'ignore_existing_ordered_qty'.
+ - Test if MR Planning table pulls Raw Material Qty even if it is in stock.
+ """
sr1 = create_stock_reconciliation(item_code="Raw Material Item 1",
target="_Test Warehouse - _TC", qty=1, rate=110)
sr2 = create_stock_reconciliation(item_code="Raw Material Item 2",
target="_Test Warehouse - _TC", qty=1, rate=120)
- pln = create_production_plan(item_code='Test Production Item 1', ignore_existing_ordered_qty=0)
+ pln = create_production_plan(
+ item_code='Test Production Item 1',
+ ignore_existing_ordered_qty=1
+ )
self.assertTrue(len(pln.mr_items), 1)
self.assertTrue(flt(pln.mr_items[0].quantity), 1.0)
@@ -95,23 +117,39 @@
pln.cancel()
def test_production_plan_with_non_stock_item(self):
- pln = create_production_plan(item_code='Test Production Item 1', include_non_stock_items=0)
+ "Test if MR Planning table includes Non Stock RM."
+ pln = create_production_plan(
+ item_code='Test Production Item 1',
+ include_non_stock_items=1
+ )
self.assertTrue(len(pln.mr_items), 3)
pln.cancel()
def test_production_plan_without_multi_level(self):
- pln = create_production_plan(item_code='Test Production Item 1', use_multi_level_bom=0)
+ "Test MR Planning table for non exploded BOM."
+ pln = create_production_plan(
+ item_code='Test Production Item 1',
+ use_multi_level_bom=0
+ )
self.assertTrue(len(pln.mr_items), 2)
pln.cancel()
def test_production_plan_without_multi_level_for_existing_ordered_qty(self):
+ """
+ - Disable 'ignore_existing_ordered_qty'.
+ - Test if MR Planning table avoids pulling Raw Material Qty as it is in stock for
+ non exploded BOM.
+ """
sr1 = create_stock_reconciliation(item_code="Raw Material Item 1",
target="_Test Warehouse - _TC", qty=1, rate=130)
sr2 = create_stock_reconciliation(item_code="Subassembly Item 1",
target="_Test Warehouse - _TC", qty=1, rate=140)
- pln = create_production_plan(item_code='Test Production Item 1',
- use_multi_level_bom=0, ignore_existing_ordered_qty=0)
+ pln = create_production_plan(
+ item_code='Test Production Item 1',
+ use_multi_level_bom=0,
+ ignore_existing_ordered_qty=0
+ )
self.assertTrue(len(pln.mr_items), 0)
sr1.cancel()
@@ -119,6 +157,7 @@
pln.cancel()
def test_production_plan_sales_orders(self):
+ "Test if previously fulfilled SO (with WO) is pulled into Prod Plan."
item = 'Test Production Item 1'
so = make_sales_order(item_code=item, qty=1)
sales_order = so.name
@@ -166,24 +205,25 @@
self.assertEqual(sales_orders, [])
def test_production_plan_combine_items(self):
+ "Test combining FG items in Production Plan."
item = 'Test Production Item 1'
- so = make_sales_order(item_code=item, qty=1)
+ so1 = make_sales_order(item_code=item, qty=1)
pln = frappe.new_doc('Production Plan')
- pln.company = so.company
+ pln.company = so1.company
pln.get_items_from = 'Sales Order'
pln.append('sales_orders', {
- 'sales_order': so.name,
- 'sales_order_date': so.transaction_date,
- 'customer': so.customer,
- 'grand_total': so.grand_total
+ 'sales_order': so1.name,
+ 'sales_order_date': so1.transaction_date,
+ 'customer': so1.customer,
+ 'grand_total': so1.grand_total
})
- so = make_sales_order(item_code=item, qty=2)
+ so2 = make_sales_order(item_code=item, qty=2)
pln.append('sales_orders', {
- 'sales_order': so.name,
- 'sales_order_date': so.transaction_date,
- 'customer': so.customer,
- 'grand_total': so.grand_total
+ 'sales_order': so2.name,
+ 'sales_order_date': so2.transaction_date,
+ 'customer': so2.customer,
+ 'grand_total': so2.grand_total
})
pln.combine_items = 1
pln.get_items()
@@ -214,28 +254,37 @@
so_wo_qty = frappe.db.get_value('Sales Order Item', so_item, 'work_order_qty')
self.assertEqual(so_wo_qty, 0.0)
- latest_plan = frappe.get_doc('Production Plan', pln.name)
- latest_plan.cancel()
+ pln.reload()
+ pln.cancel()
def test_pp_to_mr_customer_provided(self):
- #Material Request from Production Plan for Customer Provided
+ " Test Material Request from Production Plan for Customer Provided Item."
create_item('CUST-0987', is_customer_provided_item = 1, customer = '_Test Customer', is_purchase_item = 0)
create_item('Production Item CUST')
+
for item, raw_materials in {'Production Item CUST': ['Raw Material Item 1', 'CUST-0987']}.items():
if not frappe.db.get_value('BOM', {'item': item}):
make_bom(item = item, raw_materials = raw_materials)
production_plan = create_production_plan(item_code = 'Production Item CUST')
production_plan.make_material_request()
- material_request = frappe.db.get_value('Material Request Item', {'production_plan': production_plan.name, 'item_code': 'CUST-0987'}, 'parent')
+
+ material_request = frappe.db.get_value(
+ 'Material Request Item',
+ {'production_plan': production_plan.name, 'item_code': 'CUST-0987'},
+ 'parent'
+ )
mr = frappe.get_doc('Material Request', material_request)
+
self.assertTrue(mr.material_request_type, 'Customer Provided')
self.assertTrue(mr.customer, '_Test Customer')
def test_production_plan_with_multi_level_bom(self):
- #|Item Code | Qty |
- #|Test BOM 1 | 1 |
- #| Test BOM 2 | 2 |
- #| Test BOM 3 | 3 |
+ """
+ Item Code | Qty |
+ |Test BOM 1 | 1 |
+ |Test BOM 2 | 2 |
+ |Test BOM 3 | 3 |
+ """
for item_code in ["Test BOM 1", "Test BOM 2", "Test BOM 3", "Test RM BOM 1"]:
create_item(item_code, is_stock_item=1)
@@ -264,15 +313,18 @@
pln.make_work_order()
#last level sub-assembly work order produce qty
- to_produce_qty = frappe.db.get_value("Work Order",
- {"production_plan": pln.name, "production_item": "Test BOM 3"}, "qty")
+ to_produce_qty = frappe.db.get_value(
+ "Work Order",
+ {"production_plan": pln.name, "production_item": "Test BOM 3"},
+ "qty"
+ )
self.assertEqual(to_produce_qty, 18.0)
pln.cancel()
frappe.delete_doc("Production Plan", pln.name)
def test_get_warehouse_list_group(self):
- """Check if required warehouses are returned"""
+ "Check if required child warehouses are returned."
warehouse_json = '[{\"warehouse\":\"_Test Warehouse Group - _TC\"}]'
warehouses = set(get_warehouse_list(warehouse_json))
@@ -284,6 +336,7 @@
msg=f"Following warehouses were expected {', '.join(missing_warehouse)}")
def test_get_warehouse_list_single(self):
+ "Check if same warehouse is returned in absence of child warehouses."
warehouse_json = '[{\"warehouse\":\"_Test Scrap Warehouse - _TC\"}]'
warehouses = set(get_warehouse_list(warehouse_json))
@@ -292,6 +345,7 @@
self.assertEqual(warehouses, expected_warehouses)
def test_get_sales_order_with_variant(self):
+ "Check if Template BOM is fetched in absence of Variant BOM."
rm_item = create_item('PIV_RM', valuation_rate = 100)
if not frappe.db.exists('Item', {"item_code": 'PIV'}):
item = create_item('PIV', valuation_rate = 100)
@@ -348,7 +402,7 @@
frappe.db.rollback()
def test_subassmebly_sorting(self):
- """ Test subassembly sorting in case of multiple items with nested BOMs"""
+ "Test subassembly sorting in case of multiple items with nested BOMs."
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
prefix = "_TestLevel_"
@@ -386,6 +440,7 @@
self.assertIn("SuperSecret", plan.sub_assembly_items[0].production_item)
def test_multiple_work_order_for_production_plan_item(self):
+ "Test producing Prod Plan (making WO) in parts."
def create_work_order(item, pln, qty):
# Get Production Items
items_data = pln.get_production_items()
@@ -441,7 +496,107 @@
pln.reload()
self.assertEqual(pln.po_items[0].ordered_qty, 0)
+ def test_production_plan_pending_qty_with_sales_order(self):
+ """
+ Test Prod Plan impact via: SO -> Prod Plan -> WO -> SE -> SE (cancel)
+ """
+ from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
+ from erpnext.manufacturing.doctype.work_order.work_order import (
+ make_stock_entry as make_se_from_wo,
+ )
+
+ make_stock_entry(item_code="Raw Material Item 1",
+ target="Work In Progress - _TC",
+ qty=2, basic_rate=100
+ )
+ make_stock_entry(item_code="Raw Material Item 2",
+ target="Work In Progress - _TC",
+ qty=2, basic_rate=100
+ )
+
+ item = 'Test Production Item 1'
+ so = make_sales_order(item_code=item, qty=1)
+
+ pln = create_production_plan(
+ company=so.company,
+ get_items_from="Sales Order",
+ sales_order=so,
+ skip_getting_mr_items=True
+ )
+ self.assertEqual(pln.po_items[0].pending_qty, 1)
+
+ wo = make_wo_order_test_record(
+ item_code=item, qty=1,
+ company=so.company,
+ wip_warehouse='Work In Progress - _TC',
+ fg_warehouse='Finished Goods - _TC',
+ skip_transfer=1,
+ do_not_submit=True
+ )
+ wo.production_plan = pln.name
+ wo.production_plan_item = pln.po_items[0].name
+ wo.submit()
+
+ se = frappe.get_doc(make_se_from_wo(wo.name, "Manufacture", 1))
+ se.submit()
+
+ pln.reload()
+ self.assertEqual(pln.po_items[0].pending_qty, 0)
+
+ se.cancel()
+ pln.reload()
+ self.assertEqual(pln.po_items[0].pending_qty, 1)
+
+ def test_production_plan_pending_qty_independent_items(self):
+ "Test Prod Plan impact if items are added independently (no from SO or MR)."
+ from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
+ from erpnext.manufacturing.doctype.work_order.work_order import (
+ make_stock_entry as make_se_from_wo,
+ )
+
+ make_stock_entry(item_code="Raw Material Item 1",
+ target="Work In Progress - _TC",
+ qty=2, basic_rate=100
+ )
+ make_stock_entry(item_code="Raw Material Item 2",
+ target="Work In Progress - _TC",
+ qty=2, basic_rate=100
+ )
+
+ pln = create_production_plan(
+ item_code='Test Production Item 1',
+ skip_getting_mr_items=True
+ )
+ self.assertEqual(pln.po_items[0].pending_qty, 1)
+
+ wo = make_wo_order_test_record(
+ item_code='Test Production Item 1', qty=1,
+ company=pln.company,
+ wip_warehouse='Work In Progress - _TC',
+ fg_warehouse='Finished Goods - _TC',
+ skip_transfer=1,
+ do_not_submit=True
+ )
+ wo.production_plan = pln.name
+ wo.production_plan_item = pln.po_items[0].name
+ wo.submit()
+
+ se = frappe.get_doc(make_se_from_wo(wo.name, "Manufacture", 1))
+ se.submit()
+
+ pln.reload()
+ self.assertEqual(pln.po_items[0].pending_qty, 0)
+
+ se.cancel()
+ pln.reload()
+ self.assertEqual(pln.po_items[0].pending_qty, 1)
+
def create_production_plan(**args):
+ """
+ sales_order (obj): Sales Order Doc Object
+ get_items_from (str): Sales Order/Material Request
+ skip_getting_mr_items (bool): Whether or not to plan for new MRs
+ """
args = frappe._dict(args)
pln = frappe.get_doc({
@@ -449,20 +604,35 @@
'company': args.company or '_Test Company',
'customer': args.customer or '_Test Customer',
'posting_date': nowdate(),
- 'include_non_stock_items': args.include_non_stock_items or 1,
- 'include_subcontracted_items': args.include_subcontracted_items or 1,
- 'ignore_existing_ordered_qty': args.ignore_existing_ordered_qty or 1,
- 'po_items': [{
+ 'include_non_stock_items': args.include_non_stock_items or 0,
+ 'include_subcontracted_items': args.include_subcontracted_items or 0,
+ 'ignore_existing_ordered_qty': args.ignore_existing_ordered_qty or 0,
+ 'get_items_from': 'Sales Order'
+ })
+
+ if not args.get("sales_order"):
+ pln.append('po_items', {
'use_multi_level_bom': args.use_multi_level_bom or 1,
'item_code': args.item_code,
'bom_no': frappe.db.get_value('Item', args.item_code, 'default_bom'),
'planned_qty': args.planned_qty or 1,
'planned_start_date': args.planned_start_date or now_datetime()
- }]
- })
- mr_items = get_items_for_material_requests(pln.as_dict())
- for d in mr_items:
- pln.append('mr_items', d)
+ })
+
+ if args.get("get_items_from") == "Sales Order" and args.get("sales_order"):
+ so = args.get("sales_order")
+ pln.append('sales_orders', {
+ 'sales_order': so.name,
+ 'sales_order_date': so.transaction_date,
+ 'customer': so.customer,
+ 'grand_total': so.grand_total
+ })
+ pln.get_items()
+
+ if not args.get("skip_getting_mr_items"):
+ mr_items = get_items_for_material_requests(pln.as_dict())
+ for d in mr_items:
+ pln.append('mr_items', d)
if not args.do_not_save:
pln.insert()
diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py
index 7315249..ed6a029 100644
--- a/erpnext/manufacturing/doctype/work_order/work_order.py
+++ b/erpnext/manufacturing/doctype/work_order/work_order.py
@@ -76,7 +76,6 @@
self.set_required_items(reset_only_qty = len(self.get("required_items")))
-
def validate_sales_order(self):
if self.sales_order:
self.check_sales_order_on_hold_or_close()
@@ -273,7 +272,7 @@
produced_qty = total_qty[0][0] if total_qty else 0
- production_plan.run_method("update_produced_qty", produced_qty, self.production_plan_item)
+ production_plan.run_method("update_produced_pending_qty", produced_qty, self.production_plan_item)
def before_submit(self):
self.create_serial_no_batch_no()
@@ -546,7 +545,7 @@
if node.is_bom:
operations.extend(_get_operations(node.name, qty=node.exploded_qty))
- bom_qty = frappe.db.get_value("BOM", self.bom_no, "quantity")
+ bom_qty = frappe.get_cached_value("BOM", self.bom_no, "quantity")
operations.extend(_get_operations(self.bom_no, qty=1.0/bom_qty))
for correct_index, operation in enumerate(operations, start=1):
@@ -627,7 +626,7 @@
frappe.delete_doc("Job Card", d.name)
def validate_production_item(self):
- if frappe.db.get_value("Item", self.production_item, "has_variants"):
+ if frappe.get_cached_value("Item", self.production_item, "has_variants"):
frappe.throw(_("Work Order cannot be raised against a Item Template"), ItemHasVariantError)
if self.production_item:
diff --git a/erpnext/patches.txt b/erpnext/patches.txt
index d300340..9f6d0f5 100644
--- a/erpnext/patches.txt
+++ b/erpnext/patches.txt
@@ -351,3 +351,5 @@
erpnext.patches.v13_0.shopping_cart_to_ecommerce
erpnext.patches.v13_0.update_disbursement_account
erpnext.patches.v13_0.update_reserved_qty_closed_wo
+erpnext.patches.v14_0.delete_amazon_mws_doctype
+erpnext.patches.v13_0.set_work_order_qty_in_so_from_mr
diff --git a/erpnext/patches/v12_0/rename_mws_settings_fields.py b/erpnext/patches/v12_0/rename_mws_settings_fields.py
deleted file mode 100644
index d5bf38d..0000000
--- a/erpnext/patches/v12_0/rename_mws_settings_fields.py
+++ /dev/null
@@ -1,12 +0,0 @@
-# Copyright (c) 2020, Frappe and Contributors
-# License: GNU General Public License v3. See license.txt
-
-import frappe
-
-
-def execute():
- count = frappe.db.sql("SELECT COUNT(*) FROM `tabSingles` WHERE doctype='Amazon MWS Settings' AND field='enable_sync';")[0][0]
- if count == 0:
- frappe.db.sql("UPDATE `tabSingles` SET field='enable_sync' WHERE doctype='Amazon MWS Settings' AND field='enable_synch';")
-
- frappe.reload_doc("ERPNext Integrations", "doctype", "Amazon MWS Settings")
diff --git a/erpnext/patches/v13_0/set_work_order_qty_in_so_from_mr.py b/erpnext/patches/v13_0/set_work_order_qty_in_so_from_mr.py
new file mode 100644
index 0000000..f097ab9
--- /dev/null
+++ b/erpnext/patches/v13_0/set_work_order_qty_in_so_from_mr.py
@@ -0,0 +1,36 @@
+import frappe
+
+
+def execute():
+ """
+ 1. Get submitted Work Orders with MR, MR Item and SO set
+ 2. Get SO Item detail from MR Item detail in WO, and set in WO
+ 3. Update work_order_qty in SO
+ """
+ work_order = frappe.qb.DocType("Work Order")
+ query = (
+ frappe.qb.from_(work_order)
+ .select(
+ work_order.name, work_order.produced_qty,
+ work_order.material_request,
+ work_order.material_request_item,
+ work_order.sales_order
+ ).where(
+ (work_order.material_request.isnotnull())
+ & (work_order.material_request_item.isnotnull())
+ & (work_order.sales_order.isnotnull())
+ & (work_order.docstatus == 1)
+ & (work_order.produced_qty > 0)
+ )
+ )
+ results = query.run(as_dict=True)
+
+ for row in results:
+ so_item = frappe.get_value(
+ "Material Request Item", row.material_request_item, "sales_order_item"
+ )
+ frappe.db.set_value("Work Order", row.name, "sales_order_item", so_item)
+
+ if so_item:
+ wo = frappe.get_doc("Work Order", row.name)
+ wo.update_work_order_qty_in_so()
diff --git a/erpnext/patches/v14_0/delete_amazon_mws_doctype.py b/erpnext/patches/v14_0/delete_amazon_mws_doctype.py
new file mode 100644
index 0000000..525da6c
--- /dev/null
+++ b/erpnext/patches/v14_0/delete_amazon_mws_doctype.py
@@ -0,0 +1,5 @@
+import frappe
+
+
+def execute():
+ frappe.delete_doc("DocType", "Amazon MWS Settings", ignore_missing=True)
\ No newline at end of file
diff --git a/erpnext/payroll/doctype/gratuity/gratuity.js b/erpnext/payroll/doctype/gratuity/gratuity.js
index d4f7c9c..3d69c46 100644
--- a/erpnext/payroll/doctype/gratuity/gratuity.js
+++ b/erpnext/payroll/doctype/gratuity/gratuity.js
@@ -3,6 +3,14 @@
frappe.ui.form.on('Gratuity', {
setup: function (frm) {
+ frm.set_query("salary_component", function () {
+ return {
+ filters: {
+ type: "Earning"
+ }
+ };
+ });
+
frm.set_query("expense_account", function () {
return {
filters: {
@@ -24,7 +32,7 @@
});
},
refresh: function (frm) {
- if (frm.doc.docstatus == 1 && frm.doc.status == "Unpaid") {
+ if (frm.doc.docstatus == 1 && !frm.doc.pay_via_salary_slip && frm.doc.status == "Unpaid") {
frm.add_custom_button(__("Create Payment Entry"), function () {
return frappe.call({
method: 'erpnext.accounts.doctype.payment_entry.payment_entry.get_payment_entry',
diff --git a/erpnext/payroll/doctype/gratuity/gratuity.json b/erpnext/payroll/doctype/gratuity/gratuity.json
index 1970895..1fd1cec 100644
--- a/erpnext/payroll/doctype/gratuity/gratuity.json
+++ b/erpnext/payroll/doctype/gratuity/gratuity.json
@@ -1,7 +1,7 @@
{
"actions": [],
"autoname": "HR-GRA-PAY-.#####",
- "creation": "2020-08-05 20:52:13.024683",
+ "creation": "2022-01-27 16:24:28.200061",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
@@ -16,6 +16,9 @@
"company",
"gratuity_rule",
"section_break_5",
+ "pay_via_salary_slip",
+ "payroll_date",
+ "salary_component",
"payable_account",
"expense_account",
"mode_of_payment",
@@ -78,18 +81,20 @@
"reqd": 1
},
{
+ "depends_on": "eval: !doc.pay_via_salary_slip",
"fieldname": "expense_account",
"fieldtype": "Link",
"label": "Expense Account",
- "options": "Account",
- "reqd": 1
+ "mandatory_depends_on": "eval: !doc.pay_via_salary_slip",
+ "options": "Account"
},
{
+ "depends_on": "eval: !doc.pay_via_salary_slip",
"fieldname": "mode_of_payment",
"fieldtype": "Link",
"label": "Mode of Payment",
- "options": "Mode of Payment",
- "reqd": 1
+ "mandatory_depends_on": "eval: !doc.pay_via_salary_slip",
+ "options": "Mode of Payment"
},
{
"fieldname": "gratuity_rule",
@@ -151,23 +156,45 @@
"read_only": 1
},
{
+ "depends_on": "eval: !doc.pay_via_salary_slip",
"fieldname": "payable_account",
"fieldtype": "Link",
"label": "Payable Account",
- "options": "Account",
- "reqd": 1
+ "mandatory_depends_on": "eval: !doc.pay_via_salary_slip",
+ "options": "Account"
},
{
"fieldname": "cost_center",
"fieldtype": "Link",
"label": "Cost Center",
"options": "Cost Center"
+ },
+ {
+ "default": "1",
+ "fieldname": "pay_via_salary_slip",
+ "fieldtype": "Check",
+ "label": "Pay via Salary Slip"
+ },
+ {
+ "depends_on": "pay_via_salary_slip",
+ "fieldname": "payroll_date",
+ "fieldtype": "Date",
+ "label": "Payroll Date",
+ "mandatory_depends_on": "pay_via_salary_slip"
+ },
+ {
+ "depends_on": "pay_via_salary_slip",
+ "fieldname": "salary_component",
+ "fieldtype": "Link",
+ "label": "Salary Component",
+ "mandatory_depends_on": "pay_via_salary_slip",
+ "options": "Salary Component"
}
],
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2022-01-19 12:54:37.306145",
+ "modified": "2022-02-02 14:00:45.536152",
"modified_by": "Administrator",
"module": "Payroll",
"name": "Gratuity",
diff --git a/erpnext/payroll/doctype/gratuity/gratuity.py b/erpnext/payroll/doctype/gratuity/gratuity.py
index 476990a..939634a 100644
--- a/erpnext/payroll/doctype/gratuity/gratuity.py
+++ b/erpnext/payroll/doctype/gratuity/gratuity.py
@@ -21,7 +21,10 @@
self.status = "Unpaid"
def on_submit(self):
- self.create_gl_entries()
+ if self.pay_via_salary_slip:
+ self.create_additional_salary()
+ else:
+ self.create_gl_entries()
def on_cancel(self):
self.ignore_linked_doctypes = ['GL Entry']
@@ -64,6 +67,19 @@
return gl_entry
+ def create_additional_salary(self):
+ if self.pay_via_salary_slip:
+ additional_salary = frappe.new_doc('Additional Salary')
+ additional_salary.employee = self.employee
+ additional_salary.salary_component = self.salary_component
+ additional_salary.overwrite_salary_structure_amount = 0
+ additional_salary.amount = self.amount
+ additional_salary.payroll_date = self.payroll_date
+ additional_salary.company = self.company
+ additional_salary.ref_doctype = self.doctype
+ additional_salary.ref_docname = self.name
+ additional_salary.submit()
+
def set_total_advance_paid(self):
paid_amount = frappe.db.sql("""
select ifnull(sum(debit_in_account_currency), 0) as paid_amount
diff --git a/erpnext/payroll/doctype/gratuity/gratuity_dashboard.py b/erpnext/payroll/doctype/gratuity/gratuity_dashboard.py
index aeadba1..771a6fe 100644
--- a/erpnext/payroll/doctype/gratuity/gratuity_dashboard.py
+++ b/erpnext/payroll/doctype/gratuity/gratuity_dashboard.py
@@ -10,7 +10,7 @@
'transactions': [
{
'label': _('Payment'),
- 'items': ['Payment Entry']
+ 'items': ['Payment Entry', 'Additional Salary']
}
]
}
diff --git a/erpnext/payroll/doctype/gratuity/test_gratuity.py b/erpnext/payroll/doctype/gratuity/test_gratuity.py
index 93cba06..90e8061 100644
--- a/erpnext/payroll/doctype/gratuity/test_gratuity.py
+++ b/erpnext/payroll/doctype/gratuity/test_gratuity.py
@@ -18,27 +18,25 @@
test_dependencies = ["Salary Component", "Salary Slip", "Account"]
class TestGratuity(unittest.TestCase):
- @classmethod
- def setUpClass(cls):
+ def setUp(self):
+ frappe.db.delete("Gratuity")
+ frappe.db.delete("Additional Salary", {"ref_doctype": "Gratuity"})
+
make_earning_salary_component(setup=True, test_tax=True, company_list=['_Test Company'])
make_deduction_salary_component(setup=True, test_tax=True, company_list=['_Test Company'])
- def setUp(self):
- frappe.db.sql("DELETE FROM `tabGratuity`")
-
def test_get_last_salary_slip_should_return_none_for_new_employee(self):
new_employee = make_employee("new_employee@salary.com", company='_Test Company')
salary_slip = get_last_salary_slip(new_employee)
assert salary_slip is None
- def test_check_gratuity_amount_based_on_current_slab(self):
+ def test_check_gratuity_amount_based_on_current_slab_and_additional_salary_creation(self):
employee, sal_slip = create_employee_and_get_last_salary_slip()
rule = get_gratuity_rule("Rule Under Unlimited Contract on termination (UAE)")
+ gratuity = create_gratuity(pay_via_salary_slip=1, employee=employee, rule=rule.name)
- gratuity = create_gratuity(employee=employee, rule=rule.name)
-
- #work experience calculation
+ # work experience calculation
date_of_joining, relieving_date = frappe.db.get_value('Employee', employee, ['date_of_joining', 'relieving_date'])
employee_total_workings_days = (get_datetime(relieving_date) - get_datetime(date_of_joining)).days
@@ -64,6 +62,9 @@
self.assertEqual(flt(gratuity_amount, 2), flt(gratuity.amount, 2))
+ # additional salary creation (Pay via salary slip)
+ self.assertTrue(frappe.db.exists("Additional Salary", {"ref_docname": gratuity.name}))
+
def test_check_gratuity_amount_based_on_all_previous_slabs(self):
employee, sal_slip = create_employee_and_get_last_salary_slip()
rule = get_gratuity_rule("Rule Under Limited Contract (UAE)")
@@ -117,8 +118,8 @@
self.assertEqual(flt(gratuity.paid_amount,2), flt(gratuity.amount, 2))
def tearDown(self):
- frappe.db.sql("DELETE FROM `tabGratuity`")
- frappe.db.sql("DELETE FROM `tabAdditional Salary` WHERE ref_doctype = 'Gratuity'")
+ frappe.db.rollback()
+
def get_gratuity_rule(name):
rule = frappe.db.exists("Gratuity Rule", name)
@@ -141,9 +142,14 @@
gratuity.employee = args.employee
gratuity.posting_date = getdate()
gratuity.gratuity_rule = args.rule or "Rule Under Limited Contract (UAE)"
- gratuity.expense_account = args.expense_account or 'Payment Account - _TC'
- gratuity.payable_account = args.payable_account or get_payable_account("_Test Company")
- gratuity.mode_of_payment = args.mode_of_payment or 'Cash'
+ gratuity.pay_via_salary_slip = args.pay_via_salary_slip or 0
+ if gratuity.pay_via_salary_slip:
+ gratuity.payroll_date = getdate()
+ gratuity.salary_component = "Performance Bonus"
+ else:
+ gratuity.expense_account = args.expense_account or 'Payment Account - _TC'
+ gratuity.payable_account = args.payable_account or get_payable_account("_Test Company")
+ gratuity.mode_of_payment = args.mode_of_payment or 'Cash'
gratuity.save()
gratuity.submit()
diff --git a/erpnext/payroll/doctype/payroll_entry/payroll_entry.py b/erpnext/payroll/doctype/payroll_entry/payroll_entry.py
index db88c06..a634dfe 100644
--- a/erpnext/payroll/doctype/payroll_entry/payroll_entry.py
+++ b/erpnext/payroll/doctype/payroll_entry/payroll_entry.py
@@ -527,11 +527,12 @@
""" % cond, {"sal_struct": tuple(sal_struct), "from_date": end_date, "payroll_payable_account": payroll_payable_account}, as_dict=True)
def remove_payrolled_employees(emp_list, start_date, end_date):
+ new_emp_list = []
for employee_details in emp_list:
- if frappe.db.exists("Salary Slip", {"employee": employee_details.employee, "start_date": start_date, "end_date": end_date, "docstatus": 1}):
- emp_list.remove(employee_details)
+ if not frappe.db.exists("Salary Slip", {"employee": employee_details.employee, "start_date": start_date, "end_date": end_date, "docstatus": 1}):
+ new_emp_list.append(employee_details)
- return emp_list
+ return new_emp_list
@frappe.whitelist()
def get_start_end_dates(payroll_frequency, start_date=None, company=None):
diff --git a/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py b/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py
index 5f836db..3b7f4b2 100644
--- a/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py
+++ b/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py
@@ -124,7 +124,7 @@
if not frappe.db.exists("Account", "_Test Payroll Payable - _TC"):
create_account(account_name="_Test Payroll Payable",
- company="_Test Company", parent_account="Current Liabilities - _TC")
+ company="_Test Company", parent_account="Current Liabilities - _TC", account_type="Payable")
if not frappe.db.get_value("Company", "_Test Company", "default_payroll_payable_account") or \
frappe.db.get_value("Company", "_Test Company", "default_payroll_payable_account") != "_Test Payroll Payable - _TC":
diff --git a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py
index 30b604b..daa0f89 100644
--- a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py
+++ b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py
@@ -6,6 +6,7 @@
import unittest
import frappe
+from frappe.model.document import Document
from frappe.utils import (
add_days,
add_months,
@@ -687,20 +688,25 @@
def make_salary_component(salary_components, test_tax, company_list=None):
for salary_component in salary_components:
- if not frappe.db.exists('Salary Component', salary_component["salary_component"]):
- if test_tax:
- if salary_component["type"] == "Earning":
- salary_component["is_tax_applicable"] = 1
- elif salary_component["salary_component"] == "TDS":
- salary_component["variable_based_on_taxable_salary"] = 1
- salary_component["amount_based_on_formula"] = 0
- salary_component["amount"] = 0
- salary_component["formula"] = ""
- salary_component["condition"] = ""
- salary_component["doctype"] = "Salary Component"
- salary_component["salary_component_abbr"] = salary_component["abbr"]
- frappe.get_doc(salary_component).insert()
- get_salary_component_account(salary_component["salary_component"], company_list)
+ if frappe.db.exists('Salary Component', salary_component["salary_component"]):
+ continue
+
+ if test_tax:
+ if salary_component["type"] == "Earning":
+ salary_component["is_tax_applicable"] = 1
+ elif salary_component["salary_component"] == "TDS":
+ salary_component["variable_based_on_taxable_salary"] = 1
+ salary_component["amount_based_on_formula"] = 0
+ salary_component["amount"] = 0
+ salary_component["formula"] = ""
+ salary_component["condition"] = ""
+
+ salary_component["salary_component_abbr"] = salary_component["abbr"]
+ doc = frappe.new_doc("Salary Component")
+ doc.update(salary_component)
+ doc.insert()
+
+ get_salary_component_account(doc, company_list)
def get_salary_component_account(sal_comp, company_list=None):
company = erpnext.get_default_company()
@@ -708,7 +714,9 @@
if company_list and company not in company_list:
company_list.append(company)
- sal_comp = frappe.get_doc("Salary Component", sal_comp)
+ if not isinstance(sal_comp, Document):
+ sal_comp = frappe.get_doc("Salary Component", sal_comp)
+
if not sal_comp.get("accounts"):
for d in company_list:
company_abbr = frappe.get_cached_value('Company', d, 'abbr')
@@ -726,7 +734,7 @@
})
sal_comp.save()
-def create_account(account_name, company, parent_account):
+def create_account(account_name, company, parent_account, account_type=None):
company_abbr = frappe.get_cached_value('Company', company, 'abbr')
account = frappe.db.get_value("Account", account_name + " - " + company_abbr)
if not account:
diff --git a/erpnext/regional/india/utils.py b/erpnext/regional/india/utils.py
index 8715ef5..d443f9c 100644
--- a/erpnext/regional/india/utils.py
+++ b/erpnext/regional/india/utils.py
@@ -219,7 +219,6 @@
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/regional/report/datev/datev.js b/erpnext/regional/report/datev/datev.js
index 4124e3d..03c729e 100644
--- a/erpnext/regional/report/datev/datev.js
+++ b/erpnext/regional/report/datev/datev.js
@@ -40,7 +40,11 @@
});
query_report.page.add_menu_item(__("Download DATEV File"), () => {
- const filters = JSON.stringify(query_report.get_values());
+ const filters = encodeURIComponent(
+ JSON.stringify(
+ query_report.get_values()
+ )
+ );
window.open(`/api/method/erpnext.regional.report.datev.datev.download_datev_csv?filters=${filters}`);
});
diff --git a/erpnext/regional/report/gstr_1/gstr_1.py b/erpnext/regional/report/gstr_1/gstr_1.py
index e50ff18..ce2ffb4 100644
--- a/erpnext/regional/report/gstr_1/gstr_1.py
+++ b/erpnext/regional/report/gstr_1/gstr_1.py
@@ -28,7 +28,7 @@
posting_date,
base_grand_total,
base_rounded_total,
- COALESCE(NULLIF(customer_gstin,''), NULLIF(billing_address_gstin, '')) as customer_gstin,
+ NULLIF(billing_address_gstin, '') as billing_address_gstin,
place_of_supply,
ecommerce_gstin,
reverse_charge,
@@ -259,7 +259,7 @@
if self.filters.get("type_of_business") == "B2B":
- conditions += "AND IFNULL(gst_category, '') in ('Registered Regular', 'Deemed Export', 'SEZ') AND is_return != 1 AND is_debit_note !=1"
+ conditions += "AND IFNULL(gst_category, '') in ('Registered Regular', 'Registered Composition', 'Deemed Export', 'SEZ') AND is_return != 1 AND is_debit_note !=1"
if self.filters.get("type_of_business") in ("B2C Large", "B2C Small"):
b2c_limit = frappe.db.get_single_value('GST Settings', 'b2c_limit')
@@ -383,7 +383,7 @@
for invoice, items in self.invoice_items.items():
if invoice not in self.items_based_on_tax_rate and invoice not in unidentified_gst_accounts_invoice \
and self.invoices.get(invoice, {}).get('export_type') == "Without Payment of Tax" \
- and self.invoices.get(invoice, {}).get('gst_category') == "Overseas":
+ and self.invoices.get(invoice, {}).get('gst_category') in ("Overseas", "SEZ"):
self.items_based_on_tax_rate.setdefault(invoice, {}).setdefault(0, items.keys())
def get_columns(self):
@@ -409,7 +409,7 @@
if self.filters.get("type_of_business") == "B2B":
self.invoice_columns = [
{
- "fieldname": "customer_gstin",
+ "fieldname": "billing_address_gstin",
"label": "GSTIN/UIN of Recipient",
"fieldtype": "Data",
"width": 150
@@ -516,7 +516,7 @@
elif self.filters.get("type_of_business") == "CDNR-REG":
self.invoice_columns = [
{
- "fieldname": "customer_gstin",
+ "fieldname": "billing_address_gstin",
"label": "GSTIN/UIN of Recipient",
"fieldtype": "Data",
"width": 150
@@ -817,7 +817,7 @@
res = {}
if filters["type_of_business"] == "B2B":
for item in report_data[:-1]:
- res.setdefault(item["customer_gstin"], {}).setdefault(item["invoice_number"],[]).append(item)
+ res.setdefault(item["billing_address_gstin"], {}).setdefault(item["invoice_number"],[]).append(item)
out = get_b2b_json(res, gstin)
gst_json["b2b"] = out
@@ -841,7 +841,7 @@
gst_json["exp"] = out
elif filters["type_of_business"] == "CDNR-REG":
for item in report_data[:-1]:
- res.setdefault(item["customer_gstin"], {}).setdefault(item["invoice_number"],[]).append(item)
+ res.setdefault(item["billing_address_gstin"], {}).setdefault(item["invoice_number"],[]).append(item)
out = get_cdnr_reg_json(res, gstin)
gst_json["cdnr"] = out
@@ -875,7 +875,7 @@
}
def get_b2b_json(res, gstin):
- inv_type, out = {"Registered Regular": "R", "Deemed Export": "DE", "URD": "URD", "SEZ": "SEZ"}, []
+ out = []
for gst_in in res:
b2b_item, inv = {"ctin": gst_in, "inv": []}, []
if not gst_in: continue
@@ -889,7 +889,7 @@
inv_item = get_basic_invoice_detail(invoice[0])
inv_item["pos"] = "%02d" % int(invoice[0]["place_of_supply"].split('-')[0])
inv_item["rchrg"] = invoice[0]["reverse_charge"]
- inv_item["inv_typ"] = inv_type.get(invoice[0].get("gst_category", ""),"")
+ inv_item["inv_typ"] = get_invoice_type(invoice[0])
if inv_item["pos"]=="00": continue
inv_item["itms"] = []
@@ -1044,7 +1044,7 @@
"ntty": invoice[0]["document_type"],
"pos": "%02d" % int(invoice[0]["place_of_supply"].split('-')[0]),
"rchrg": invoice[0]["reverse_charge"],
- "inv_typ": get_invoice_type_for_cdnr(invoice[0])
+ "inv_typ": get_invoice_type(invoice[0])
}
inv_item["itms"] = []
@@ -1069,7 +1069,7 @@
"val": abs(flt(items[0]["invoice_value"])),
"ntty": items[0]["document_type"],
"pos": "%02d" % int(items[0]["place_of_supply"].split('-')[0]),
- "typ": get_invoice_type_for_cdnrur(items[0])
+ "typ": get_invoice_type(items[0])
}
inv_item["itms"] = []
@@ -1110,29 +1110,21 @@
return out
-def get_invoice_type_for_cdnr(row):
- if row.get('gst_category') == 'SEZ':
- if row.get('export_type') == 'WPAY':
- invoice_type = 'SEWP'
- else:
- invoice_type = 'SEWOP'
- elif row.get('gst_category') == 'Deemed Export':
- invoice_type = 'DE'
- elif row.get('gst_category') == 'Registered Regular':
- invoice_type = 'R'
+def get_invoice_type(row):
+ gst_category = row.get('gst_category')
- return invoice_type
+ if gst_category == 'SEZ':
+ return 'SEWP' if row.get('export_type') == 'WPAY' else 'SEWOP'
-def get_invoice_type_for_cdnrur(row):
- if row.get('gst_category') == 'Overseas':
- if row.get('export_type') == 'WPAY':
- invoice_type = 'EXPWP'
- else:
- invoice_type = 'EXPWOP'
- elif row.get('gst_category') == 'Unregistered':
- invoice_type = 'B2CL'
+ if gst_category == 'Overseas':
+ return 'EXPWP' if row.get('export_type') == 'WPAY' else 'EXPWOP'
- return invoice_type
+ return ({
+ 'Deemed Export': 'DE',
+ 'Registered Regular': 'R',
+ 'Registered Composition': 'R',
+ 'Unregistered': 'B2CL'
+ }).get(gst_category)
def get_basic_invoice_detail(row):
return {
@@ -1154,7 +1146,7 @@
# calculate tax amount added
tax = flt((row["taxable_value"]*rate)/100.0, 2)
frappe.errprint([tax, tax/2])
- if row.get("customer_gstin") and gstin[0:2] == row["customer_gstin"][0:2]:
+ if row.get("billing_address_gstin") and gstin[0:2] == row["billing_address_gstin"][0:2]:
itm_det.update({"camt": flt(tax/2.0, 2), "samt": flt(tax/2.0, 2)})
else:
itm_det.update({"iamt": tax})
@@ -1199,4 +1191,4 @@
if invoice_detail.place_of_supply.split("-")[0] != invoice_detail.company_gstin[:2]:
return True
else:
- return False
\ No newline at end of file
+ return False
diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py
index acf048e..73c5bd2 100644
--- a/erpnext/selling/doctype/sales_order/test_sales_order.py
+++ b/erpnext/selling/doctype/sales_order/test_sales_order.py
@@ -6,7 +6,7 @@
import frappe
import frappe.permissions
from frappe.core.doctype.user_permission.test_user_permission import create_user
-from frappe.utils import add_days, flt, getdate, nowdate
+from frappe.utils import add_days, flt, getdate, nowdate, today
from erpnext.controllers.accounts_controller import update_child_qty_rate
from erpnext.maintenance.doctype.maintenance_schedule.test_maintenance_schedule import (
@@ -1399,6 +1399,48 @@
so.load_from_db()
self.assertEqual(so.billing_status, 'Fully Billed')
+ def test_so_back_updated_from_wo_via_mr(self):
+ "SO -> MR (Manufacture) -> WO. Test if WO Qty is updated in SO."
+ from erpnext.manufacturing.doctype.work_order.work_order import (
+ make_stock_entry as make_se_from_wo,
+ )
+ from erpnext.stock.doctype.material_request.material_request import raise_work_orders
+
+ so = make_sales_order(item_list=[{"item_code": "_Test FG Item","qty": 2, "rate":100}])
+
+ mr = make_material_request(so.name)
+ mr.material_request_type = "Manufacture"
+ mr.schedule_date = today()
+ mr.submit()
+
+ # WO from MR
+ wo_name = raise_work_orders(mr.name)[0]
+ wo = frappe.get_doc("Work Order", wo_name)
+ wo.wip_warehouse = "Work In Progress - _TC"
+ wo.skip_transfer = True
+
+ self.assertEqual(wo.sales_order, so.name)
+ self.assertEqual(wo.sales_order_item, so.items[0].name)
+
+ wo.submit()
+ make_stock_entry(item_code="_Test Item", # Stock RM
+ target="Work In Progress - _TC",
+ qty=4, basic_rate=100
+ )
+ make_stock_entry(item_code="_Test Item Home Desktop 100", # Stock RM
+ target="Work In Progress - _TC",
+ qty=4, basic_rate=100
+ )
+
+ se = frappe.get_doc(make_se_from_wo(wo.name, "Manufacture", 2))
+ se.submit() # Finish WO
+
+ mr.reload()
+ wo.reload()
+ so.reload()
+ self.assertEqual(so.items[0].work_order_qty, wo.produced_qty)
+ self.assertEqual(mr.status, "Manufactured")
+
def automatically_fetch_payment_terms(enable=1):
accounts_settings = frappe.get_doc("Accounts Settings")
accounts_settings.automatically_fetch_payment_terms = enable
diff --git a/erpnext/accounts/print_format/gst_pos_invoice/__init__.py b/erpnext/selling/report/payment_terms_status_for_sales_order/__init__.py
similarity index 100%
rename from erpnext/accounts/print_format/gst_pos_invoice/__init__.py
rename to erpnext/selling/report/payment_terms_status_for_sales_order/__init__.py
diff --git a/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.js b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.js
new file mode 100644
index 0000000..0e36b3f
--- /dev/null
+++ b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.js
@@ -0,0 +1,84 @@
+// Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+/* eslint-disable */
+
+function get_filters() {
+ let filters = [
+ {
+ "fieldname":"company",
+ "label": __("Company"),
+ "fieldtype": "Link",
+ "options": "Company",
+ "default": frappe.defaults.get_user_default("Company"),
+ "reqd": 1
+ },
+ {
+ "fieldname":"period_start_date",
+ "label": __("Start Date"),
+ "fieldtype": "Date",
+ "reqd": 1,
+ "default": frappe.datetime.add_months(frappe.datetime.get_today(), -1)
+ },
+ {
+ "fieldname":"period_end_date",
+ "label": __("End Date"),
+ "fieldtype": "Date",
+ "reqd": 1,
+ "default": frappe.datetime.get_today()
+ },
+ {
+ "fieldname":"sales_order",
+ "label": __("Sales Order"),
+ "fieldtype": "MultiSelectList",
+ "width": 100,
+ "options": "Sales Order",
+ "get_data": function(txt) {
+ return frappe.db.get_link_options("Sales Order", txt, this.filters());
+ },
+ "filters": () => {
+ return {
+ docstatus: 1,
+ payment_terms_template: ['not in', ['']],
+ company: frappe.query_report.get_filter_value("company"),
+ transaction_date: ['between', [frappe.query_report.get_filter_value("period_start_date"), frappe.query_report.get_filter_value("period_end_date")]]
+ }
+ },
+ on_change: function(){
+ frappe.query_report.refresh();
+ }
+ }
+ ]
+
+ return filters;
+}
+
+frappe.query_reports["Payment Terms Status for Sales Order"] = {
+ "filters": get_filters(),
+ "formatter": function(value, row, column, data, default_formatter){
+ if(column.fieldname == 'invoices' && value) {
+ invoices = value.split(',');
+ const invoice_formatter = (prev_value, curr_value) => {
+ if(prev_value != "") {
+ return prev_value + ", " + default_formatter(curr_value, row, column, data);
+ }
+ else {
+ return default_formatter(curr_value, row, column, data);
+ }
+ }
+ return invoices.reduce(invoice_formatter, "")
+ }
+ else if (column.fieldname == 'paid_amount' && value){
+ formatted_value = default_formatter(value, row, column, data);
+ if(value > 0) {
+ formatted_value = "<span style='color:green;'>" + formatted_value + "</span>"
+ }
+ return formatted_value;
+ }
+ else if (column.fieldname == 'status' && value == 'Completed'){
+ return "<span style='color:green;'>" + default_formatter(value, row, column, data) + "</span>";
+ }
+
+ return default_formatter(value, row, column, data);
+ },
+
+};
diff --git a/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.json b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.json
new file mode 100644
index 0000000..850fa4d
--- /dev/null
+++ b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.json
@@ -0,0 +1,38 @@
+{
+ "add_total_row": 1,
+ "columns": [],
+ "creation": "2021-12-28 10:39:34.533964",
+ "disable_prepared_report": 0,
+ "disabled": 0,
+ "docstatus": 0,
+ "doctype": "Report",
+ "filters": [],
+ "idx": 0,
+ "is_standard": "Yes",
+ "modified": "2021-12-30 10:42:06.058457",
+ "modified_by": "Administrator",
+ "module": "Selling",
+ "name": "Payment Terms Status for Sales Order",
+ "owner": "Administrator",
+ "prepared_report": 0,
+ "ref_doctype": "Sales Order",
+ "report_name": "Payment Terms Status for Sales Order",
+ "report_type": "Script Report",
+ "roles": [
+ {
+ "role": "Sales User"
+ },
+ {
+ "role": "Sales Manager"
+ },
+ {
+ "role": "Maintenance User"
+ },
+ {
+ "role": "Accounts User"
+ },
+ {
+ "role": "Stock User"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py
new file mode 100644
index 0000000..e6a56ee
--- /dev/null
+++ b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py
@@ -0,0 +1,205 @@
+# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors
+# License: MIT. See LICENSE
+
+import frappe
+from frappe import _, qb, query_builder
+from frappe.query_builder import functions
+
+
+def get_columns():
+ columns = [
+ {
+ "label": _("Sales Order"),
+ "fieldname": "name",
+ "fieldtype": "Link",
+ "options": "Sales Order",
+ },
+ {
+ "label": _("Posting Date"),
+ "fieldname": "submitted",
+ "fieldtype": "Date",
+ },
+ {
+ "label": _("Payment Term"),
+ "fieldname": "payment_term",
+ "fieldtype": "Data",
+ },
+ {
+ "label": _("Description"),
+ "fieldname": "description",
+ "fieldtype": "Data",
+ },
+ {
+ "label": _("Due Date"),
+ "fieldname": "due_date",
+ "fieldtype": "Date",
+ },
+ {
+ "label": _("Invoice Portion"),
+ "fieldname": "invoice_portion",
+ "fieldtype": "Percent",
+ },
+ {
+ "label": _("Payment Amount"),
+ "fieldname": "base_payment_amount",
+ "fieldtype": "Currency",
+ "options": "currency",
+ },
+ {
+ "label": _("Paid Amount"),
+ "fieldname": "paid_amount",
+ "fieldtype": "Currency",
+ "options": "currency",
+ },
+ {
+ "label": _("Invoices"),
+ "fieldname": "invoices",
+ "fieldtype": "Link",
+ "options": "Sales Invoice",
+ },
+ {
+ "label": _("Status"),
+ "fieldname": "status",
+ "fieldtype": "Data",
+ },
+ {
+ "label": _("Currency"),
+ "fieldname": "currency",
+ "fieldtype": "Currency",
+ "hidden": 1
+ }
+ ]
+ return columns
+
+
+def get_conditions(filters):
+ """
+ Convert filter options to conditions used in query
+ """
+ filters = frappe._dict(filters) if filters else frappe._dict({})
+ conditions = frappe._dict({})
+
+ conditions.company = filters.company or frappe.defaults.get_user_default("company")
+ conditions.end_date = filters.period_end_date or frappe.utils.today()
+ conditions.start_date = filters.period_start_date or frappe.utils.add_months(
+ conditions.end_date, -1
+ )
+ conditions.sales_order = filters.sales_order or []
+
+ return conditions
+
+
+def get_so_with_invoices(filters):
+ """
+ Get Sales Order with payment terms template with their associated Invoices
+ """
+ sorders = []
+
+ so = qb.DocType("Sales Order")
+ ps = qb.DocType("Payment Schedule")
+ datediff = query_builder.CustomFunction("DATEDIFF", ["cur_date", "due_date"])
+ ifelse = query_builder.CustomFunction("IF", ["condition", "then", "else"])
+
+ conditions = get_conditions(filters)
+ query_so = (
+ qb.from_(so)
+ .join(ps)
+ .on(ps.parent == so.name)
+ .select(
+ so.name,
+ so.transaction_date.as_("submitted"),
+ ifelse(datediff(ps.due_date, functions.CurDate()) < 0, "Overdue", "Unpaid").as_("status"),
+ ps.payment_term,
+ ps.description,
+ ps.due_date,
+ ps.invoice_portion,
+ ps.base_payment_amount,
+ ps.paid_amount,
+ )
+ .where(
+ (so.docstatus == 1)
+ & (so.payment_terms_template != "NULL")
+ & (so.company == conditions.company)
+ & (so.transaction_date[conditions.start_date : conditions.end_date])
+ )
+ .orderby(so.name, so.transaction_date, ps.due_date)
+ )
+
+ if conditions.sales_order != []:
+ query_so = query_so.where(so.name.isin(conditions.sales_order))
+
+ sorders = query_so.run(as_dict=True)
+
+ invoices = []
+ if sorders != []:
+ soi = qb.DocType("Sales Order Item")
+ si = qb.DocType("Sales Invoice")
+ sii = qb.DocType("Sales Invoice Item")
+ query_inv = (
+ qb.from_(sii)
+ .right_join(si)
+ .on(si.name == sii.parent)
+ .inner_join(soi)
+ .on(soi.name == sii.so_detail)
+ .select(sii.sales_order, sii.parent.as_("invoice"), si.base_grand_total.as_("invoice_amount"))
+ .where((sii.sales_order.isin([x.name for x in sorders])) & (si.docstatus == 1))
+ .groupby(sii.parent)
+ )
+ invoices = query_inv.run(as_dict=True)
+
+ return sorders, invoices
+
+
+def set_payment_terms_statuses(sales_orders, invoices, filters):
+ """
+ compute status for payment terms with associated sales invoice using FIFO
+ """
+
+ for so in sales_orders:
+ so.currency = frappe.get_cached_value('Company', filters.get('company'), 'default_currency')
+ so.invoices = ""
+ for inv in [x for x in invoices if x.sales_order == so.name and x.invoice_amount > 0]:
+ if so.base_payment_amount - so.paid_amount > 0:
+ amount = so.base_payment_amount - so.paid_amount
+ if inv.invoice_amount >= amount:
+ inv.invoice_amount -= amount
+ so.paid_amount += amount
+ so.invoices += "," + inv.invoice
+ so.status = "Completed"
+ break
+ else:
+ so.paid_amount += inv.invoice_amount
+ inv.invoice_amount = 0
+ so.invoices += "," + inv.invoice
+ so.status = "Partly Paid"
+
+ return sales_orders, invoices
+
+
+def prepare_chart(s_orders):
+ if len(set([x.name for x in s_orders])) == 1:
+ chart = {
+ "data": {
+ "labels": [term.payment_term for term in s_orders],
+ "datasets": [
+ {"name": "Payment Amount", "values": [x.base_payment_amount for x in s_orders],},
+ {"name": "Paid Amount", "values": [x.paid_amount for x in s_orders],},
+ ],
+ },
+ "type": "bar",
+ }
+ return chart
+
+
+def execute(filters=None):
+ columns = get_columns()
+ sales_orders, so_invoices = get_so_with_invoices(filters)
+ sales_orders, so_invoices = set_payment_terms_statuses(sales_orders, so_invoices, filters)
+
+ prepare_chart(sales_orders)
+
+ data = sales_orders
+ message = []
+ chart = prepare_chart(sales_orders)
+
+ return columns, data, message, chart
diff --git a/erpnext/selling/report/payment_terms_status_for_sales_order/test_payment_terms_status_for_sales_order.py b/erpnext/selling/report/payment_terms_status_for_sales_order/test_payment_terms_status_for_sales_order.py
new file mode 100644
index 0000000..cad41e1
--- /dev/null
+++ b/erpnext/selling/report/payment_terms_status_for_sales_order/test_payment_terms_status_for_sales_order.py
@@ -0,0 +1,198 @@
+import datetime
+
+import frappe
+from frappe.utils import add_days
+
+from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
+from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
+from erpnext.selling.report.payment_terms_status_for_sales_order.payment_terms_status_for_sales_order import (
+ execute,
+)
+from erpnext.stock.doctype.item.test_item import create_item
+from erpnext.tests.utils import ERPNextTestCase
+
+test_dependencies = ["Sales Order", "Item", "Sales Invoice", "Payment Terms Template"]
+
+
+class TestPaymentTermsStatusForSalesOrder(ERPNextTestCase):
+ def create_payment_terms_template(self):
+ # create template for 50-50 payments
+ template = None
+ if frappe.db.exists("Payment Terms Template", "_Test 50-50"):
+ template = frappe.get_doc("Payment Terms Template", "_Test 50-50")
+ else:
+ template = frappe.get_doc(
+ {
+ "doctype": "Payment Terms Template",
+ "template_name": "_Test 50-50",
+ "terms": [
+ {
+ "doctype": "Payment Terms Template Detail",
+ "due_date_based_on": "Day(s) after invoice date",
+ "payment_term_name": "_Test 50% on 15 Days",
+ "description": "_Test 50-50",
+ "invoice_portion": 50,
+ "credit_days": 15,
+ },
+ {
+ "doctype": "Payment Terms Template Detail",
+ "due_date_based_on": "Day(s) after invoice date",
+ "payment_term_name": "_Test 50% on 30 Days",
+ "description": "_Test 50-50",
+ "invoice_portion": 50,
+ "credit_days": 30,
+ },
+ ],
+ }
+ )
+ template.insert()
+ self.template = template
+
+ def test_payment_terms_status(self):
+ self.create_payment_terms_template()
+ item = create_item(item_code="_Test Excavator", is_stock_item=0)
+ so = make_sales_order(
+ transaction_date="2021-06-15",
+ delivery_date=add_days("2021-06-15", -30),
+ item=item.item_code,
+ qty=10,
+ rate=100000,
+ do_not_save=True,
+ )
+ so.po_no = ""
+ so.taxes_and_charges = ""
+ so.taxes = ""
+ so.payment_terms_template = self.template.name
+ so.save()
+ so.submit()
+
+ # make invoice with 60% of the total sales order value
+ sinv = make_sales_invoice(so.name)
+ sinv.taxes_and_charges = ""
+ sinv.taxes = ""
+ sinv.items[0].qty = 6
+ sinv.insert()
+ sinv.submit()
+ columns, data, message, chart = execute(
+ {
+ "company": "_Test Company",
+ "period_start_date": "2021-06-01",
+ "period_end_date": "2021-06-30",
+ "sales_order": [so.name],
+ }
+ )
+
+ expected_value = [
+ {
+ "name": so.name,
+ "submitted": datetime.date(2021, 6, 15),
+ "status": "Completed",
+ "payment_term": None,
+ "description": "_Test 50-50",
+ "due_date": datetime.date(2021, 6, 30),
+ "invoice_portion": 50.0,
+ "currency": "INR",
+ "base_payment_amount": 500000.0,
+ "paid_amount": 500000.0,
+ "invoices": ","+sinv.name,
+ },
+ {
+ "name": so.name,
+ "submitted": datetime.date(2021, 6, 15),
+ "status": "Partly Paid",
+ "payment_term": None,
+ "description": "_Test 50-50",
+ "due_date": datetime.date(2021, 7, 15),
+ "invoice_portion": 50.0,
+ "currency": "INR",
+ "base_payment_amount": 500000.0,
+ "paid_amount": 100000.0,
+ "invoices": ","+sinv.name,
+ },
+ ]
+ self.assertEqual(data, expected_value)
+
+ def create_exchange_rate(self, date):
+ # make an entry in Currency Exchange list. serves as a static exchange rate
+ if frappe.db.exists({'doctype': "Currency Exchange",'date': date,'from_currency': 'USD', 'to_currency':'INR'}):
+ return
+ else:
+ doc = frappe.get_doc({
+ 'doctype': "Currency Exchange",
+ 'date': date,
+ 'from_currency': 'USD',
+ 'to_currency': frappe.get_cached_value("Company", '_Test Company','default_currency'),
+ 'exchange_rate': 70,
+ 'for_buying': True,
+ 'for_selling': True
+ })
+ doc.insert()
+
+ def test_alternate_currency(self):
+ transaction_date = "2021-06-15"
+ self.create_payment_terms_template()
+ self.create_exchange_rate(transaction_date)
+ item = create_item(item_code="_Test Excavator", is_stock_item=0)
+ so = make_sales_order(
+ transaction_date=transaction_date,
+ currency="USD",
+ delivery_date=add_days(transaction_date, -30),
+ item=item.item_code,
+ qty=10,
+ rate=10000,
+ do_not_save=True,
+ )
+ so.po_no = ""
+ so.taxes_and_charges = ""
+ so.taxes = ""
+ so.payment_terms_template = self.template.name
+ so.save()
+ so.submit()
+
+ # make invoice with 60% of the total sales order value
+ sinv = make_sales_invoice(so.name)
+ sinv.currency = "USD"
+ sinv.taxes_and_charges = ""
+ sinv.taxes = ""
+ sinv.items[0].qty = 6
+ sinv.insert()
+ sinv.submit()
+ columns, data, message, chart = execute(
+ {
+ "company": "_Test Company",
+ "period_start_date": "2021-06-01",
+ "period_end_date": "2021-06-30",
+ "sales_order": [so.name],
+ }
+ )
+
+ # report defaults to company currency.
+ expected_value = [
+ {
+ "name": so.name,
+ "submitted": datetime.date(2021, 6, 15),
+ "status": "Completed",
+ "payment_term": None,
+ "description": "_Test 50-50",
+ "due_date": datetime.date(2021, 6, 30),
+ "invoice_portion": 50.0,
+ "currency": frappe.get_cached_value("Company", '_Test Company','default_currency'),
+ "base_payment_amount": 3500000.0,
+ "paid_amount": 3500000.0,
+ "invoices": ","+sinv.name,
+ },
+ {
+ "name": so.name,
+ "submitted": datetime.date(2021, 6, 15),
+ "status": "Partly Paid",
+ "payment_term": None,
+ "description": "_Test 50-50",
+ "due_date": datetime.date(2021, 7, 15),
+ "invoice_portion": 50.0,
+ "currency": frappe.get_cached_value("Company", '_Test Company','default_currency'),
+ "base_payment_amount": 3500000.0,
+ "paid_amount": 700000.0,
+ "invoices": ","+sinv.name,
+ },
+ ]
+ self.assertEqual(data, expected_value)
diff --git a/erpnext/selling/sales_common.js b/erpnext/selling/sales_common.js
index 16e3847..98131f9 100644
--- a/erpnext/selling/sales_common.js
+++ b/erpnext/selling/sales_common.js
@@ -227,11 +227,11 @@
},
callback:function(r){
if (in_list(['Delivery Note', 'Sales Invoice'], doc.doctype)) {
-
if (doc.doctype === 'Sales Invoice' && (!doc.update_stock)) return;
-
- me.set_batch_number(cdt, cdn);
- me.batch_no(doc, cdt, cdn);
+ if (has_batch_no) {
+ me.set_batch_number(cdt, cdn);
+ me.batch_no(doc, cdt, cdn);
+ }
}
}
});
diff --git a/erpnext/stock/doctype/bin/bin.py b/erpnext/stock/doctype/bin/bin.py
index d2bae65..3bc15a8 100644
--- a/erpnext/stock/doctype/bin/bin.py
+++ b/erpnext/stock/doctype/bin/bin.py
@@ -20,18 +20,6 @@
+ flt(self.indented_qty) + flt(self.planned_qty) - flt(self.reserved_qty)
- flt(self.reserved_qty_for_production) - flt(self.reserved_qty_for_sub_contract))
- def get_first_sle(self):
- sle = frappe.qb.DocType("Stock Ledger Entry")
- first_sle = (
- frappe.qb.from_(sle)
- .select("*")
- .where((sle.item_code == self.item_code) & (sle.warehouse == self.warehouse))
- .orderby(sle.posting_date, sle.posting_time, sle.creation)
- .limit(1)
- ).run(as_dict=True)
-
- return first_sle and first_sle[0] or None
-
def update_reserved_qty_for_production(self):
'''Update qty reserved for production from Production Item tables
in open work orders'''
@@ -107,13 +95,6 @@
frappe.db.add_unique("Bin", ["item_code", "warehouse"], constraint_name="unique_item_warehouse")
-def update_stock(bin_name, args, allow_negative_stock=False, via_landed_cost_voucher=False):
- """WARNING: This function is deprecated. Inline this function instead of using it."""
- from erpnext.stock.stock_ledger import repost_current_voucher
-
- repost_current_voucher(args, allow_negative_stock, via_landed_cost_voucher)
- update_qty(bin_name, args)
-
def get_bin_details(bin_name):
return frappe.db.get_value('Bin', bin_name, ['actual_qty', 'ordered_qty',
'reserved_qty', 'indented_qty', 'planned_qty', 'reserved_qty_for_production',
diff --git a/erpnext/stock/doctype/item/item.js b/erpnext/stock/doctype/item/item.js
index 2a30ca1..dfc0918 100644
--- a/erpnext/stock/doctype/item/item.js
+++ b/erpnext/stock/doctype/item/item.js
@@ -545,7 +545,7 @@
let selected_attributes = {};
me.multiple_variant_dialog.$wrapper.find('.form-column').each((i, col) => {
if(i===0) return;
- let attribute_name = $(col).find('label').html();
+ let attribute_name = $(col).find('label').html().trim();
selected_attributes[attribute_name] = [];
let checked_opts = $(col).find('.checkbox input');
checked_opts.each((i, opt) => {
diff --git a/erpnext/stock/doctype/item/item.json b/erpnext/stock/doctype/item/item.json
index b05f58a..c797187 100644
--- a/erpnext/stock/doctype/item/item.json
+++ b/erpnext/stock/doctype/item/item.json
@@ -48,6 +48,7 @@
"warranty_period",
"weight_per_unit",
"weight_uom",
+ "allow_negative_stock",
"reorder_section",
"reorder_levels",
"unit_of_measure_conversion",
@@ -907,6 +908,12 @@
"fieldname": "is_grouped_asset",
"fieldtype": "Check",
"label": "Create Grouped Asset"
+ },
+ {
+ "default": "0",
+ "fieldname": "allow_negative_stock",
+ "fieldtype": "Check",
+ "label": "Allow Negative Stock"
}
],
"icon": "fa fa-tag",
@@ -914,7 +921,7 @@
"image_field": "image",
"index_web_pages_for_search": 1,
"links": [],
- "modified": "2022-01-18 12:57:54.273202",
+ "modified": "2022-02-11 08:07:46.663220",
"modified_by": "Administrator",
"module": "Stock",
"name": "Item",
diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py
index fc45ba9..fd4df42 100644
--- a/erpnext/stock/doctype/item/test_item.py
+++ b/erpnext/stock/doctype/item/test_item.py
@@ -6,6 +6,7 @@
import frappe
from frappe.test_runner import make_test_objects
+from frappe.utils import add_days, today
from erpnext.controllers.item_variant import (
InvalidItemAttributeValueError,
@@ -608,6 +609,45 @@
item.item_group = "All Item Groups"
item.save() # if item code saved without item_code then series worked
+ @change_settings("Stock Settings", {"allow_negative_stock": 0})
+ def test_item_wise_negative_stock(self):
+ """ When global settings are disabled check that item that allows
+ negative stock can still consume material in all known stock
+ transactions that consume inventory."""
+ from erpnext.stock.stock_ledger import is_negative_stock_allowed
+
+ item = make_item("_TestNegativeItemSetting", {"allow_negative_stock": 1, "valuation_rate": 100})
+ self.assertTrue(is_negative_stock_allowed(item_code=item.name))
+
+ self.consume_item_code_with_differet_stock_transactions(item_code=item.name)
+
+ @change_settings("Stock Settings", {"allow_negative_stock": 0})
+ def test_backdated_negative_stock(self):
+ """ same as test above but backdated entries """
+ from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
+ item = make_item("_TestNegativeItemSetting", {"allow_negative_stock": 1, "valuation_rate": 100})
+
+ # create a future entry so all new entries are backdated
+ make_stock_entry(qty=1, item_code=item.name, target="_Test Warehouse - _TC", posting_date = add_days(today(), 5))
+ self.consume_item_code_with_differet_stock_transactions(item_code=item.name)
+
+
+ def consume_item_code_with_differet_stock_transactions(self, item_code, warehouse="_Test Warehouse - _TC"):
+ from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
+ from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
+ from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
+ from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
+
+ typical_args = {"item_code": item_code, "warehouse": warehouse}
+
+ create_delivery_note(**typical_args)
+ create_sales_invoice(update_stock=1, **typical_args)
+ make_stock_entry(item_code=item_code, source=warehouse, qty=1, purpose="Material Issue")
+ make_stock_entry(item_code=item_code, source=warehouse, target="Stores - _TC", qty=1)
+ # standalone return
+ make_purchase_receipt(is_return=True, qty=-1, **typical_args)
+
+
def set_item_variant_settings(fields):
doc = frappe.get_doc('Item Variant Settings')
diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py
index 103e8d6..b39328f 100644
--- a/erpnext/stock/doctype/material_request/material_request.py
+++ b/erpnext/stock/doctype/material_request/material_request.py
@@ -533,6 +533,7 @@
"stock_uom": d.stock_uom,
"expected_delivery_date": d.schedule_date,
"sales_order": d.sales_order,
+ "sales_order_item": d.get("sales_order_item"),
"bom_no": get_item_details(d.item_code).bom_no,
"material_request": mr.name,
"material_request_item": d.name,
diff --git a/erpnext/stock/doctype/putaway_rule/putaway_rule.py b/erpnext/stock/doctype/putaway_rule/putaway_rule.py
index 523ba12..4e472a9 100644
--- a/erpnext/stock/doctype/putaway_rule/putaway_rule.py
+++ b/erpnext/stock/doctype/putaway_rule/putaway_rule.py
@@ -9,7 +9,7 @@
import frappe
from frappe import _
from frappe.model.document import Document
-from frappe.utils import cint, floor, flt, nowdate
+from frappe.utils import cint, cstr, floor, flt, nowdate
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
from erpnext.stock.utils import get_stock_balance
@@ -142,11 +142,44 @@
if items_not_accomodated:
show_unassigned_items_message(items_not_accomodated)
- items[:] = updated_table if updated_table else items # modify items table
+ if updated_table and _items_changed(items, updated_table, doctype):
+ items[:] = updated_table
+ frappe.msgprint(_("Applied putaway rules."), alert=True)
if sync and json.loads(sync): # sync with client side
return items
+def _items_changed(old, new, doctype: str) -> bool:
+ """ Check if any items changed by application of putaway rules.
+
+ If not, changing item table can have side effects since `name` items also changes.
+ """
+ if len(old) != len(new):
+ return True
+
+ old = [frappe._dict(item) if isinstance(item, dict) else item for item in old]
+
+ if doctype == "Stock Entry":
+ compare_keys = ("item_code", "t_warehouse", "transfer_qty", "serial_no")
+ sort_key = lambda item: (item.item_code, cstr(item.t_warehouse), # noqa
+ flt(item.transfer_qty), cstr(item.serial_no))
+ else:
+ # purchase receipt / invoice
+ compare_keys = ("item_code", "warehouse", "stock_qty", "received_qty", "serial_no")
+ sort_key = lambda item: (item.item_code, cstr(item.warehouse), # noqa
+ flt(item.stock_qty), flt(item.received_qty), cstr(item.serial_no))
+
+ old_sorted = sorted(old, key=sort_key)
+ new_sorted = sorted(new, key=sort_key)
+
+ # Once sorted by all relevant keys both tables should align if they are same.
+ for old_item, new_item in zip(old_sorted, new_sorted):
+ for key in compare_keys:
+ if old_item.get(key) != new_item.get(key):
+ return True
+ return False
+
+
def get_ordered_putaway_rules(item_code, company, source_warehouse=None):
"""Returns an ordered list of putaway rules to apply on an item."""
filters = {
diff --git a/erpnext/stock/doctype/putaway_rule/test_putaway_rule.py b/erpnext/stock/doctype/putaway_rule/test_putaway_rule.py
index bd4d811..ff1c19a 100644
--- a/erpnext/stock/doctype/putaway_rule/test_putaway_rule.py
+++ b/erpnext/stock/doctype/putaway_rule/test_putaway_rule.py
@@ -35,6 +35,18 @@
new_uom.uom_name = "Bag"
new_uom.save()
+ def assertUnchangedItemsOnResave(self, doc):
+ """ Check if same items remain even after reapplication of rules.
+
+ This is required since some business logic like subcontracting
+ depends on `name` of items to be same if item isn't changed.
+ """
+ doc.reload()
+ old_items = {d.name for d in doc.items}
+ doc.save()
+ new_items = {d.name for d in doc.items}
+ self.assertSetEqual(old_items, new_items)
+
def test_putaway_rules_priority(self):
"""Test if rule is applied by priority, irrespective of free space."""
rule_1 = create_putaway_rule(item_code="_Rice", warehouse=self.warehouse_1, capacity=200,
@@ -50,6 +62,8 @@
self.assertEqual(pr.items[1].qty, 100)
self.assertEqual(pr.items[1].warehouse, self.warehouse_2)
+ self.assertUnchangedItemsOnResave(pr)
+
pr.delete()
rule_1.delete()
rule_2.delete()
@@ -162,6 +176,8 @@
# leftover space was for 500 kg (0.5 Bag)
# Since Bag is a whole UOM, 1(out of 2) Bag will be unassigned
+ self.assertUnchangedItemsOnResave(pr)
+
pr.delete()
rule_1.delete()
rule_2.delete()
@@ -196,6 +212,8 @@
self.assertEqual(pr.items[1].warehouse, self.warehouse_1)
self.assertEqual(pr.items[1].putaway_rule, rule_1.name)
+ self.assertUnchangedItemsOnResave(pr)
+
pr.delete()
rule_1.delete()
@@ -239,6 +257,8 @@
self.assertEqual(stock_entry_item.qty, 100) # unassigned 100 out of 200 Kg
self.assertEqual(stock_entry_item.putaway_rule, rule_2.name)
+ self.assertUnchangedItemsOnResave(stock_entry)
+
stock_entry.delete()
rule_1.delete()
rule_2.delete()
@@ -294,6 +314,8 @@
self.assertEqual(stock_entry.items[2].qty, 200)
self.assertEqual(stock_entry.items[2].putaway_rule, rule_2.name)
+ self.assertUnchangedItemsOnResave(stock_entry)
+
stock_entry.delete()
rule_1.delete()
rule_2.delete()
@@ -344,6 +366,8 @@
self.assertEqual(stock_entry.items[1].serial_no, "\n".join(serial_nos[3:]))
self.assertEqual(stock_entry.items[1].batch_no, "BOTTL-BATCH-1")
+ self.assertUnchangedItemsOnResave(stock_entry)
+
stock_entry.delete()
pr.cancel()
rule_1.delete()
@@ -366,6 +390,8 @@
self.assertEqual(stock_entry_item.qty, 100)
self.assertEqual(stock_entry_item.putaway_rule, rule_1.name)
+ self.assertUnchangedItemsOnResave(stock_entry)
+
stock_entry.delete()
rule_1.delete()
rule_2.delete()
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py
index 782fcf0..9ba007a 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry.py
+++ b/erpnext/stock/doctype/stock_entry/stock_entry.py
@@ -433,9 +433,10 @@
)
def set_actual_qty(self):
- allow_negative_stock = cint(frappe.db.get_value("Stock Settings", None, "allow_negative_stock"))
+ from erpnext.stock.stock_ledger import is_negative_stock_allowed
for d in self.get('items'):
+ allow_negative_stock = is_negative_stock_allowed(item_code=d.item_code)
previous_sle = get_previous_sle({
"item_code": d.item_code,
"warehouse": d.s_warehouse or d.t_warehouse,
diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py
index 06f8fa7..9bec5f7 100644
--- a/erpnext/stock/get_item_details.py
+++ b/erpnext/stock/get_item_details.py
@@ -6,6 +6,7 @@
import frappe
from frappe import _, throw
+from frappe.model import child_table_fields, default_fields
from frappe.model.meta import get_field_precision
from frappe.utils import add_days, add_months, cint, cstr, flt, getdate
@@ -119,8 +120,15 @@
out.rate = args.rate or out.price_list_rate
out.amount = flt(args.qty) * flt(out.rate)
+ out = remove_standard_fields(out)
return out
+def remove_standard_fields(details):
+ for key in child_table_fields + default_fields:
+ details.pop(key, None)
+ return details
+
+
def update_stock(args, out):
if (args.get("doctype") == "Delivery Note" or
(args.get("doctype") == "Sales Invoice" and args.get('update_stock'))) \
@@ -343,6 +351,7 @@
args.conversion_factor = out.conversion_factor
out.stock_qty = out.qty * out.conversion_factor
+ args.stock_qty = out.stock_qty
# calculate last purchase rate
if args.get('doctype') in purchase_doctypes:
diff --git a/erpnext/stock/report/stock_ageing/stock_ageing.py b/erpnext/stock/report/stock_ageing/stock_ageing.py
index e6dfc97..a89a403 100644
--- a/erpnext/stock/report/stock_ageing/stock_ageing.py
+++ b/erpnext/stock/report/stock_ageing/stock_ageing.py
@@ -252,6 +252,7 @@
key, fifo_queue, transferred_item_key = self.__init_key_stores(d)
if d.voucher_type == "Stock Reconciliation":
+ # get difference in qty shift as actual qty
prev_balance_qty = self.item_details[key].get("qty_after_transaction", 0)
d.actual_qty = flt(d.qty_after_transaction) - flt(prev_balance_qty)
@@ -264,12 +265,16 @@
self.__update_balances(d, key)
+ if not self.filters.get("show_warehouse_wise_stock"):
+ # (Item 1, WH 1), (Item 1, WH 2) => (Item 1)
+ self.item_details = self.__aggregate_details_by_item(self.item_details)
+
return self.item_details
def __init_key_stores(self, row: Dict) -> Tuple:
"Initialise keys and FIFO Queue."
- key = (row.name, row.warehouse) if self.filters.get('show_warehouse_wise_stock') else row.name
+ key = (row.name, row.warehouse)
self.item_details.setdefault(key, {"details": row, "fifo_queue": []})
fifo_queue = self.item_details[key]["fifo_queue"]
@@ -338,6 +343,27 @@
self.item_details[key]["has_serial_no"] = row.has_serial_no
+ def __aggregate_details_by_item(self, wh_wise_data: Dict) -> Dict:
+ "Aggregate Item-Wh wise data into single Item entry."
+ item_aggregated_data = {}
+ for key,row in wh_wise_data.items():
+ item = key[0]
+ if not item_aggregated_data.get(item):
+ item_aggregated_data.setdefault(item, {
+ "details": frappe._dict(),
+ "fifo_queue": [],
+ "qty_after_transaction": 0.0,
+ "total_qty": 0.0
+ })
+ item_row = item_aggregated_data.get(item)
+ item_row["details"].update(row["details"])
+ item_row["fifo_queue"].extend(row["fifo_queue"])
+ item_row["qty_after_transaction"] += flt(row["qty_after_transaction"])
+ item_row["total_qty"] += flt(row["total_qty"])
+ item_row["has_serial_no"] = row["has_serial_no"]
+
+ return item_aggregated_data
+
def __get_stock_ledger_entries(self) -> List[Dict]:
sle = frappe.qb.DocType("Stock Ledger Entry")
item = self.__get_item_query() # used as derived table in sle query
diff --git a/erpnext/stock/report/stock_ageing/stock_ageing_fifo_logic.md b/erpnext/stock/report/stock_ageing/stock_ageing_fifo_logic.md
index 5ffe97f..9e9bed4 100644
--- a/erpnext/stock/report/stock_ageing/stock_ageing_fifo_logic.md
+++ b/erpnext/stock/report/stock_ageing/stock_ageing_fifo_logic.md
@@ -15,6 +15,7 @@
50 qty is (today-the 1st) days old
20 qty is (today-the 2nd) days old
+> Note: We generate FIFO slots warehouse wise as stock reconciliations from different warehouses can cause incorrect values.
### Calculation of FIFO Slots
#### Case 1: Outward from sufficient balance qty
diff --git a/erpnext/stock/report/stock_ageing/test_stock_ageing.py b/erpnext/stock/report/stock_ageing/test_stock_ageing.py
index 949bb7c..66d2f6b 100644
--- a/erpnext/stock/report/stock_ageing/test_stock_ageing.py
+++ b/erpnext/stock/report/stock_ageing/test_stock_ageing.py
@@ -15,11 +15,12 @@
)
def test_normal_inward_outward_queue(self):
- "Reference: Case 1 in stock_ageing_fifo_logic.md"
+ "Reference: Case 1 in stock_ageing_fifo_logic.md (same wh)"
sle = [
frappe._dict(
name="Flask Item",
actual_qty=30, qty_after_transaction=30,
+ warehouse="WH 1",
posting_date="2021-12-01", voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False, serial_no=None
@@ -27,6 +28,7 @@
frappe._dict(
name="Flask Item",
actual_qty=20, qty_after_transaction=50,
+ warehouse="WH 1",
posting_date="2021-12-02", voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False, serial_no=None
@@ -34,6 +36,7 @@
frappe._dict(
name="Flask Item",
actual_qty=(-10), qty_after_transaction=40,
+ warehouse="WH 1",
posting_date="2021-12-03", voucher_type="Stock Entry",
voucher_no="003",
has_serial_no=False, serial_no=None
@@ -50,11 +53,12 @@
self.assertEqual(queue[0][0], 20.0)
def test_insufficient_balance(self):
- "Reference: Case 3 in stock_ageing_fifo_logic.md"
+ "Reference: Case 3 in stock_ageing_fifo_logic.md (same wh)"
sle = [
frappe._dict(
name="Flask Item",
actual_qty=(-30), qty_after_transaction=(-30),
+ warehouse="WH 1",
posting_date="2021-12-01", voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False, serial_no=None
@@ -62,6 +66,7 @@
frappe._dict(
name="Flask Item",
actual_qty=20, qty_after_transaction=(-10),
+ warehouse="WH 1",
posting_date="2021-12-02", voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False, serial_no=None
@@ -69,6 +74,7 @@
frappe._dict(
name="Flask Item",
actual_qty=20, qty_after_transaction=10,
+ warehouse="WH 1",
posting_date="2021-12-03", voucher_type="Stock Entry",
voucher_no="003",
has_serial_no=False, serial_no=None
@@ -76,6 +82,7 @@
frappe._dict(
name="Flask Item",
actual_qty=10, qty_after_transaction=20,
+ warehouse="WH 1",
posting_date="2021-12-03", voucher_type="Stock Entry",
voucher_no="004",
has_serial_no=False, serial_no=None
@@ -91,11 +98,16 @@
self.assertEqual(queue[0][0], 10.0)
self.assertEqual(queue[1][0], 10.0)
- def test_stock_reconciliation(self):
+ def test_basic_stock_reconciliation(self):
+ """
+ Ledger (same wh): [+30, reco reset >> 50, -10]
+ Bal: 40
+ """
sle = [
frappe._dict(
name="Flask Item",
actual_qty=30, qty_after_transaction=30,
+ warehouse="WH 1",
posting_date="2021-12-01", voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False, serial_no=None
@@ -103,6 +115,7 @@
frappe._dict(
name="Flask Item",
actual_qty=0, qty_after_transaction=50,
+ warehouse="WH 1",
posting_date="2021-12-02", voucher_type="Stock Reconciliation",
voucher_no="002",
has_serial_no=False, serial_no=None
@@ -110,6 +123,7 @@
frappe._dict(
name="Flask Item",
actual_qty=(-10), qty_after_transaction=40,
+ warehouse="WH 1",
posting_date="2021-12-03", voucher_type="Stock Entry",
voucher_no="003",
has_serial_no=False, serial_no=None
@@ -122,5 +136,112 @@
queue = result["fifo_queue"]
self.assertEqual(result["qty_after_transaction"], result["total_qty"])
+ self.assertEqual(result["total_qty"], 40.0)
self.assertEqual(queue[0][0], 20.0)
self.assertEqual(queue[1][0], 20.0)
+
+ def test_sequential_stock_reco_same_warehouse(self):
+ """
+ Test back to back stock recos (same warehouse).
+ Ledger: [reco opening >> +1000, reco reset >> 400, -10]
+ Bal: 390
+ """
+ sle = [
+ frappe._dict(
+ name="Flask Item",
+ actual_qty=0, qty_after_transaction=1000,
+ warehouse="WH 1",
+ posting_date="2021-12-01", voucher_type="Stock Reconciliation",
+ voucher_no="002",
+ has_serial_no=False, serial_no=None
+ ),
+ frappe._dict(
+ name="Flask Item",
+ actual_qty=0, qty_after_transaction=400,
+ warehouse="WH 1",
+ posting_date="2021-12-02", voucher_type="Stock Reconciliation",
+ voucher_no="003",
+ has_serial_no=False, serial_no=None
+ ),
+ frappe._dict(
+ name="Flask Item",
+ actual_qty=(-10), qty_after_transaction=390,
+ warehouse="WH 1",
+ posting_date="2021-12-03", voucher_type="Stock Entry",
+ voucher_no="003",
+ has_serial_no=False, serial_no=None
+ )
+ ]
+ slots = FIFOSlots(self.filters, sle).generate()
+
+ result = slots["Flask Item"]
+ queue = result["fifo_queue"]
+
+ self.assertEqual(result["qty_after_transaction"], result["total_qty"])
+ self.assertEqual(result["total_qty"], 390.0)
+ self.assertEqual(queue[0][0], 390.0)
+
+ def test_sequential_stock_reco_different_warehouse(self):
+ """
+ Ledger:
+ WH | Voucher | Qty
+ -------------------
+ WH1 | Reco | 1000
+ WH2 | Reco | 400
+ WH1 | SE | -10
+
+ Bal: WH1 bal + WH2 bal = 990 + 400 = 1390
+ """
+ sle = [
+ frappe._dict(
+ name="Flask Item",
+ actual_qty=0, qty_after_transaction=1000,
+ warehouse="WH 1",
+ posting_date="2021-12-01", voucher_type="Stock Reconciliation",
+ voucher_no="002",
+ has_serial_no=False, serial_no=None
+ ),
+ frappe._dict(
+ name="Flask Item",
+ actual_qty=0, qty_after_transaction=400,
+ warehouse="WH 2",
+ posting_date="2021-12-02", voucher_type="Stock Reconciliation",
+ voucher_no="003",
+ has_serial_no=False, serial_no=None
+ ),
+ frappe._dict(
+ name="Flask Item",
+ actual_qty=(-10), qty_after_transaction=990,
+ warehouse="WH 1",
+ posting_date="2021-12-03", voucher_type="Stock Entry",
+ voucher_no="004",
+ has_serial_no=False, serial_no=None
+ )
+ ]
+
+ item_wise_slots, item_wh_wise_slots = generate_item_and_item_wh_wise_slots(
+ filters=self.filters,sle=sle
+ )
+
+ # test without 'show_warehouse_wise_stock'
+ item_result = item_wise_slots["Flask Item"]
+ queue = item_result["fifo_queue"]
+
+ self.assertEqual(item_result["qty_after_transaction"], item_result["total_qty"])
+ self.assertEqual(item_result["total_qty"], 1390.0)
+ self.assertEqual(queue[0][0], 990.0)
+ self.assertEqual(queue[1][0], 400.0)
+
+ # test with 'show_warehouse_wise_stock' checked
+ item_wh_balances = [item_wh_wise_slots.get(i).get("qty_after_transaction") for i in item_wh_wise_slots]
+ self.assertEqual(sum(item_wh_balances), item_result["qty_after_transaction"])
+
+def generate_item_and_item_wh_wise_slots(filters, sle):
+ "Return results with and without 'show_warehouse_wise_stock'"
+ item_wise_slots = FIFOSlots(filters, sle).generate()
+
+ filters.show_warehouse_wise_stock = True
+ item_wh_wise_slots = FIFOSlots(filters, sle).generate()
+ filters.show_warehouse_wise_stock = False
+
+ return item_wise_slots, item_wh_wise_slots
\ No newline at end of file
diff --git a/erpnext/stock/stock_balance.py b/erpnext/stock/stock_balance.py
index 6663458..62017e4 100644
--- a/erpnext/stock/stock_balance.py
+++ b/erpnext/stock/stock_balance.py
@@ -3,10 +3,9 @@
import frappe
-from frappe.utils import cstr, flt, nowdate, nowtime
+from frappe.utils import cstr, flt, now, nowdate, nowtime
from erpnext.controllers.stock_controller import create_repost_item_valuation_entry
-from erpnext.stock.utils import update_bin
def repost(only_actual=False, allow_negative_stock=False, allow_zero_rate=False, only_bin=False):
@@ -175,6 +174,7 @@
bin.set(field, flt(value))
mismatch = True
+ bin.modified = now()
if mismatch:
bin.set_projected_qty()
bin.db_update()
@@ -227,8 +227,6 @@
"sle_id": sle_doc.name
})
- update_bin(args)
-
create_repost_item_valuation_entry({
"item_code": d[0],
"warehouse": d[1],
diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py
index 41c4002..00ca81f 100644
--- a/erpnext/stock/stock_ledger.py
+++ b/erpnext/stock/stock_ledger.py
@@ -3,6 +3,7 @@
import copy
import json
+from typing import Optional
import frappe
from frappe import _
@@ -268,11 +269,10 @@
self.verbose = verbose
self.allow_zero_rate = allow_zero_rate
self.via_landed_cost_voucher = via_landed_cost_voucher
- self.allow_negative_stock = allow_negative_stock \
- or cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock"))
+ self.item_code = args.get("item_code")
+ self.allow_negative_stock = allow_negative_stock or is_negative_stock_allowed(item_code=self.item_code)
self.args = frappe._dict(args)
- self.item_code = args.get("item_code")
if self.args.sle_id:
self.args['name'] = self.args.sle_id
@@ -1049,10 +1049,7 @@
)"""
def validate_negative_qty_in_future_sle(args, allow_negative_stock=False):
- allow_negative_stock = cint(allow_negative_stock) \
- or cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock"))
-
- if allow_negative_stock:
+ if allow_negative_stock or is_negative_stock_allowed(item_code=args.item_code):
return
if not (args.actual_qty < 0 or args.voucher_type == "Stock Reconciliation"):
return
@@ -1121,3 +1118,11 @@
and timestamp(posting_date, posting_time) >= timestamp(%(posting_date)s, %(posting_time)s)
limit 1
""", args, as_dict=1)
+
+
+def is_negative_stock_allowed(*, item_code: Optional[str] = None) -> bool:
+ if cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock", cache=True)):
+ return True
+ if item_code and cint(frappe.db.get_value("Item", item_code, "allow_negative_stock", cache=True)):
+ return True
+ return False
diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py
index c75c737..7263e39 100644
--- a/erpnext/stock/utils.py
+++ b/erpnext/stock/utils.py
@@ -206,16 +206,6 @@
return bin_obj
-def update_bin(args, allow_negative_stock=False, via_landed_cost_voucher=False):
- """WARNING: This function is deprecated. Inline this function instead of using it."""
- from erpnext.stock.doctype.bin.bin import update_stock
- is_stock_item = frappe.get_cached_value('Item', args.get("item_code"), 'is_stock_item')
- if is_stock_item:
- bin_name = get_or_make_bin(args.get("item_code"), args.get("warehouse"))
- update_stock(bin_name, args, allow_negative_stock, via_landed_cost_voucher)
- else:
- frappe.msgprint(_("Item {0} ignored since it is not a stock item").format(args.get("item_code")))
-
@frappe.whitelist()
def get_incoming_rate(args, raise_error_if_no_rate=True):
"""Get Incoming Rate based on valuation method"""
diff --git a/erpnext/translations/de.csv b/erpnext/translations/de.csv
index 4a6c834..cf73564 100644
--- a/erpnext/translations/de.csv
+++ b/erpnext/translations/de.csv
@@ -913,6 +913,7 @@
Email Address,E-Mail-Adresse,
"Email Address must be unique, already exists for {0}","E-Mail-Adresse muss eindeutig sein, diese wird bereits für {0} verwendet",
Email Digest: ,E-Mail-Bericht:,
+Email Digest Recipient,E-Mail-Berichtsempfänger,
Email Reminders will be sent to all parties with email contacts,E-Mail-Erinnerungen werden an alle Parteien mit E-Mail-Kontakten gesendet,
Email Sent,E-Mail wurde versandt,
Email Template,E-Mail-Vorlage,
@@ -2944,7 +2945,7 @@
Temporary Opening,Temporäre Eröffnungskonten,
Terms and Conditions,Allgemeine Geschäftsbedingungen,
Terms and Conditions Template,Vorlage für Allgemeine Geschäftsbedingungen,
-Territory,Region,
+Territory,Gebiet,
Test,Test,
Thank you,Danke,
Thank you for your business!,Vielen Dank für Ihr Unternehmen!,