Merge branch 'develop' into gross_profit_non_stock_item
This commit is contained in:
		
						commit
						e13d774e8e
					
				
							
								
								
									
										14
									
								
								.github/helper/install.sh
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										14
									
								
								.github/helper/install.sh
									
									
									
									
										vendored
									
									
								
							| @ -40,10 +40,14 @@ if [ "$DB" == "postgres" ];then | ||||
|     echo "travis" | psql -h 127.0.0.1 -p 5432 -c "CREATE USER test_frappe WITH PASSWORD 'test_frappe'" -U postgres; | ||||
| fi | ||||
| 
 | ||||
| wget -O /tmp/wkhtmltox.tar.xz https://github.com/frappe/wkhtmltopdf/raw/master/wkhtmltox-0.12.3_linux-generic-amd64.tar.xz | ||||
| tar -xf /tmp/wkhtmltox.tar.xz -C /tmp | ||||
| sudo mv /tmp/wkhtmltox/bin/wkhtmltopdf /usr/local/bin/wkhtmltopdf | ||||
| sudo chmod o+x /usr/local/bin/wkhtmltopdf | ||||
| 
 | ||||
| install_whktml() { | ||||
|     wget -O /tmp/wkhtmltox.tar.xz https://github.com/frappe/wkhtmltopdf/raw/master/wkhtmltox-0.12.3_linux-generic-amd64.tar.xz | ||||
|     tar -xf /tmp/wkhtmltox.tar.xz -C /tmp | ||||
|     sudo mv /tmp/wkhtmltox/bin/wkhtmltopdf /usr/local/bin/wkhtmltopdf | ||||
|     sudo chmod o+x /usr/local/bin/wkhtmltopdf | ||||
| } | ||||
| install_whktml & | ||||
| 
 | ||||
| cd ~/frappe-bench || exit | ||||
| 
 | ||||
| @ -57,5 +61,5 @@ bench get-app erpnext "${GITHUB_WORKSPACE}" | ||||
| if [ "$TYPE" == "server" ]; then bench setup requirements --dev; fi | ||||
| 
 | ||||
| bench start &> bench_run_logs.txt & | ||||
| CI=Yes bench build --app frappe & | ||||
| bench --site test_site reinstall --yes | ||||
| bench build --app frappe | ||||
|  | ||||
							
								
								
									
										1
									
								
								.github/stale.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.github/stale.yml
									
									
									
									
										vendored
									
									
								
							| @ -30,6 +30,7 @@ issues: | ||||
|   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 | ||||
|  | ||||
| @ -2,8 +2,6 @@ import inspect | ||||
| 
 | ||||
| import frappe | ||||
| 
 | ||||
| from erpnext.hooks import regional_overrides | ||||
| 
 | ||||
| __version__ = '14.0.0-dev' | ||||
| 
 | ||||
| def get_default_company(user=None): | ||||
| @ -121,14 +119,17 @@ def allow_regional(fn): | ||||
| 	@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): | ||||
|  | ||||
| @ -14,6 +14,10 @@ frappe.ui.form.on("Bank Reconciliation Tool", { | ||||
| 		}); | ||||
| 	}, | ||||
| 
 | ||||
| 	onload: function (frm) { | ||||
| 		frm.trigger('bank_account'); | ||||
| 	}, | ||||
| 
 | ||||
| 	refresh: function (frm) { | ||||
| 		frappe.require("bank-reconciliation-tool.bundle.js", () => | ||||
| 			frm.trigger("make_reconciliation_tool") | ||||
| @ -51,7 +55,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", { | ||||
| 	bank_account: function (frm) { | ||||
| 		frappe.db.get_value( | ||||
| 			"Bank Account", | ||||
| 			frm.bank_account, | ||||
| 			frm.doc.bank_account, | ||||
| 			"account", | ||||
| 			(r) => { | ||||
| 				frappe.db.get_value( | ||||
| @ -60,6 +64,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", { | ||||
| 					"account_currency", | ||||
| 					(r) => { | ||||
| 						frm.currency = r.account_currency; | ||||
| 						frm.trigger("render_chart"); | ||||
| 					} | ||||
| 				); | ||||
| 			} | ||||
| @ -124,7 +129,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", { | ||||
| 		} | ||||
| 	}, | ||||
| 
 | ||||
| 	render_chart(frm) { | ||||
| 	render_chart: frappe.utils.debounce((frm) => { | ||||
| 		frm.cards_manager = new erpnext.accounts.bank_reconciliation.NumberCardManager( | ||||
| 			{ | ||||
| 				$reconciliation_tool_cards: frm.get_field( | ||||
| @ -136,7 +141,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", { | ||||
| 				currency: frm.currency, | ||||
| 			} | ||||
| 		); | ||||
| 	}, | ||||
| 	}, 500), | ||||
| 
 | ||||
| 	render(frm) { | ||||
| 		if (frm.doc.bank_account) { | ||||
|  | ||||
| @ -167,7 +167,8 @@ class OpeningInvoiceCreationTool(Document): | ||||
| 			"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() | ||||
|  | ||||
| @ -172,9 +172,10 @@ class POSInvoice(SalesInvoice): | ||||
| 			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 @@ class POSInvoice(SalesInvoice): | ||||
| 			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) | ||||
|  | ||||
| @ -586,23 +586,29 @@ class TestPOSInvoice(unittest.TestCase): | ||||
| 		item_price.insert() | ||||
| 		pr = make_pricing_rule(selling=1, priority=5, discount_percentage=10) | ||||
| 		pr.save() | ||||
| 		pos_inv = create_pos_invoice(qty=1, do_not_submit=1) | ||||
| 		pos_inv.items[0].rate = 300 | ||||
| 		pos_inv.save() | ||||
| 		self.assertEquals(pos_inv.items[0].discount_percentage, 10) | ||||
| 		# rate shouldn't change | ||||
| 		self.assertEquals(pos_inv.items[0].rate, 405) | ||||
| 
 | ||||
| 		pos_inv.ignore_pricing_rule = 1 | ||||
| 		pos_inv.items[0].rate = 300 | ||||
| 		pos_inv.save() | ||||
| 		self.assertEquals(pos_inv.ignore_pricing_rule, 1) | ||||
| 		# rate should change since pricing rules are ignored | ||||
| 		self.assertEquals(pos_inv.items[0].rate, 300) | ||||
| 		try: | ||||
| 			pos_inv = create_pos_invoice(qty=1, do_not_submit=1) | ||||
| 			pos_inv.items[0].rate = 300 | ||||
| 			pos_inv.save() | ||||
| 			self.assertEquals(pos_inv.items[0].discount_percentage, 10) | ||||
| 			# rate shouldn't change | ||||
| 			self.assertEquals(pos_inv.items[0].rate, 405) | ||||
| 
 | ||||
| 		item_price.delete() | ||||
| 		pos_inv.delete() | ||||
| 		pr.delete() | ||||
| 			pos_inv.ignore_pricing_rule = 1 | ||||
| 			pos_inv.save() | ||||
| 			self.assertEquals(pos_inv.ignore_pricing_rule, 1) | ||||
| 			# rate should reset since pricing rules are ignored | ||||
| 			self.assertEquals(pos_inv.items[0].rate, 450) | ||||
| 
 | ||||
| 			pos_inv.items[0].rate = 300 | ||||
| 			pos_inv.save() | ||||
| 			self.assertEquals(pos_inv.items[0].rate, 300) | ||||
| 
 | ||||
| 		finally: | ||||
| 			item_price.delete() | ||||
| 			pos_inv.delete() | ||||
| 			pr.delete() | ||||
| 
 | ||||
| 
 | ||||
| def create_pos_invoice(**args): | ||||
|  | ||||
| @ -84,12 +84,20 @@ class POSInvoiceMergeLog(Document): | ||||
| 		sales_invoice.set_posting_time = 1 | ||||
| 		sales_invoice.posting_date = getdate(self.posting_date) | ||||
| 		sales_invoice.save() | ||||
| 		self.write_off_fractional_amount(sales_invoice, data) | ||||
| 		sales_invoice.submit() | ||||
| 
 | ||||
| 		self.consolidated_invoice = sales_invoice.name | ||||
| 
 | ||||
| 		return sales_invoice.name | ||||
| 
 | ||||
| 	def write_off_fractional_amount(self, invoice, data): | ||||
| 		pos_invoice_grand_total = sum(d.grand_total for d in data) | ||||
| 
 | ||||
| 		if abs(pos_invoice_grand_total - invoice.grand_total) < 1: | ||||
| 			invoice.write_off_amount += -1 * (pos_invoice_grand_total - invoice.grand_total) | ||||
| 			invoice.save() | ||||
| 
 | ||||
| 	def process_merging_into_credit_note(self, data): | ||||
| 		credit_note = self.get_new_sales_invoice() | ||||
| 		credit_note.is_return = 1 | ||||
| @ -102,6 +110,7 @@ class POSInvoiceMergeLog(Document): | ||||
| 		# TODO: return could be against multiple sales invoice which could also have been consolidated? | ||||
| 		# credit_note.return_against = self.consolidated_invoice | ||||
| 		credit_note.save() | ||||
| 		self.write_off_fractional_amount(credit_note, data) | ||||
| 		credit_note.submit() | ||||
| 
 | ||||
| 		self.consolidated_credit_note = credit_note.name | ||||
| @ -135,9 +144,15 @@ class POSInvoiceMergeLog(Document): | ||||
| 						i.uom == item.uom and i.net_rate == item.net_rate and i.warehouse == item.warehouse): | ||||
| 						found = True | ||||
| 						i.qty = i.qty + item.qty | ||||
| 						i.amount = i.amount + item.net_amount | ||||
| 						i.net_amount = i.amount | ||||
| 						i.base_amount = i.base_amount + item.base_net_amount | ||||
| 						i.base_net_amount = i.base_amount | ||||
| 
 | ||||
| 				if not found: | ||||
| 					item.rate = item.net_rate | ||||
| 					item.amount = item.net_amount | ||||
| 					item.base_amount = item.base_net_amount | ||||
| 					item.price_list_rate = 0 | ||||
| 					si_item = map_child_doc(item, invoice, {"doctype": "Sales Invoice Item"}) | ||||
| 					items.append(si_item) | ||||
| @ -169,6 +184,7 @@ class POSInvoiceMergeLog(Document): | ||||
| 						found = True | ||||
| 				if not found: | ||||
| 					payments.append(payment) | ||||
| 
 | ||||
| 			rounding_adjustment += doc.rounding_adjustment | ||||
| 			rounded_total += doc.rounded_total | ||||
| 			base_rounding_adjustment += doc.base_rounding_adjustment | ||||
|  | ||||
| @ -12,6 +12,7 @@ from erpnext.accounts.doctype.pos_invoice.test_pos_invoice import create_pos_inv | ||||
| from erpnext.accounts.doctype.pos_invoice_merge_log.pos_invoice_merge_log import ( | ||||
| 	consolidate_pos_invoices, | ||||
| ) | ||||
| from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry | ||||
| 
 | ||||
| 
 | ||||
| class TestPOSInvoiceMergeLog(unittest.TestCase): | ||||
| @ -150,3 +151,132 @@ class TestPOSInvoiceMergeLog(unittest.TestCase): | ||||
| 			frappe.set_user("Administrator") | ||||
| 			frappe.db.sql("delete from `tabPOS Profile`") | ||||
| 			frappe.db.sql("delete from `tabPOS Invoice`") | ||||
| 
 | ||||
| 
 | ||||
| 	def test_consolidation_round_off_error_1(self): | ||||
| 		''' | ||||
| 		Test round off error in consolidated invoice creation if POS Invoice has inclusive tax | ||||
| 		''' | ||||
| 
 | ||||
| 		frappe.db.sql("delete from `tabPOS Invoice`") | ||||
| 
 | ||||
| 		try: | ||||
| 			make_stock_entry( | ||||
| 				to_warehouse="_Test Warehouse - _TC", | ||||
| 				item_code="_Test Item", | ||||
| 				rate=8000, | ||||
| 				qty=10, | ||||
| 			) | ||||
| 
 | ||||
| 			init_user_and_profile() | ||||
| 
 | ||||
| 			inv = create_pos_invoice(qty=3, rate=10000, do_not_save=True) | ||||
| 			inv.append("taxes", { | ||||
| 				"account_head": "_Test Account VAT - _TC", | ||||
| 				"charge_type": "On Net Total", | ||||
| 				"cost_center": "_Test Cost Center - _TC", | ||||
| 				"description": "VAT", | ||||
| 				"doctype": "Sales Taxes and Charges", | ||||
| 				"rate": 7.5, | ||||
| 				"included_in_print_rate": 1 | ||||
| 			}) | ||||
| 			inv.append('payments', { | ||||
| 				'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 30000 | ||||
| 			}) | ||||
| 			inv.insert() | ||||
| 			inv.submit() | ||||
| 
 | ||||
| 			inv2 = create_pos_invoice(qty=3, rate=10000, do_not_save=True) | ||||
| 			inv2.append("taxes", { | ||||
| 				"account_head": "_Test Account VAT - _TC", | ||||
| 				"charge_type": "On Net Total", | ||||
| 				"cost_center": "_Test Cost Center - _TC", | ||||
| 				"description": "VAT", | ||||
| 				"doctype": "Sales Taxes and Charges", | ||||
| 				"rate": 7.5, | ||||
| 				"included_in_print_rate": 1 | ||||
| 			}) | ||||
| 			inv2.append('payments', { | ||||
| 				'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 30000 | ||||
| 			}) | ||||
| 			inv2.insert() | ||||
| 			inv2.submit() | ||||
| 
 | ||||
| 			consolidate_pos_invoices() | ||||
| 
 | ||||
| 			inv.load_from_db() | ||||
| 			consolidated_invoice = frappe.get_doc('Sales Invoice', inv.consolidated_invoice) | ||||
| 			self.assertEqual(consolidated_invoice.outstanding_amount, 0) | ||||
| 			self.assertEqual(consolidated_invoice.status, 'Paid') | ||||
| 
 | ||||
| 		finally: | ||||
| 			frappe.set_user("Administrator") | ||||
| 			frappe.db.sql("delete from `tabPOS Profile`") | ||||
| 			frappe.db.sql("delete from `tabPOS Invoice`") | ||||
| 
 | ||||
| 	def test_consolidation_round_off_error_2(self): | ||||
| 		''' | ||||
| 		Test the same case as above but with an Unpaid POS Invoice | ||||
| 		''' | ||||
| 		frappe.db.sql("delete from `tabPOS Invoice`") | ||||
| 
 | ||||
| 		try: | ||||
| 			make_stock_entry( | ||||
| 				to_warehouse="_Test Warehouse - _TC", | ||||
| 				item_code="_Test Item", | ||||
| 				rate=8000, | ||||
| 				qty=10, | ||||
| 			) | ||||
| 
 | ||||
| 			init_user_and_profile() | ||||
| 
 | ||||
| 			inv = create_pos_invoice(qty=6, rate=10000, do_not_save=True) | ||||
| 			inv.append("taxes", { | ||||
| 				"account_head": "_Test Account VAT - _TC", | ||||
| 				"charge_type": "On Net Total", | ||||
| 				"cost_center": "_Test Cost Center - _TC", | ||||
| 				"description": "VAT", | ||||
| 				"doctype": "Sales Taxes and Charges", | ||||
| 				"rate": 7.5, | ||||
| 				"included_in_print_rate": 1 | ||||
| 			}) | ||||
| 			inv.append('payments', { | ||||
| 				'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 60000 | ||||
| 			}) | ||||
| 			inv.insert() | ||||
| 			inv.submit() | ||||
| 
 | ||||
| 			inv2 = create_pos_invoice(qty=6, rate=10000, do_not_save=True) | ||||
| 			inv2.append("taxes", { | ||||
| 				"account_head": "_Test Account VAT - _TC", | ||||
| 				"charge_type": "On Net Total", | ||||
| 				"cost_center": "_Test Cost Center - _TC", | ||||
| 				"description": "VAT", | ||||
| 				"doctype": "Sales Taxes and Charges", | ||||
| 				"rate": 7.5, | ||||
| 				"included_in_print_rate": 1 | ||||
| 			}) | ||||
| 			inv2.append('payments', { | ||||
| 				'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 60000 | ||||
| 			}) | ||||
| 			inv2.insert() | ||||
| 			inv2.submit() | ||||
| 
 | ||||
| 			inv3 = create_pos_invoice(qty=3, rate=600, do_not_save=True) | ||||
| 			inv3.append('payments', { | ||||
| 				'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 1000 | ||||
| 			}) | ||||
| 			inv3.insert() | ||||
| 			inv3.submit() | ||||
| 
 | ||||
| 			consolidate_pos_invoices() | ||||
| 
 | ||||
| 			inv.load_from_db() | ||||
| 			consolidated_invoice = frappe.get_doc('Sales Invoice', inv.consolidated_invoice) | ||||
| 			self.assertEqual(consolidated_invoice.outstanding_amount, 800) | ||||
| 			self.assertNotEqual(consolidated_invoice.status, 'Paid') | ||||
| 
 | ||||
| 		finally: | ||||
| 			frappe.set_user("Administrator") | ||||
| 			frappe.db.sql("delete from `tabPOS Profile`") | ||||
| 			frappe.db.sql("delete from `tabPOS Invoice`") | ||||
|  | ||||
| @ -249,13 +249,17 @@ def get_pricing_rule_for_item(args, price_list_rate=0, doc=None, for_validate=Fa | ||||
| 		"free_item_data": [], | ||||
| 		"parent": args.parent, | ||||
| 		"parenttype": args.parenttype, | ||||
| 		"child_docname": args.get('child_docname') | ||||
| 		"child_docname": args.get('child_docname'), | ||||
| 	}) | ||||
| 
 | ||||
| 	if args.ignore_pricing_rule or not args.item_code: | ||||
| 		if frappe.db.exists(args.doctype, args.name) and args.get("pricing_rules"): | ||||
| 			item_details = remove_pricing_rule_for_item(args.get("pricing_rules"), | ||||
| 				item_details, args.get('item_code')) | ||||
| 			item_details = remove_pricing_rule_for_item( | ||||
| 				args.get("pricing_rules"), | ||||
| 				item_details, | ||||
| 				item_code=args.get("item_code"), | ||||
| 				rate=args.get("price_list_rate"), | ||||
| 			) | ||||
| 		return item_details | ||||
| 
 | ||||
| 	update_args_for_pricing_rule(args) | ||||
| @ -308,8 +312,12 @@ def get_pricing_rule_for_item(args, price_list_rate=0, doc=None, for_validate=Fa | ||||
| 		if not doc: return item_details | ||||
| 
 | ||||
| 	elif args.get("pricing_rules"): | ||||
| 		item_details = remove_pricing_rule_for_item(args.get("pricing_rules"), | ||||
| 			item_details, args.get('item_code')) | ||||
| 		item_details = remove_pricing_rule_for_item( | ||||
| 			args.get("pricing_rules"), | ||||
| 			item_details, | ||||
| 			item_code=args.get("item_code"), | ||||
| 			rate=args.get("price_list_rate"), | ||||
| 		) | ||||
| 
 | ||||
| 	return item_details | ||||
| 
 | ||||
| @ -390,7 +398,7 @@ def apply_price_discount_rule(pricing_rule, item_details, args): | ||||
| 			item_details[field] += (pricing_rule.get(field, 0) | ||||
| 				if pricing_rule else args.get(field, 0)) | ||||
| 
 | ||||
| def remove_pricing_rule_for_item(pricing_rules, item_details, item_code=None): | ||||
| def remove_pricing_rule_for_item(pricing_rules, item_details, item_code=None, rate=None): | ||||
| 	from erpnext.accounts.doctype.pricing_rule.utils import ( | ||||
| 		get_applied_pricing_rules, | ||||
| 		get_pricing_rule_items, | ||||
| @ -403,6 +411,7 @@ def remove_pricing_rule_for_item(pricing_rules, item_details, item_code=None): | ||||
| 			if pricing_rule.rate_or_discount == 'Discount Percentage': | ||||
| 				item_details.discount_percentage = 0.0 | ||||
| 				item_details.discount_amount = 0.0 | ||||
| 				item_details.rate = rate or 0.0 | ||||
| 
 | ||||
| 			if pricing_rule.rate_or_discount == 'Discount Amount': | ||||
| 				item_details.discount_amount = 0.0 | ||||
| @ -421,6 +430,7 @@ def remove_pricing_rule_for_item(pricing_rules, item_details, item_code=None): | ||||
| 			item_details.applied_on_items = ','.join(items) | ||||
| 
 | ||||
| 	item_details.pricing_rules = '' | ||||
| 	item_details.pricing_rule_removed = True | ||||
| 
 | ||||
| 	return item_details | ||||
| 
 | ||||
| @ -432,9 +442,12 @@ def remove_pricing_rules(item_list): | ||||
| 	out = [] | ||||
| 	for item in item_list: | ||||
| 		item = frappe._dict(item) | ||||
| 		if item.get('pricing_rules'): | ||||
| 			out.append(remove_pricing_rule_for_item(item.get("pricing_rules"), | ||||
| 				item, item.item_code)) | ||||
| 		if item.get("pricing_rules"): | ||||
| 			out.append( | ||||
| 				remove_pricing_rule_for_item( | ||||
| 					item.get("pricing_rules"), item, item.item_code, item.get("price_list_rate") | ||||
| 				) | ||||
| 			) | ||||
| 
 | ||||
| 	return out | ||||
| 
 | ||||
|  | ||||
| @ -628,6 +628,46 @@ class TestPricingRule(unittest.TestCase): | ||||
| 		for doc in [si, si1]: | ||||
| 			doc.delete() | ||||
| 
 | ||||
| 	def test_remove_pricing_rule(self): | ||||
| 		item = make_item("Water Flask") | ||||
| 		make_item_price("Water Flask", "_Test Price List", 100) | ||||
| 
 | ||||
| 		pricing_rule_record = { | ||||
| 			"doctype": "Pricing Rule", | ||||
| 			"title": "_Test Water Flask Rule", | ||||
| 			"apply_on": "Item Code", | ||||
| 			"price_or_product_discount": "Price", | ||||
| 			"items": [{ | ||||
| 				"item_code": "Water Flask", | ||||
| 			}], | ||||
| 			"selling": 1, | ||||
| 			"currency": "INR", | ||||
| 			"rate_or_discount": "Discount Percentage", | ||||
| 			"discount_percentage": 20, | ||||
| 			"company": "_Test Company" | ||||
| 		} | ||||
| 		rule = frappe.get_doc(pricing_rule_record) | ||||
| 		rule.insert() | ||||
| 
 | ||||
| 		si = create_sales_invoice(do_not_save=True, item_code="Water Flask") | ||||
| 		si.selling_price_list = "_Test Price List" | ||||
| 		si.save() | ||||
| 
 | ||||
| 		self.assertEqual(si.items[0].price_list_rate, 100) | ||||
| 		self.assertEqual(si.items[0].discount_percentage, 20) | ||||
| 		self.assertEqual(si.items[0].rate, 80) | ||||
| 
 | ||||
| 		si.ignore_pricing_rule = 1 | ||||
| 		si.save() | ||||
| 
 | ||||
| 		self.assertEqual(si.items[0].discount_percentage, 0) | ||||
| 		self.assertEqual(si.items[0].rate, 100) | ||||
| 
 | ||||
| 		si.delete() | ||||
| 		rule.delete() | ||||
| 		frappe.get_doc("Item Price", {"item_code": "Water Flask"}).delete() | ||||
| 		item.delete() | ||||
| 
 | ||||
| 	def test_multiple_pricing_rules_with_min_qty(self): | ||||
| 		make_pricing_rule(discount_percentage=20, selling=1, priority=1, min_qty=4, | ||||
| 			apply_multiple_pricing_rules=1, title="_Test Pricing Rule with Min Qty - 1") | ||||
| @ -648,6 +688,7 @@ class TestPricingRule(unittest.TestCase): | ||||
| 		frappe.delete_doc_if_exists("Pricing Rule", "_Test Pricing Rule with Min Qty - 1") | ||||
| 		frappe.delete_doc_if_exists("Pricing Rule", "_Test Pricing Rule with Min Qty - 2") | ||||
| 
 | ||||
| 
 | ||||
| test_dependencies = ["Campaign"] | ||||
| 
 | ||||
| def make_pricing_rule(**args): | ||||
|  | ||||
| @ -285,7 +285,7 @@ class SalesInvoice(SellingController): | ||||
| 				filters={ invoice_or_credit_note: self.name }, | ||||
| 				pluck="pos_closing_entry" | ||||
| 			) | ||||
| 			if pos_closing_entry: | ||||
| 			if pos_closing_entry and pos_closing_entry[0]: | ||||
| 				msg = _("To cancel a {} you need to cancel the POS Closing Entry {}.").format( | ||||
| 					frappe.bold("Consolidated Sales Invoice"), | ||||
| 					get_link_to_form("POS Closing Entry", pos_closing_entry[0]) | ||||
|  | ||||
| @ -319,13 +319,18 @@ def make_reverse_gl_entries(gl_entries=None, voucher_type=None, voucher_no=None, | ||||
| 	""" | ||||
| 
 | ||||
| 	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 @@ def make_reverse_gl_entries(gl_entries=None, voucher_type=None, voucher_no=None, | ||||
| 		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): | ||||
|  | ||||
| @ -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" | ||||
| } | ||||
| @ -354,9 +354,6 @@ def accumulate_values_into_parents(accounts, accounts_by_name, companies): | ||||
| 		if d.parent_account: | ||||
| 			account = d.parent_account_name | ||||
| 
 | ||||
| 			# if not accounts_by_name.get(account): | ||||
| 			# 	continue | ||||
| 
 | ||||
| 			for company in companies: | ||||
| 				accounts_by_name[account][company] = \ | ||||
| 					accounts_by_name[account].get(company, 0.0) + d.get(company, 0.0) | ||||
| @ -367,7 +364,7 @@ def accumulate_values_into_parents(accounts, accounts_by_name, companies): | ||||
| 				accounts_by_name[account].get("opening_balance", 0.0) + d.get("opening_balance", 0.0) | ||||
| 
 | ||||
| def get_account_heads(root_type, companies, filters): | ||||
| 	accounts = get_accounts(root_type, filters) | ||||
| 	accounts = get_accounts(root_type, companies) | ||||
| 
 | ||||
| 	if not accounts: | ||||
| 		return None, None, None | ||||
| @ -396,7 +393,7 @@ def update_parent_account_names(accounts): | ||||
| 
 | ||||
| 	for account in accounts: | ||||
| 		if account.parent_account: | ||||
| 			account["parent_account_name"] = name_to_account_map[account.parent_account] | ||||
| 			account["parent_account_name"] = name_to_account_map.get(account.parent_account) | ||||
| 
 | ||||
| 	return accounts | ||||
| 
 | ||||
| @ -419,12 +416,19 @@ def get_subsidiary_companies(company): | ||||
| 	return frappe.db.sql_list("""select name from `tabCompany` | ||||
| 		where lft >= {0} and rgt <= {1} order by lft, rgt""".format(lft, rgt)) | ||||
| 
 | ||||
| def get_accounts(root_type, filters): | ||||
| 	return frappe.db.sql(""" select name, is_group, company, | ||||
| 			parent_account, lft, rgt, root_type, report_type, account_name, account_number | ||||
| 		from | ||||
| 			`tabAccount` where company = %s and root_type = %s | ||||
| 		""" , (filters.get('company'), root_type), as_dict=1) | ||||
| def get_accounts(root_type, companies): | ||||
| 	accounts = [] | ||||
| 	added_accounts = [] | ||||
| 
 | ||||
| 	for company in companies: | ||||
| 		for account in frappe.get_all("Account", fields=["name", "is_group", "company", | ||||
| 			"parent_account", "lft", "rgt", "root_type", "report_type", "account_name", "account_number"], | ||||
| 			filters={"company": company, "root_type": root_type}): | ||||
| 			if account.account_name not in added_accounts: | ||||
| 				accounts.append(account) | ||||
| 				added_accounts.append(account.account_name) | ||||
| 
 | ||||
| 	return accounts | ||||
| 
 | ||||
| def prepare_data(accounts, start_date, end_date, balance_must_be, companies, company_currency, filters): | ||||
| 	data = [] | ||||
|  | ||||
| @ -44,6 +44,11 @@ frappe.query_reports["Gross Profit"] = { | ||||
| 	"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")) { | ||||
|  | ||||
| @ -23,7 +23,7 @@ def validate_filters(filters): | ||||
| 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 @@ def get_supplier_pan_map(): | ||||
| 
 | ||||
| 	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 @@ def get_gle_map(filters, documents): | ||||
| 	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 @@ def get_tds_docs(filters): | ||||
| 	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": | ||||
|  | ||||
| @ -682,17 +682,18 @@ class TestPurchaseOrder(unittest.TestCase): | ||||
| 
 | ||||
| 		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", | ||||
|  | ||||
| @ -49,7 +49,7 @@ valid_scorecard = [ | ||||
| 				"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 @@ valid_scorecard = [ | ||||
| 				"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 @@ valid_scorecard = [ | ||||
| 				"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 @@ valid_scorecard = [ | ||||
| 				"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, | ||||
|  | ||||
| @ -407,6 +407,22 @@ class AccountsController(TransactionBase): | ||||
| 								if item_qty != len(get_serial_nos(item.get('serial_no'))): | ||||
| 									item.set(fieldname, value) | ||||
| 
 | ||||
| 							elif ( | ||||
| 								ret.get("pricing_rule_removed") | ||||
| 								and value is not None | ||||
| 								and fieldname | ||||
| 								in [ | ||||
| 									"discount_percentage", | ||||
| 									"discount_amount", | ||||
| 									"rate", | ||||
| 									"margin_rate_or_amount", | ||||
| 									"margin_type", | ||||
| 									"remove_free_item", | ||||
| 								] | ||||
| 							): | ||||
| 								# reset pricing rule fields if pricing_rule_removed | ||||
| 								item.set(fieldname, value) | ||||
| 
 | ||||
| 					if self.doctype in ["Purchase Invoice", "Sales Invoice"] and item.meta.get_field('is_fixed_asset'): | ||||
| 						item.set('is_fixed_asset', ret.get('is_fixed_asset', 0)) | ||||
| 
 | ||||
| @ -1318,6 +1334,9 @@ class AccountsController(TransactionBase): | ||||
| 				payment_schedule['discount_type'] = schedule.discount_type | ||||
| 				payment_schedule['discount'] = schedule.discount | ||||
| 
 | ||||
| 			if not schedule.invoice_portion: | ||||
| 				payment_schedule['payment_amount'] = schedule.payment_amount | ||||
| 
 | ||||
| 			self.append("payment_schedule", payment_schedule) | ||||
| 
 | ||||
| 	def set_due_date(self): | ||||
| @ -1936,7 +1955,8 @@ def update_bin_on_delete(row, doctype): | ||||
| 
 | ||||
| 		qty_dict["ordered_qty"] = get_ordered_qty(row.item_code, row.warehouse) | ||||
| 
 | ||||
| 	update_bin_qty(row.item_code, row.warehouse, qty_dict) | ||||
| 	if row.warehouse: | ||||
| 		update_bin_qty(row.item_code, row.warehouse, qty_dict) | ||||
| 
 | ||||
| def validate_and_delete_children(parent, data): | ||||
| 	deleted_children = [] | ||||
|  | ||||
| @ -106,6 +106,9 @@ class calculate_taxes_and_totals(object): | ||||
| 		self.doc.conversion_rate = flt(self.doc.conversion_rate) | ||||
| 
 | ||||
| 	def calculate_item_values(self): | ||||
| 		if self.doc.get('is_consolidated'): | ||||
| 			return | ||||
| 
 | ||||
| 		if not self.discount_amount_applied: | ||||
| 			for item in self.doc.get("items"): | ||||
| 				self.doc.round_floats_in(item) | ||||
| @ -647,12 +650,12 @@ class calculate_taxes_and_totals(object): | ||||
| 	def calculate_change_amount(self): | ||||
| 		self.doc.change_amount = 0.0 | ||||
| 		self.doc.base_change_amount = 0.0 | ||||
| 		grand_total = self.doc.rounded_total or self.doc.grand_total | ||||
| 		base_grand_total = self.doc.base_rounded_total or self.doc.base_grand_total | ||||
| 
 | ||||
| 		if self.doc.doctype == "Sales Invoice" \ | ||||
| 			and self.doc.paid_amount > self.doc.grand_total and not self.doc.is_return \ | ||||
| 			and self.doc.paid_amount > grand_total and not self.doc.is_return \ | ||||
| 			and any(d.type == "Cash" for d in self.doc.payments): | ||||
| 			grand_total = self.doc.rounded_total or self.doc.grand_total | ||||
| 			base_grand_total = self.doc.base_rounded_total or self.doc.base_grand_total | ||||
| 
 | ||||
| 			self.doc.change_amount = flt(self.doc.paid_amount - grand_total + | ||||
| 				self.doc.write_off_amount, self.doc.precision("change_amount")) | ||||
|  | ||||
| @ -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 | ||||
| } | ||||
| } | ||||
|  | ||||
| @ -66,26 +66,24 @@ class ItemVariantsCacheManager: | ||||
| 			) | ||||
| 		] | ||||
| 
 | ||||
| 		# 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 | ||||
| 		) | ||||
| 
 | ||||
| 		disabled_items = set( | ||||
| 			[i.name for i in frappe.db.get_all('Item', {'disabled': 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) | ||||
| 		) | ||||
| 		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 build_cache(item_code): | ||||
| 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' | ||||
| 	) | ||||
|  | ||||
| @ -104,6 +104,8 @@ class TestVariantSelector(ERPNextTestCase): | ||||
| 		}) | ||||
| 
 | ||||
| 		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"} | ||||
|  | ||||
| @ -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 | ||||
| @ -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) | ||||
| @ -1,2 +0,0 @@ | ||||
| // Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors
 | ||||
| // For license information, please see license.txt
 | ||||
| @ -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 | ||||
| } | ||||
| @ -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) | ||||
| @ -1,8 +0,0 @@ | ||||
| # Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors | ||||
| # See license.txt | ||||
| 
 | ||||
| import unittest | ||||
| 
 | ||||
| 
 | ||||
| class TestAmazonMWSSettings(unittest.TestCase): | ||||
| 	pass | ||||
| @ -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}) | ||||
| @ -13,7 +13,7 @@ from frappe.utils import call_hook_method, cint, flt, get_url | ||||
| 
 | ||||
| 
 | ||||
| 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 @@ class GoCardlessSettings(Document): | ||||
| 
 | ||||
| 	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))) | ||||
|  | ||||
| @ -29,17 +29,6 @@ | ||||
|    "onboard": 0, | ||||
|    "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, | ||||
|  | ||||
| @ -333,7 +333,6 @@ scheduler_events = { | ||||
| 	"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", | ||||
|  | ||||
| @ -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 | ||||
| } | ||||
| } | ||||
|  | ||||
| @ -546,7 +546,7 @@ class TestLeaveApplication(unittest.TestCase): | ||||
| 		from erpnext.hr.utils import allocate_earned_leaves | ||||
| 		i = 0 | ||||
| 		while(i<14): | ||||
| 			allocate_earned_leaves() | ||||
| 			allocate_earned_leaves(ignore_duplicates=True) | ||||
| 			i += 1 | ||||
| 		self.assertEqual(get_leave_balance_on(employee.name, leave_type, nowdate()), 6) | ||||
| 
 | ||||
| @ -554,7 +554,7 @@ class TestLeaveApplication(unittest.TestCase): | ||||
| 		frappe.db.set_value('Leave Type', leave_type, 'max_leaves_allowed', 0) | ||||
| 		i = 0 | ||||
| 		while(i<6): | ||||
| 			allocate_earned_leaves() | ||||
| 			allocate_earned_leaves(ignore_duplicates=True) | ||||
| 			i += 1 | ||||
| 		self.assertEqual(get_leave_balance_on(employee.name, leave_type, nowdate()), 9) | ||||
| 
 | ||||
|  | ||||
| @ -8,11 +8,10 @@ from math import ceil | ||||
| import frappe | ||||
| from frappe import _, bold | ||||
| from frappe.model.document import Document | ||||
| from frappe.utils import date_diff, flt, formatdate, get_datetime, 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 @@ class LeavePolicyAssignment(Document): | ||||
| 			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,21 +109,24 @@ class LeavePolicyAssignment(Document): | ||||
| 	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().month | ||||
| 		current_year = 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 | ||||
| 		if current_year == from_date_year and current_month > from_date_month: | ||||
| 			months_passed = current_month - from_date_month | ||||
| 		elif current_year > from_date_year: | ||||
| 			months_passed = (12 - from_date_month) + current_month | ||||
| 		based_on_doj = leave_type_details.get(leave_type).based_on_date_of_joining | ||||
| 
 | ||||
| 		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_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, | ||||
| @ -134,6 +138,23 @@ class LeavePolicyAssignment(Document): | ||||
| 		return new_leaves_allocated | ||||
| 
 | ||||
| 
 | ||||
| def add_current_month_if_applicable(months_passed, date_of_joining, based_on_doj): | ||||
| 	date = getdate(frappe.flags.current_date) or getdate() | ||||
| 
 | ||||
| 	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 | ||||
| 
 | ||||
| 
 | ||||
| @frappe.whitelist() | ||||
| def create_assignment_for_multiple_employees(employees, data): | ||||
| 
 | ||||
| @ -168,7 +189,7 @@ def create_assignment_for_multiple_employees(employees, data): | ||||
| 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) | ||||
|  | ||||
| @ -4,7 +4,7 @@ | ||||
| import unittest | ||||
| 
 | ||||
| import frappe | ||||
| from frappe.utils import add_months, get_first_day, getdate | ||||
| from frappe.utils import add_months, get_first_day, get_last_day, getdate | ||||
| 
 | ||||
| from erpnext.hr.doctype.leave_application.test_leave_application import ( | ||||
| 	get_employee, | ||||
| @ -20,36 +20,31 @@ test_dependencies = ["Employee"] | ||||
| 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 @@ class TestLeavePolicyAssignment(unittest.TestCase): | ||||
| 
 | ||||
| 	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,11 +103,200 @@ class TestLeavePolicyAssignment(unittest.TestCase): | ||||
| 		}, "total_leaves_allocated") | ||||
| 		self.assertEqual(leaves_allocated, 0) | ||||
| 
 | ||||
| 	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()) | ||||
| 		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, 1) | ||||
| 
 | ||||
| 	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 | ||||
| 		frappe.flags.current_date = get_last_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 | ||||
| 		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_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 | ||||
| 
 | ||||
| 		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=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, | ||||
| 			"leave_period": leave_period.name, | ||||
| 			"carry_forward": 1 | ||||
| 		} | ||||
| 		# carry forwarded leaves = 5, 3 leaves allocated for passed months | ||||
| 		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] | ||||
| 		}, ["total_leaves_allocated", "new_leaves_allocated", "unused_leaves", "name"], as_dict=True) | ||||
| 		self.assertEqual(details.new_leaves_allocated, 2) | ||||
| 		self.assertEqual(details.unused_leaves, 5) | ||||
| 		self.assertEqual(details.total_leaves_allocated, 7) | ||||
| 
 | ||||
| 		# 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 is_earned_leave_already_allocated | ||||
| 		frappe.flags.current_date = get_last_day(getdate()) | ||||
| 
 | ||||
| 		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( | ||||
| @ -138,13 +305,15 @@ def create_earned_leave_type(leave_type): | ||||
| 		is_earned_leave=1, | ||||
| 		earned_leave_frequency="Monthly", | ||||
| 		rounding=0.5, | ||||
| 		max_leaves_allowed=6 | ||||
| 		is_carry_forward=1, | ||||
| 		based_on_date_of_joining=based_on_doj | ||||
| 	)).insert() | ||||
| 
 | ||||
| 
 | ||||
| def create_leave_period(name): | ||||
| def create_leave_period(name, start_date=None): | ||||
| 	frappe.delete_doc_if_exists("Leave Period", name, force=1) | ||||
| 	start_date = get_first_day(getdate()) | ||||
| 	if not start_date: | ||||
| 		start_date = get_first_day(getdate()) | ||||
| 
 | ||||
| 	return frappe.get_doc(dict( | ||||
| 		name=name, | ||||
| @ -153,4 +322,17 @@ def create_leave_period(name): | ||||
| 		to_date=add_months(start_date, 12), | ||||
| 		company="_Test Company", | ||||
| 		is_active=1 | ||||
| 	)).insert() | ||||
| 	)).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 | ||||
| @ -237,7 +237,7 @@ def generate_leave_encashment(): | ||||
| 
 | ||||
| 		create_leave_encashment(leave_allocation=leave_allocation) | ||||
| 
 | ||||
| def allocate_earned_leaves(): | ||||
| def allocate_earned_leaves(ignore_duplicates=False): | ||||
| 	'''Allocate earned leaves to Employees''' | ||||
| 	e_leave_types = get_earned_leaves() | ||||
| 	today = getdate() | ||||
| @ -261,13 +261,13 @@ def allocate_earned_leaves(): | ||||
| 
 | ||||
| 			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): | ||||
| 				update_previous_leave_allocation(allocation, annual_allocation, e_leave_type) | ||||
| 			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): | ||||
| def update_previous_leave_allocation(allocation, annual_allocation, e_leave_type, ignore_duplicates=False): | ||||
| 		earned_leaves = get_monthly_earned_leave(annual_allocation, e_leave_type.earned_leave_frequency, e_leave_type.rounding) | ||||
| 
 | ||||
| 		allocation = frappe.get_doc('Leave Allocation', allocation.name) | ||||
| @ -277,9 +277,12 @@ def update_previous_leave_allocation(allocation, annual_allocation, e_leave_type | ||||
| 			new_allocation = e_leave_type.max_leaves_allowed | ||||
| 
 | ||||
| 		if new_allocation != allocation.total_leaves_allocated: | ||||
| 			allocation.db_set("total_leaves_allocated", new_allocation, update_modified=False) | ||||
| 			today_date = today() | ||||
| 			create_additional_leave_ledger_entry(allocation, earned_leaves, today_date) | ||||
| 
 | ||||
| 			if ignore_duplicates or not is_earned_leave_already_allocated(allocation, annual_allocation): | ||||
| 				allocation.db_set("total_leaves_allocated", new_allocation, update_modified=False) | ||||
| 				create_additional_leave_ledger_entry(allocation, earned_leaves, today_date) | ||||
| 
 | ||||
| 
 | ||||
| def get_monthly_earned_leave(annual_leaves, frequency, rounding): | ||||
| 	earned_leaves = 0.0 | ||||
| @ -297,6 +300,28 @@ def get_monthly_earned_leave(annual_leaves, frequency, rounding): | ||||
| 	return earned_leaves | ||||
| 
 | ||||
| 
 | ||||
| def is_earned_leave_already_allocated(allocation, annual_allocation): | ||||
| 	from erpnext.hr.doctype.leave_policy_assignment.leave_policy_assignment import ( | ||||
| 		get_leave_type_details, | ||||
| 	) | ||||
| 
 | ||||
| 	leave_type_details = get_leave_type_details() | ||||
| 	date_of_joining = frappe.db.get_value("Employee", allocation.employee, "date_of_joining") | ||||
| 
 | ||||
| 	assignment = frappe.get_doc("Leave Policy Assignment", allocation.leave_policy_assignment) | ||||
| 	leaves_for_passed_months = assignment.get_leaves_for_passed_months(allocation.leave_type, | ||||
| 		annual_allocation, leave_type_details, date_of_joining) | ||||
| 
 | ||||
| 	# exclude carry-forwarded leaves while checking for leave allocation for passed months | ||||
| 	num_allocations = allocation.total_leaves_allocated | ||||
| 	if allocation.unused_leaves: | ||||
| 		num_allocations -= allocation.unused_leaves | ||||
| 
 | ||||
| 	if num_allocations >= leaves_for_passed_months: | ||||
| 		return True | ||||
| 	return False | ||||
| 
 | ||||
| 
 | ||||
| def get_leave_allocations(date, leave_type): | ||||
| 	return frappe.db.sql("""select name, employee, from_date, to_date, leave_policy_assignment, leave_policy | ||||
| 		from `tabLeave Allocation` | ||||
| @ -318,7 +343,7 @@ def create_additional_leave_ledger_entry(allocation, leaves, date): | ||||
| 	allocation.unused_leaves = 0 | ||||
| 	allocation.create_leave_ledger_entry() | ||||
| 
 | ||||
| def check_effective_date(from_date, to_date, frequency, based_on_date_of_joining_date): | ||||
| def check_effective_date(from_date, to_date, frequency, based_on_date_of_joining): | ||||
| 	import calendar | ||||
| 
 | ||||
| 	from dateutil import relativedelta | ||||
| @ -329,7 +354,7 @@ def check_effective_date(from_date, to_date, frequency, based_on_date_of_joining | ||||
| 	#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: | ||||
|  | ||||
| @ -345,7 +345,7 @@ class LoanRepayment(AccountsController): | ||||
| 			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 @@ class LoanRepayment(AccountsController): | ||||
| 				"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 '' | ||||
| 			}) | ||||
| 		) | ||||
| 
 | ||||
|  | ||||
| @ -28,9 +28,24 @@ from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults | ||||
| 
 | ||||
| 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 @@ class ProductionPlan(Document): | ||||
| 						'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 @@ class ProductionPlan(Document): | ||||
| 
 | ||||
| 		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() | ||||
| @ -308,7 +319,7 @@ class ProductionPlan(Document): | ||||
| 
 | ||||
| 		if self.total_produced_qty > 0: | ||||
| 			self.status = "In Process" | ||||
| 			if self.check_have_work_orders_completed(): | ||||
| 			if self.all_items_completed(): | ||||
| 				self.status = "Completed" | ||||
| 
 | ||||
| 		if self.status != 'Completed': | ||||
| @ -341,6 +352,7 @@ class ProductionPlan(Document): | ||||
| 
 | ||||
| 	def get_production_items(self): | ||||
| 		item_dict = {} | ||||
| 
 | ||||
| 		for d in self.po_items: | ||||
| 			item_details = { | ||||
| 				"production_item"		: d.item_code, | ||||
| @ -357,12 +369,12 @@ class ProductionPlan(Document): | ||||
| 				"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 @@ class ProductionPlan(Document): | ||||
| 
 | ||||
| 	@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 @@ class ProductionPlan(Document): | ||||
| 			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 @@ class ProductionPlan(Document): | ||||
| 
 | ||||
| 				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 @@ class ProductionPlan(Document): | ||||
| 			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 @@ class ProductionPlan(Document): | ||||
| 			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: | ||||
| @ -574,21 +591,32 @@ class ProductionPlan(Document): | ||||
| 
 | ||||
| 			self.append("sub_assembly_items", data) | ||||
| 
 | ||||
| 	def check_have_work_orders_completed(self): | ||||
| 		wo_status = frappe.db.get_list( | ||||
| 	def all_items_completed(self): | ||||
| 		all_items_produced = all(flt(d.planned_qty) - flt(d.produced_qty) < 0.000001 | ||||
| 									for d in self.po_items) | ||||
| 		if not all_items_produced: | ||||
| 			return False | ||||
| 
 | ||||
| 		wo_status = frappe.get_all( | ||||
| 			"Work Order", | ||||
| 			filters={"production_plan": self.name}, | ||||
| 			filters={ | ||||
| 				"production_plan": self.name, | ||||
| 				"status": ("not in", ["Closed", "Stopped"]), | ||||
| 				"docstatus": ("<", 2), | ||||
| 			}, | ||||
| 			fields="status", | ||||
| 			pluck="status" | ||||
| 			pluck="status", | ||||
| 		) | ||||
| 		return all(s == "Completed" for s in wo_status) | ||||
| 		all_work_orders_completed = all(s == "Completed" for s in wo_status) | ||||
| 		return all_work_orders_completed | ||||
| 
 | ||||
| @frappe.whitelist() | ||||
| def download_raw_materials(doc, warehouses=None): | ||||
| 	if isinstance(doc, str): | ||||
| 		doc = frappe._dict(json.loads(doc)) | ||||
| 
 | ||||
| 	item_list = [['Item Code', 'Description', 'Stock UOM', 'Warehouse', 'Required Qty as per BOM', | ||||
| 	item_list = [['Item Code', 'Item Name', 'Description', | ||||
| 		'Stock UOM', 'Warehouse', 'Required Qty as per BOM', | ||||
| 		'Projected Qty', 'Available Qty In Hand', 'Ordered Qty', 'Planned Qty', | ||||
| 		'Reserved Qty for Production', 'Safety Stock', 'Required Qty']] | ||||
| 
 | ||||
| @ -597,7 +625,8 @@ def download_raw_materials(doc, warehouses=None): | ||||
| 	items = get_items_for_material_requests(doc, warehouses=warehouses, get_parent_warehouse_data=True) | ||||
| 
 | ||||
| 	for d in items: | ||||
| 		item_list.append([d.get('item_code'), d.get('description'), d.get('stock_uom'), d.get('warehouse'), | ||||
| 		item_list.append([d.get('item_code'), d.get('item_name'), | ||||
| 			d.get('description'), d.get('stock_uom'), d.get('warehouse'), | ||||
| 			d.get('required_bom_qty'), d.get('projected_qty'), d.get('actual_qty'), d.get('ordered_qty'), | ||||
| 			d.get('planned_qty'), d.get('reserved_qty_for_production'), d.get('safety_stock'), d.get('quantity')]) | ||||
| 
 | ||||
| @ -1023,3 +1052,8 @@ def get_sub_assembly_items(bom_no, bom_data, to_produce_qty, indent=0): | ||||
| 
 | ||||
| 			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) | ||||
|  | ||||
| @ -11,6 +11,7 @@ from erpnext.manufacturing.doctype.production_plan.production_plan import ( | ||||
| ) | ||||
| 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 @@ class TestProductionPlan(ERPNextTestCase): | ||||
| 			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 @@ class TestProductionPlan(ERPNextTestCase): | ||||
| 		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 @@ class TestProductionPlan(ERPNextTestCase): | ||||
| 		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 @@ class TestProductionPlan(ERPNextTestCase): | ||||
| 		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 @@ class TestProductionPlan(ERPNextTestCase): | ||||
| 		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 @@ class TestProductionPlan(ERPNextTestCase): | ||||
| 			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 @@ class TestProductionPlan(ERPNextTestCase): | ||||
| 		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 @@ class TestProductionPlan(ERPNextTestCase): | ||||
| 				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 @@ class TestProductionPlan(ERPNextTestCase): | ||||
| 		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,16 +402,13 @@ class TestProductionPlan(ERPNextTestCase): | ||||
| 		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_" | ||||
| 		boms = { | ||||
| 			"Assembly": { | ||||
| 				"SubAssembly1": {"ChildPart1": {}, "ChildPart2": {},}, | ||||
| 				"SubAssembly2": {"ChildPart3": {}}, | ||||
| 				"SubAssembly3": {"SubSubAssy1": {"ChildPart4": {}}}, | ||||
| 				"ChildPart5": {}, | ||||
| 				"ChildPart6": {}, | ||||
| 				"SubAssembly4": {"SubSubAssy2": {"ChildPart7": {}}}, | ||||
| 			}, | ||||
| @ -386,6 +437,7 @@ class TestProductionPlan(ERPNextTestCase): | ||||
| 		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 +493,121 @@ class TestProductionPlan(ERPNextTestCase): | ||||
| 		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 test_qty_based_status(self): | ||||
| 		pp = frappe.new_doc("Production Plan") | ||||
| 		pp.po_items = [ | ||||
| 			frappe._dict(planned_qty=5, produce_qty=4) | ||||
| 		] | ||||
| 		self.assertFalse(pp.all_items_completed()) | ||||
| 
 | ||||
| 		pp.po_items = [ | ||||
| 			frappe._dict(planned_qty=5, produce_qty=10), | ||||
| 			frappe._dict(planned_qty=5, produce_qty=4) | ||||
| 		] | ||||
| 		self.assertFalse(pp.all_items_completed()) | ||||
| 
 | ||||
| 
 | ||||
| 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 +615,35 @@ def create_production_plan(**args): | ||||
| 		'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() | ||||
|  | ||||
| @ -201,6 +201,21 @@ class TestWorkOrder(ERPNextTestCase): | ||||
| 		self.assertEqual(cint(bin1_on_end_production.reserved_qty_for_production), | ||||
| 			cint(bin1_on_start_production.reserved_qty_for_production)) | ||||
| 
 | ||||
| 	def test_reserved_qty_for_production_closed(self): | ||||
| 
 | ||||
| 		wo1 = make_wo_order_test_record(item="_Test FG Item", qty=2, | ||||
| 			source_warehouse=self.warehouse) | ||||
| 		item = wo1.required_items[0].item_code | ||||
| 		bin_before = get_bin(item, self.warehouse) | ||||
| 		bin_before.update_reserved_qty_for_production() | ||||
| 
 | ||||
| 		make_wo_order_test_record(item="_Test FG Item", qty=2, | ||||
| 			source_warehouse=self.warehouse) | ||||
| 		close_work_order(wo1.name, "Closed") | ||||
| 
 | ||||
| 		bin_after = get_bin(item, self.warehouse) | ||||
| 		self.assertEqual(bin_before.reserved_qty_for_production, bin_after.reserved_qty_for_production) | ||||
| 
 | ||||
| 	def test_backflush_qty_for_overpduction_manufacture(self): | ||||
| 		cancel_stock_entry = [] | ||||
| 		allow_overproduction("overproduction_percentage_for_work_order", 30) | ||||
|  | ||||
| @ -8,6 +8,8 @@ from dateutil.relativedelta import relativedelta | ||||
| from frappe import _ | ||||
| from frappe.model.document import Document | ||||
| from frappe.model.mapper import get_mapped_doc | ||||
| from frappe.query_builder import Case | ||||
| from frappe.query_builder.functions import Sum | ||||
| from frappe.utils import ( | ||||
| 	cint, | ||||
| 	date_diff, | ||||
| @ -74,7 +76,6 @@ class WorkOrder(Document): | ||||
| 
 | ||||
| 		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() | ||||
| @ -271,7 +272,7 @@ class WorkOrder(Document): | ||||
| 
 | ||||
| 			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() | ||||
| @ -544,7 +545,7 @@ class WorkOrder(Document): | ||||
| 				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): | ||||
| @ -625,7 +626,7 @@ class WorkOrder(Document): | ||||
| 			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: | ||||
| @ -1175,3 +1176,27 @@ def create_pick_list(source_name, target_doc=None, for_qty=None): | ||||
| 	doc.set_item_locations() | ||||
| 
 | ||||
| 	return doc | ||||
| 
 | ||||
| def get_reserved_qty_for_production(item_code: str, warehouse: str) -> float: | ||||
| 	"""Get total reserved quantity for any item in specified warehouse""" | ||||
| 	wo = frappe.qb.DocType("Work Order") | ||||
| 	wo_item = frappe.qb.DocType("Work Order Item") | ||||
| 
 | ||||
| 	return ( | ||||
| 	frappe.qb | ||||
| 		.from_(wo) | ||||
| 		.from_(wo_item) | ||||
| 		.select(Sum(Case() | ||||
| 				.when(wo.skip_transfer == 0, wo_item.required_qty - wo_item.transferred_qty) | ||||
| 				.else_(wo_item.required_qty - wo_item.consumed_qty)) | ||||
| 			) | ||||
| 		.where( | ||||
| 			(wo_item.item_code == item_code) | ||||
| 			& (wo_item.parent == wo.name) | ||||
| 			& (wo.docstatus == 1) | ||||
| 			& (wo_item.source_warehouse == warehouse) | ||||
| 			& (wo.status.notin(["Stopped", "Completed", "Closed"])) | ||||
| 			& ((wo_item.required_qty > wo_item.transferred_qty) | ||||
| 				| (wo_item.required_qty > wo_item.consumed_qty)) | ||||
| 		) | ||||
| 	).run()[0][0] or 0.0 | ||||
|  | ||||
| @ -329,7 +329,6 @@ execute:frappe.delete_doc_if_exists('Workspace', 'ERPNext Integrations Settings' | ||||
| erpnext.patches.v14_0.set_payroll_cost_centers | ||||
| erpnext.patches.v13_0.agriculture_deprecation_warning | ||||
| erpnext.patches.v13_0.hospitality_deprecation_warning | ||||
| erpnext.patches.v13_0.update_exchange_rate_settings | ||||
| erpnext.patches.v13_0.update_asset_quantity_field | ||||
| erpnext.patches.v13_0.delete_bank_reconciliation_detail | ||||
| erpnext.patches.v13_0.enable_provisional_accounting | ||||
| @ -350,3 +349,7 @@ erpnext.patches.v14_0.migrate_cost_center_allocations | ||||
| erpnext.patches.v13_0.convert_to_website_item_in_item_card_group_template | ||||
| 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.v13_0.update_exchange_rate_settings | ||||
| erpnext.patches.v14_0.delete_amazon_mws_doctype | ||||
| erpnext.patches.v13_0.set_work_order_qty_in_so_from_mr | ||||
|  | ||||
| @ -9,6 +9,8 @@ def execute(): | ||||
| 		FROM `tabBin`""",as_dict=1) | ||||
| 
 | ||||
| 	for entry in bin_details: | ||||
| 		if not (entry.item_code and entry.warehouse): | ||||
| 			continue | ||||
| 		update_bin_qty(entry.get("item_code"), entry.get("warehouse"), { | ||||
| 			"indented_qty": get_indented_qty(entry.get("item_code"), entry.get("warehouse")) | ||||
| 		}) | ||||
|  | ||||
| @ -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") | ||||
							
								
								
									
										36
									
								
								erpnext/patches/v13_0/set_work_order_qty_in_so_from_mr.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								erpnext/patches/v13_0/set_work_order_qty_in_so_from_mr.py
									
									
									
									
									
										Normal file
									
								
							| @ -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() | ||||
							
								
								
									
										28
									
								
								erpnext/patches/v13_0/update_reserved_qty_closed_wo.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								erpnext/patches/v13_0/update_reserved_qty_closed_wo.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,28 @@ | ||||
| import frappe | ||||
| 
 | ||||
| from erpnext.stock.utils import get_bin | ||||
| 
 | ||||
| 
 | ||||
| def execute(): | ||||
| 
 | ||||
| 	wo = frappe.qb.DocType("Work Order") | ||||
| 	wo_item = frappe.qb.DocType("Work Order Item") | ||||
| 
 | ||||
| 	incorrect_item_wh = ( | ||||
| 		frappe.qb | ||||
| 			.from_(wo) | ||||
| 			.join(wo_item).on(wo.name == wo_item.parent) | ||||
| 			.select(wo_item.item_code, wo.source_warehouse).distinct() | ||||
| 			.where( | ||||
| 				(wo.status == "Closed") | ||||
| 				& (wo.docstatus == 1) | ||||
| 				& (wo.source_warehouse.notnull()) | ||||
| 			) | ||||
| 	).run() | ||||
| 
 | ||||
| 	for item_code, warehouse in incorrect_item_wh: | ||||
| 		if not (item_code and warehouse): | ||||
| 			continue | ||||
| 
 | ||||
| 		bin = get_bin(item_code, warehouse) | ||||
| 		bin.update_reserved_qty_for_production() | ||||
							
								
								
									
										5
									
								
								erpnext/patches/v14_0/delete_amazon_mws_doctype.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								erpnext/patches/v14_0/delete_amazon_mws_doctype.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,5 @@ | ||||
| import frappe | ||||
| 
 | ||||
| 
 | ||||
| def execute(): | ||||
| 	frappe.delete_doc("DocType", "Amazon MWS Settings", ignore_missing=True) | ||||
| @ -6,9 +6,6 @@ from erpnext.setup.utils import get_exchange_rate | ||||
| 
 | ||||
| 
 | ||||
| def execute(): | ||||
| 	frappe.reload_doc('crm', 'doctype', 'opportunity') | ||||
| 	frappe.reload_doc('crm', 'doctype', 'opportunity_item') | ||||
| 
 | ||||
| 	opportunities = frappe.db.get_list('Opportunity', filters={ | ||||
| 		'opportunity_amount': ['>', 0] | ||||
| 	}, fields=['name', 'company', 'currency', 'opportunity_amount']) | ||||
| @ -20,15 +17,11 @@ def execute(): | ||||
| 		if opportunity.currency != company_currency: | ||||
| 			conversion_rate = get_exchange_rate(opportunity.currency, company_currency) | ||||
| 			base_opportunity_amount = flt(conversion_rate) * flt(opportunity.opportunity_amount) | ||||
| 			grand_total = flt(opportunity.opportunity_amount) | ||||
| 			base_grand_total = flt(conversion_rate) * flt(opportunity.opportunity_amount) | ||||
| 		else: | ||||
| 			conversion_rate = 1 | ||||
| 			base_opportunity_amount = grand_total = base_grand_total = flt(opportunity.opportunity_amount) | ||||
| 			base_opportunity_amount = flt(opportunity.opportunity_amount) | ||||
| 
 | ||||
| 		frappe.db.set_value('Opportunity', opportunity.name, { | ||||
| 			'conversion_rate': conversion_rate, | ||||
| 			'base_opportunity_amount': base_opportunity_amount, | ||||
| 			'grand_total': grand_total, | ||||
| 			'base_grand_total': base_grand_total | ||||
| 			'base_opportunity_amount': base_opportunity_amount | ||||
| 		}, update_modified=False) | ||||
|  | ||||
| @ -29,9 +29,11 @@ def execute(): | ||||
| 	""") | ||||
| 
 | ||||
| 	for item_code, warehouse in repost_for: | ||||
| 			update_bin_qty(item_code, warehouse, { | ||||
| 				"reserved_qty": get_reserved_qty(item_code, warehouse) | ||||
| 			}) | ||||
| 		if not (item_code and warehouse): | ||||
| 			continue | ||||
| 		update_bin_qty(item_code, warehouse, { | ||||
| 			"reserved_qty": get_reserved_qty(item_code, warehouse) | ||||
| 		}) | ||||
| 
 | ||||
| 	frappe.db.sql("""delete from tabBin | ||||
| 		where exists( | ||||
|  | ||||
| @ -14,6 +14,8 @@ def execute(): | ||||
| 		union | ||||
| 		select item_code, warehouse from `tabStock Ledger Entry`) a"""): | ||||
| 			try: | ||||
| 				if not (item_code and warehouse): | ||||
| 					continue | ||||
| 				count += 1 | ||||
| 				update_bin_qty(item_code, warehouse, { | ||||
| 					"indented_qty": get_indented_qty(item_code, warehouse), | ||||
|  | ||||
| @ -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 @@ frappe.ui.form.on('Gratuity', { | ||||
| 		}); | ||||
| 	}, | ||||
| 	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', | ||||
|  | ||||
| @ -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", | ||||
|  | ||||
| @ -21,7 +21,10 @@ class Gratuity(AccountsController): | ||||
| 			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 @@ class Gratuity(AccountsController): | ||||
| 
 | ||||
| 		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 | ||||
|  | ||||
| @ -10,7 +10,7 @@ def get_data(): | ||||
| 		'transactions': [ | ||||
| 			{ | ||||
| 				'label': _('Payment'), | ||||
| 				'items': ['Payment Entry'] | ||||
| 				'items': ['Payment Entry', 'Additional Salary'] | ||||
| 			} | ||||
| 		] | ||||
| 	} | ||||
|  | ||||
| @ -18,27 +18,25 @@ from erpnext.regional.united_arab_emirates.setup import create_gratuity_rule | ||||
| 
 | ||||
| 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 @@ class TestGratuity(unittest.TestCase): | ||||
| 
 | ||||
| 		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 @@ class TestGratuity(unittest.TestCase): | ||||
| 		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 @@ def create_gratuity(**args): | ||||
| 	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() | ||||
|  | ||||
| @ -527,11 +527,12 @@ def get_emp_list(sal_struct, cond, end_date, payroll_payable_account): | ||||
| 		""" % 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): | ||||
|  | ||||
| @ -124,7 +124,7 @@ class TestPayrollEntry(unittest.TestCase): | ||||
| 
 | ||||
| 		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": | ||||
|  | ||||
| @ -6,6 +6,7 @@ import random | ||||
| import unittest | ||||
| 
 | ||||
| import frappe | ||||
| from frappe.model.document import Document | ||||
| from frappe.utils import ( | ||||
| 	add_days, | ||||
| 	add_months, | ||||
| @ -687,20 +688,25 @@ def make_employee_salary_slip(user, payroll_frequency, salary_structure=None): | ||||
| 
 | ||||
| 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 @@ def get_salary_component_account(sal_comp, company_list=None): | ||||
| 	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 @@ def get_salary_component_account(sal_comp, company_list=None): | ||||
| 			}) | ||||
| 			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: | ||||
|  | ||||
| @ -151,6 +151,35 @@ class TestTimesheet(unittest.TestCase): | ||||
| 		settings.ignore_employee_time_overlap = initial_setting | ||||
| 		settings.save() | ||||
| 
 | ||||
| 	def test_timesheet_not_overlapping_with_continuous_timelogs(self): | ||||
| 		emp = make_employee("test_employee_6@salary.com") | ||||
| 
 | ||||
| 		update_activity_type("_Test Activity Type") | ||||
| 		timesheet = frappe.new_doc("Timesheet") | ||||
| 		timesheet.employee = emp | ||||
| 		timesheet.append( | ||||
| 			'time_logs', | ||||
| 			{ | ||||
| 				"billable": 1, | ||||
| 				"activity_type": "_Test Activity Type", | ||||
| 				"from_time": now_datetime(), | ||||
| 				"to_time": now_datetime() + datetime.timedelta(hours=3), | ||||
| 				"company": "_Test Company" | ||||
| 			} | ||||
| 		) | ||||
| 		timesheet.append( | ||||
| 			'time_logs', | ||||
| 			{ | ||||
| 				"billable": 1, | ||||
| 				"activity_type": "_Test Activity Type", | ||||
| 				"from_time": now_datetime() + datetime.timedelta(hours=3), | ||||
| 				"to_time": now_datetime() + datetime.timedelta(hours=4), | ||||
| 				"company": "_Test Company" | ||||
| 			} | ||||
| 		) | ||||
| 
 | ||||
| 		timesheet.save() # should not throw an error | ||||
| 
 | ||||
| 	def test_to_time(self): | ||||
| 		emp = make_employee("test_employee_6@salary.com") | ||||
| 		from_time = now_datetime() | ||||
|  | ||||
| @ -7,7 +7,7 @@ import json | ||||
| import frappe | ||||
| from frappe import _ | ||||
| from frappe.model.document import Document | ||||
| from frappe.utils import add_to_date, flt, getdate, time_diff_in_hours | ||||
| from frappe.utils import add_to_date, flt, get_datetime, getdate, time_diff_in_hours | ||||
| 
 | ||||
| from erpnext.controllers.queries import get_match_cond | ||||
| from erpnext.hr.utils import validate_active_employee | ||||
| @ -145,7 +145,7 @@ class Timesheet(Document): | ||||
| 		if not (data.from_time and data.hours): | ||||
| 			return | ||||
| 
 | ||||
| 		_to_time = add_to_date(data.from_time, hours=data.hours, as_datetime=True) | ||||
| 		_to_time = get_datetime(add_to_date(data.from_time, hours=data.hours, as_datetime=True)) | ||||
| 		if data.to_time != _to_time: | ||||
| 			data.to_time = _to_time | ||||
| 
 | ||||
| @ -171,39 +171,54 @@ class Timesheet(Document): | ||||
| 				.format(args.idx, self.name, existing.name), OverlapError) | ||||
| 
 | ||||
| 	def get_overlap_for(self, fieldname, args, value): | ||||
| 		cond = "ts.`{0}`".format(fieldname) | ||||
| 		if fieldname == 'workstation': | ||||
| 			cond = "tsd.`{0}`".format(fieldname) | ||||
| 		timesheet = frappe.qb.DocType("Timesheet") | ||||
| 		timelog = frappe.qb.DocType("Timesheet Detail") | ||||
| 
 | ||||
| 		existing = frappe.db.sql("""select ts.name as name, tsd.from_time as from_time, tsd.to_time as to_time from | ||||
| 			`tabTimesheet Detail` tsd, `tabTimesheet` ts where {0}=%(val)s and tsd.parent = ts.name and | ||||
| 			( | ||||
| 				(%(from_time)s > tsd.from_time and %(from_time)s < tsd.to_time) or | ||||
| 				(%(to_time)s > tsd.from_time and %(to_time)s < tsd.to_time) or | ||||
| 				(%(from_time)s <= tsd.from_time and %(to_time)s >= tsd.to_time)) | ||||
| 			and tsd.name!=%(name)s | ||||
| 			and ts.name!=%(parent)s | ||||
| 			and ts.docstatus < 2""".format(cond), | ||||
| 			{ | ||||
| 				"val": value, | ||||
| 				"from_time": args.from_time, | ||||
| 				"to_time": args.to_time, | ||||
| 				"name": args.name or "No Name", | ||||
| 				"parent": args.parent or "No Name" | ||||
| 			}, as_dict=True) | ||||
| 		# check internal overlap | ||||
| 		for time_log in self.time_logs: | ||||
| 			if not (time_log.from_time and time_log.to_time | ||||
| 				and args.from_time and args.to_time): continue | ||||
| 		from_time = get_datetime(args.from_time) | ||||
| 		to_time = get_datetime(args.to_time) | ||||
| 
 | ||||
| 			if (fieldname != 'workstation' or args.get(fieldname) == time_log.get(fieldname)) and \ | ||||
| 				args.idx != time_log.idx and ((args.from_time > time_log.from_time and args.from_time < time_log.to_time) or | ||||
| 				(args.to_time > time_log.from_time and args.to_time < time_log.to_time) or | ||||
| 				(args.from_time <= time_log.from_time and args.to_time >= time_log.to_time)): | ||||
| 				return self | ||||
| 		existing = ( | ||||
| 			frappe.qb.from_(timesheet) | ||||
| 				.join(timelog) | ||||
| 				.on(timelog.parent == timesheet.name) | ||||
| 				.select(timesheet.name.as_('name'), timelog.from_time.as_('from_time'), timelog.to_time.as_('to_time')) | ||||
| 				.where( | ||||
| 					(timelog.name != (args.name or "No Name")) | ||||
| 					& (timesheet.name != (args.parent or "No Name")) | ||||
| 					& (timesheet.docstatus < 2) | ||||
| 					& (timesheet[fieldname] == value) | ||||
| 					& ( | ||||
| 						((from_time > timelog.from_time) & (from_time < timelog.to_time)) | ||||
| 						| ((to_time > timelog.from_time) & (to_time < timelog.to_time)) | ||||
| 						| ((from_time <= timelog.from_time) & (to_time >= timelog.to_time)) | ||||
| 					) | ||||
| 				) | ||||
| 		).run(as_dict=True) | ||||
| 
 | ||||
| 		if self.check_internal_overlap(fieldname, args): | ||||
| 			return self | ||||
| 
 | ||||
| 		return existing[0] if existing else None | ||||
| 
 | ||||
| 	def check_internal_overlap(self, fieldname, args): | ||||
| 		for time_log in self.time_logs: | ||||
| 			if not (time_log.from_time and time_log.to_time | ||||
| 				and args.from_time and args.to_time): | ||||
| 				continue | ||||
| 
 | ||||
| 			from_time = get_datetime(time_log.from_time) | ||||
| 			to_time = get_datetime(time_log.to_time) | ||||
| 			args_from_time = get_datetime(args.from_time) | ||||
| 			args_to_time = get_datetime(args.to_time) | ||||
| 
 | ||||
| 			if (args.get(fieldname) == time_log.get(fieldname)) and (args.idx != time_log.idx) and ( | ||||
| 				(args_from_time > from_time and args_from_time < to_time) | ||||
| 				or (args_to_time > from_time and args_to_time < to_time) | ||||
| 				or (args_from_time <= from_time and args_to_time >= to_time) | ||||
| 			): | ||||
| 				return True | ||||
| 		return False | ||||
| 
 | ||||
| 	def update_cost(self): | ||||
| 		for data in self.time_logs: | ||||
| 			if data.activity_type or data.is_billable: | ||||
|  | ||||
| @ -14,12 +14,6 @@ | ||||
|   "to_time", | ||||
|   "hours", | ||||
|   "completed", | ||||
|   "section_break_7", | ||||
|   "completed_qty", | ||||
|   "workstation", | ||||
|   "column_break_12", | ||||
|   "operation", | ||||
|   "operation_id", | ||||
|   "project_details", | ||||
|   "project", | ||||
|   "project_name", | ||||
| @ -83,43 +77,6 @@ | ||||
|    "fieldtype": "Check", | ||||
|    "label": "Completed" | ||||
|   }, | ||||
|   { | ||||
|    "fieldname": "section_break_7", | ||||
|    "fieldtype": "Section Break" | ||||
|   }, | ||||
|   { | ||||
|    "depends_on": "eval:parent.work_order", | ||||
|    "fieldname": "completed_qty", | ||||
|    "fieldtype": "Float", | ||||
|    "label": "Completed Qty" | ||||
|   }, | ||||
|   { | ||||
|    "depends_on": "eval:parent.work_order", | ||||
|    "fieldname": "workstation", | ||||
|    "fieldtype": "Link", | ||||
|    "label": "Workstation", | ||||
|    "options": "Workstation", | ||||
|    "read_only": 1 | ||||
|   }, | ||||
|   { | ||||
|    "fieldname": "column_break_12", | ||||
|    "fieldtype": "Column Break" | ||||
|   }, | ||||
|   { | ||||
|    "depends_on": "eval:parent.work_order", | ||||
|    "fieldname": "operation", | ||||
|    "fieldtype": "Link", | ||||
|    "label": "Operation", | ||||
|    "options": "Operation", | ||||
|    "read_only": 1 | ||||
|   }, | ||||
|   { | ||||
|    "depends_on": "eval:parent.work_order", | ||||
|    "fieldname": "operation_id", | ||||
|    "fieldtype": "Data", | ||||
|    "hidden": 1, | ||||
|    "label": "Operation Id" | ||||
|   }, | ||||
|   { | ||||
|    "fieldname": "project_details", | ||||
|    "fieldtype": "Section Break" | ||||
| @ -267,7 +224,7 @@ | ||||
|  "idx": 1, | ||||
|  "istable": 1, | ||||
|  "links": [], | ||||
|  "modified": "2021-05-18 12:19:33.205940", | ||||
|  "modified": "2022-02-17 16:53:34.878798", | ||||
|  "modified_by": "Administrator", | ||||
|  "module": "Projects", | ||||
|  "name": "Timesheet Detail", | ||||
| @ -275,5 +232,6 @@ | ||||
|  "permissions": [], | ||||
|  "quick_entry": 1, | ||||
|  "sort_field": "modified", | ||||
|  "sort_order": "ASC" | ||||
|  "sort_order": "ASC", | ||||
|  "states": [] | ||||
| } | ||||
| @ -1463,7 +1463,8 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe | ||||
| 						"item_code": d.item_code, | ||||
| 						"pricing_rules": d.pricing_rules, | ||||
| 						"parenttype": d.parenttype, | ||||
| 						"parent": d.parent | ||||
| 						"parent": d.parent, | ||||
| 						"price_list_rate": d.price_list_rate | ||||
| 					}) | ||||
| 				} | ||||
| 			}); | ||||
| @ -2283,20 +2284,12 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe | ||||
| 
 | ||||
| 	coupon_code() { | ||||
| 		var me = this; | ||||
| 		if (this.frm.doc.coupon_code) { | ||||
| 			frappe.run_serially([ | ||||
| 				() => this.frm.doc.ignore_pricing_rule=1, | ||||
| 				() => me.ignore_pricing_rule(), | ||||
| 				() => this.frm.doc.ignore_pricing_rule=0, | ||||
| 				() => me.apply_pricing_rule(), | ||||
| 				() => this.frm.save() | ||||
| 			]); | ||||
| 		} else { | ||||
| 			frappe.run_serially([ | ||||
| 				() => this.frm.doc.ignore_pricing_rule=1, | ||||
| 				() => me.ignore_pricing_rule() | ||||
| 			]); | ||||
| 		} | ||||
| 		frappe.run_serially([ | ||||
| 			() => this.frm.doc.ignore_pricing_rule=1, | ||||
| 			() => me.ignore_pricing_rule(), | ||||
| 			() => this.frm.doc.ignore_pricing_rule=0, | ||||
| 			() => me.apply_pricing_rule() | ||||
| 		]); | ||||
| 	} | ||||
| }; | ||||
| 
 | ||||
|  | ||||
| @ -219,7 +219,6 @@ def get_regional_address_details(party_details, doctype, company): | ||||
| 
 | ||||
| 	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", | ||||
|  | ||||
| @ -40,7 +40,11 @@ frappe.query_reports["DATEV"] = { | ||||
| 		}); | ||||
| 
 | ||||
| 		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}`); | ||||
| 		}); | ||||
| 
 | ||||
|  | ||||
| @ -17,7 +17,7 @@ frappe.query_reports["GSTR-1"] = { | ||||
| 			"fieldtype": "Link", | ||||
| 			"options": "Address", | ||||
| 			"get_query": function () { | ||||
| 				var company = frappe.query_report.get_filter_value('company'); | ||||
| 				let company = frappe.query_report.get_filter_value('company'); | ||||
| 				if (company) { | ||||
| 					return { | ||||
| 						"query": 'frappe.contacts.doctype.address.address.address_query', | ||||
| @ -26,6 +26,11 @@ frappe.query_reports["GSTR-1"] = { | ||||
| 				} | ||||
| 			} | ||||
| 		}, | ||||
| 		{ | ||||
| 			"fieldname": "company_gstin", | ||||
| 			"label": __("Company GSTIN"), | ||||
| 			"fieldtype": "Select" | ||||
| 		}, | ||||
| 		{ | ||||
| 			"fieldname": "from_date", | ||||
| 			"label": __("From Date"), | ||||
| @ -60,10 +65,21 @@ frappe.query_reports["GSTR-1"] = { | ||||
| 		} | ||||
| 	], | ||||
| 	onload: function (report) { | ||||
| 		let filters = report.get_values(); | ||||
| 
 | ||||
| 		frappe.call({ | ||||
| 			method: 'erpnext.regional.report.gstr_1.gstr_1.get_company_gstins', | ||||
| 			args: { | ||||
| 				company: filters.company | ||||
| 			}, | ||||
| 			callback: function(r) { | ||||
| 				frappe.query_report.page.fields_dict.company_gstin.df.options = r.message; | ||||
| 				frappe.query_report.page.fields_dict.company_gstin.refresh(); | ||||
| 			} | ||||
| 		}); | ||||
| 
 | ||||
| 
 | ||||
| 		report.page.add_inner_button(__("Download as JSON"), function () { | ||||
| 			var filters = report.get_values(); | ||||
| 
 | ||||
| 			frappe.call({ | ||||
| 				method: 'erpnext.regional.report.gstr_1.gstr_1.get_json', | ||||
| 				args: { | ||||
|  | ||||
| @ -28,7 +28,7 @@ class Gstr1Report(object): | ||||
| 			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, | ||||
| @ -253,13 +253,14 @@ class Gstr1Report(object): | ||||
| 		for opts in (("company", " and company=%(company)s"), | ||||
| 			("from_date", " and posting_date>=%(from_date)s"), | ||||
| 			("to_date", " and posting_date<=%(to_date)s"), | ||||
| 			("company_address", " and company_address=%(company_address)s")): | ||||
| 			("company_address", " and company_address=%(company_address)s"), | ||||
| 			("company_gstin", " and company_gstin=%(company_gstin)s")): | ||||
| 				if self.filters.get(opts[0]): | ||||
| 					conditions += opts[1] | ||||
| 
 | ||||
| 
 | ||||
| 		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 +384,7 @@ class Gstr1Report(object): | ||||
| 		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 +410,7 @@ class Gstr1Report(object): | ||||
| 		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 +517,7 @@ class Gstr1Report(object): | ||||
| 		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 +818,7 @@ def get_json(filters, report_name, data): | ||||
| 	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 +842,7 @@ def get_json(filters, report_name, data): | ||||
| 		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 +876,7 @@ def get_json(filters, report_name, data): | ||||
| 	} | ||||
| 
 | ||||
| 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 +890,7 @@ def get_b2b_json(res, gstin): | ||||
| 			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 +1045,7 @@ def get_cdnr_reg_json(res, gstin): | ||||
| 				"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 +1070,7 @@ def get_cdnr_unreg_json(res, gstin): | ||||
| 			"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 +1111,21 @@ def get_exempted_json(data): | ||||
| 
 | ||||
| 	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 +1147,7 @@ def get_rate_and_tax_details(row, gstin): | ||||
| 	# 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 +1192,24 @@ def is_inter_state(invoice_detail): | ||||
| 	if invoice_detail.place_of_supply.split("-")[0] != invoice_detail.company_gstin[:2]: | ||||
| 		return True | ||||
| 	else: | ||||
| 		return False | ||||
| 		return False | ||||
| 
 | ||||
| 
 | ||||
| @frappe.whitelist() | ||||
| def get_company_gstins(company): | ||||
| 	address = frappe.qb.DocType("Address") | ||||
| 	links = frappe.qb.DocType("Dynamic Link") | ||||
| 
 | ||||
| 	addresses = frappe.qb.from_(address).inner_join(links).on( | ||||
| 		address.name == links.parent | ||||
| 	).select( | ||||
| 		address.gstin | ||||
| 	).where( | ||||
| 		links.link_doctype == 'Company' | ||||
| 	).where( | ||||
| 		links.link_name == company | ||||
| 	).run(as_dict=1) | ||||
| 
 | ||||
| 	address_list = [''] + [d.gstin for d in addresses] | ||||
| 
 | ||||
| 	return address_list | ||||
| @ -102,7 +102,7 @@ def make_custom_fields(): | ||||
| 		] | ||||
| 	} | ||||
| 
 | ||||
| 	create_custom_fields(custom_fields, update=True) | ||||
| 	create_custom_fields(custom_fields, ignore_validate=True, update=True) | ||||
| 
 | ||||
| def update_regional_tax_settings(country, company): | ||||
| 	create_ksa_vat_setting(company) | ||||
|  | ||||
| @ -6,7 +6,7 @@ import json | ||||
| 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 @@ class TestSalesOrder(ERPNextTestCase): | ||||
| 		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 | ||||
|  | ||||
| @ -169,6 +169,21 @@ erpnext.PointOfSale.Payment = class { | ||||
|       } | ||||
|     }); | ||||
| 
 | ||||
| 		frappe.ui.form.on('POS Invoice', 'coupon_code', (frm) => { | ||||
| 			if (!frm.doc.ignore_pricing_rule) { | ||||
| 				if (frm.doc.coupon_code) { | ||||
| 					frappe.run_serially([ | ||||
| 						() => frm.doc.ignore_pricing_rule=1, | ||||
| 						() => frm.trigger('ignore_pricing_rule'), | ||||
| 						() => frm.doc.ignore_pricing_rule=0, | ||||
| 						() => frm.trigger('apply_pricing_rule'), | ||||
| 						() => frm.save(), | ||||
| 						() => this.update_totals_section(frm.doc) | ||||
| 					]); | ||||
| 				} | ||||
| 			} | ||||
| 		}); | ||||
| 
 | ||||
| 		this.setup_listener_for_payments(); | ||||
| 
 | ||||
| 		this.$payment_modes.on('click', '.shortcut', function() { | ||||
|  | ||||
| @ -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); | ||||
| 	}, | ||||
| 
 | ||||
| }; | ||||
| @ -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" | ||||
|   } | ||||
|  ] | ||||
| } | ||||
| @ -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 | ||||
| @ -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) | ||||
| @ -227,11 +227,11 @@ erpnext.selling.SellingController = class SellingController extends erpnext.Tran | ||||
| 					}, | ||||
| 					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); | ||||
| 							} | ||||
| 						} | ||||
| 					} | ||||
| 				}); | ||||
|  | ||||
| @ -20,43 +20,12 @@ class Bin(Document): | ||||
| 			+ 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''' | ||||
| 		from erpnext.manufacturing.doctype.work_order.work_order import get_reserved_qty_for_production | ||||
| 
 | ||||
| 		wo = frappe.qb.DocType("Work Order") | ||||
| 		wo_item = frappe.qb.DocType("Work Order Item") | ||||
| 
 | ||||
| 		self.reserved_qty_for_production = ( | ||||
| 				frappe.qb | ||||
| 					.from_(wo) | ||||
| 					.from_(wo_item) | ||||
| 					.select(Sum(Case() | ||||
| 							.when(wo.skip_transfer == 0, wo_item.required_qty - wo_item.transferred_qty) | ||||
| 							.else_(wo_item.required_qty - wo_item.consumed_qty)) | ||||
| 						) | ||||
| 					.where( | ||||
| 						(wo_item.item_code == self.item_code) | ||||
| 						& (wo_item.parent == wo.name) | ||||
| 						& (wo.docstatus == 1) | ||||
| 						& (wo_item.source_warehouse == self.warehouse) | ||||
| 						& (wo.status.notin(["Stopped", "Completed"])) | ||||
| 						& ((wo_item.required_qty > wo_item.transferred_qty) | ||||
| 							| (wo_item.required_qty > wo_item.consumed_qty)) | ||||
| 					) | ||||
| 		).run()[0][0] or 0.0 | ||||
| 		self.reserved_qty_for_production = get_reserved_qty_for_production(self.item_code, self.warehouse) | ||||
| 
 | ||||
| 		self.set_projected_qty() | ||||
| 
 | ||||
| @ -126,13 +95,6 @@ def on_doctype_update(): | ||||
| 	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', | ||||
|  | ||||
| @ -545,7 +545,7 @@ $.extend(erpnext.item, { | ||||
| 			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) => { | ||||
|  | ||||
| @ -48,6 +48,7 @@ | ||||
|   "warranty_period", | ||||
|   "weight_per_unit", | ||||
|   "weight_uom", | ||||
|   "allow_negative_stock", | ||||
|   "reorder_section", | ||||
|   "reorder_levels", | ||||
|   "unit_of_measure_conversion", | ||||
| @ -346,7 +347,7 @@ | ||||
|    "fieldname": "valuation_method", | ||||
|    "fieldtype": "Select", | ||||
|    "label": "Valuation Method", | ||||
|    "options": "\nFIFO\nMoving Average" | ||||
|    "options": "\nFIFO\nMoving Average\nLIFO" | ||||
|   }, | ||||
|   { | ||||
|    "depends_on": "is_stock_item", | ||||
| @ -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", | ||||
| @ -987,4 +994,4 @@ | ||||
|  "states": [], | ||||
|  "title_field": "item_name", | ||||
|  "track_changes": 1 | ||||
| } | ||||
| } | ||||
|  | ||||
| @ -6,6 +6,7 @@ import json | ||||
| 
 | ||||
| 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 @@ class TestItem(ERPNextTestCase): | ||||
| 		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') | ||||
|  | ||||
| @ -533,6 +533,7 @@ def raise_work_orders(material_request): | ||||
| 					"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, | ||||
|  | ||||
| @ -9,7 +9,7 @@ from collections import defaultdict | ||||
| 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 @@ def apply_putaway_rule(doctype, items, company, sync=None, purpose=None): | ||||
| 	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 = { | ||||
|  | ||||
| @ -35,6 +35,18 @@ class TestPutawayRule(ERPNextTestCase): | ||||
| 			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 @@ class TestPutawayRule(ERPNextTestCase): | ||||
| 		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 @@ class TestPutawayRule(ERPNextTestCase): | ||||
| 		# 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 @@ class TestPutawayRule(ERPNextTestCase): | ||||
| 		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 @@ class TestPutawayRule(ERPNextTestCase): | ||||
| 		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 @@ class TestPutawayRule(ERPNextTestCase): | ||||
| 		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 @@ class TestPutawayRule(ERPNextTestCase): | ||||
| 		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 @@ class TestPutawayRule(ERPNextTestCase): | ||||
| 		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() | ||||
|  | ||||
| @ -433,9 +433,10 @@ class StockEntry(StockController): | ||||
| 				) | ||||
| 
 | ||||
| 	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, | ||||
|  | ||||
| @ -99,7 +99,7 @@ | ||||
|    "fieldname": "valuation_method", | ||||
|    "fieldtype": "Select", | ||||
|    "label": "Default Valuation Method", | ||||
|    "options": "FIFO\nMoving Average" | ||||
|    "options": "FIFO\nMoving Average\nLIFO" | ||||
|   }, | ||||
|   { | ||||
|    "description": "The percentage you are allowed to receive or deliver more against the quantity ordered. For example, if you have ordered 100 units, and your Allowance is 10%, then you are allowed to receive 110 units.", | ||||
| @ -346,7 +346,7 @@ | ||||
|  "index_web_pages_for_search": 1, | ||||
|  "issingle": 1, | ||||
|  "links": [], | ||||
|  "modified": "2022-02-04 15:33:43.692736", | ||||
|  "modified": "2022-02-05 15:33:43.692736", | ||||
|  "modified_by": "Administrator", | ||||
|  "module": "Stock", | ||||
|  "name": "Stock Settings", | ||||
|  | ||||
| @ -6,6 +6,7 @@ import json | ||||
| 
 | ||||
| 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 @@ def get_item_details(args, doc=None, for_validate=False, overwrite_warehouse=Tru | ||||
| 		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 @@ def get_basic_details(args, item, overwrite_warehouse=True): | ||||
| 
 | ||||
| 	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: | ||||
|  | ||||
| @ -12,6 +12,7 @@ from frappe.utils import cint, date_diff, flt | ||||
| from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos | ||||
| 
 | ||||
| Filters = frappe._dict | ||||
| precision = cint(frappe.db.get_single_value("System Settings", "float_precision")) | ||||
| 
 | ||||
| def execute(filters: Filters = None) -> Tuple: | ||||
| 	to_date = filters["to_date"] | ||||
| @ -48,10 +49,13 @@ def format_report_data(filters: Filters, item_details: Dict, to_date: str) -> Li | ||||
| 		if filters.get("show_warehouse_wise_stock"): | ||||
| 			row.append(details.warehouse) | ||||
| 
 | ||||
| 		row.extend([item_dict.get("total_qty"), average_age, | ||||
| 		row.extend([ | ||||
| 			flt(item_dict.get("total_qty"), precision), | ||||
| 			average_age, | ||||
| 			range1, range2, range3, above_range3, | ||||
| 			earliest_age, latest_age, | ||||
| 			details.stock_uom]) | ||||
| 			details.stock_uom | ||||
| 		]) | ||||
| 
 | ||||
| 		data.append(row) | ||||
| 
 | ||||
| @ -79,13 +83,13 @@ def get_range_age(filters: Filters, fifo_queue: List, to_date: str, item_dict: D | ||||
| 		qty = flt(item[0]) if not item_dict["has_serial_no"] else 1.0 | ||||
| 
 | ||||
| 		if age <= filters.range1: | ||||
| 			range1 += qty | ||||
| 			range1 = flt(range1 + qty, precision) | ||||
| 		elif age <= filters.range2: | ||||
| 			range2 += qty | ||||
| 			range2 = flt(range2 + qty, precision) | ||||
| 		elif age <= filters.range3: | ||||
| 			range3 += qty | ||||
| 			range3 = flt(range3 + qty, precision) | ||||
| 		else: | ||||
| 			above_range3 += qty | ||||
| 			above_range3 = flt(above_range3 + qty, precision) | ||||
| 
 | ||||
| 	return range1, range2, range3, above_range3 | ||||
| 
 | ||||
| @ -252,6 +256,7 @@ class FIFOSlots: | ||||
| 			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 +269,16 @@ class FIFOSlots: | ||||
| 
 | ||||
| 			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"] | ||||
| 
 | ||||
| @ -281,14 +290,16 @@ class FIFOSlots: | ||||
| 	def __compute_incoming_stock(self, row: Dict, fifo_queue: List, transfer_key: Tuple, serial_nos: List): | ||||
| 		"Update FIFO Queue on inward stock." | ||||
| 
 | ||||
| 		if self.transferred_item_details.get(transfer_key): | ||||
| 		transfer_data = self.transferred_item_details.get(transfer_key) | ||||
| 		if transfer_data: | ||||
| 			# inward/outward from same voucher, item & warehouse | ||||
| 			slot = self.transferred_item_details[transfer_key].pop(0) | ||||
| 			fifo_queue.append(slot) | ||||
| 			# eg: Repack with same item, Stock reco for batch item | ||||
| 			# consume transfer data and add stock to fifo queue | ||||
| 			self.__adjust_incoming_transfer_qty(transfer_data, fifo_queue, row) | ||||
| 		else: | ||||
| 			if not serial_nos: | ||||
| 				if fifo_queue and flt(fifo_queue[0][0]) < 0: | ||||
| 					# neutralize negative stock by adding positive stock | ||||
| 				if fifo_queue and flt(fifo_queue[0][0]) <= 0: | ||||
| 					# neutralize 0/negative stock by adding positive stock | ||||
| 					fifo_queue[0][0] += flt(row.actual_qty) | ||||
| 					fifo_queue[0][1] = row.posting_date | ||||
| 				else: | ||||
| @ -319,7 +330,7 @@ class FIFOSlots: | ||||
| 			elif not fifo_queue: | ||||
| 				# negative stock, no balance but qty yet to consume | ||||
| 				fifo_queue.append([-(qty_to_pop), row.posting_date]) | ||||
| 				self.transferred_item_details[transfer_key].append([row.actual_qty, row.posting_date]) | ||||
| 				self.transferred_item_details[transfer_key].append([qty_to_pop, row.posting_date]) | ||||
| 				qty_to_pop = 0 | ||||
| 			else: | ||||
| 				# qty to pop < slot qty, ample balance | ||||
| @ -328,6 +339,33 @@ class FIFOSlots: | ||||
| 				self.transferred_item_details[transfer_key].append([qty_to_pop, slot[1]]) | ||||
| 				qty_to_pop = 0 | ||||
| 
 | ||||
| 	def __adjust_incoming_transfer_qty(self, transfer_data: Dict, fifo_queue: List, row: Dict): | ||||
| 		"Add previously removed stock back to FIFO Queue." | ||||
| 		transfer_qty_to_pop = flt(row.actual_qty) | ||||
| 
 | ||||
| 		def add_to_fifo_queue(slot): | ||||
| 			if fifo_queue and flt(fifo_queue[0][0]) <= 0: | ||||
| 				# neutralize 0/negative stock by adding positive stock | ||||
| 				fifo_queue[0][0] += flt(slot[0]) | ||||
| 				fifo_queue[0][1] = slot[1] | ||||
| 			else: | ||||
| 				fifo_queue.append(slot) | ||||
| 
 | ||||
| 		while transfer_qty_to_pop: | ||||
| 			if transfer_data and 0 < transfer_data[0][0] <= transfer_qty_to_pop: | ||||
| 				# bucket qty is not enough, consume whole | ||||
| 				transfer_qty_to_pop -= transfer_data[0][0] | ||||
| 				add_to_fifo_queue(transfer_data.pop(0)) | ||||
| 			elif not transfer_data: | ||||
| 				# transfer bucket is empty, extra incoming qty | ||||
| 				add_to_fifo_queue([transfer_qty_to_pop, row.posting_date]) | ||||
| 				transfer_qty_to_pop = 0 | ||||
| 			else: | ||||
| 				# ample bucket qty to consume | ||||
| 				transfer_data[0][0] -= transfer_qty_to_pop | ||||
| 				add_to_fifo_queue([transfer_qty_to_pop, transfer_data[0][1]]) | ||||
| 				transfer_qty_to_pop = 0 | ||||
| 
 | ||||
| 	def __update_balances(self, row: Dict, key: Union[Tuple, str]): | ||||
| 		self.item_details[key]["qty_after_transaction"] = row.qty_after_transaction | ||||
| 
 | ||||
| @ -338,6 +376,27 @@ class FIFOSlots: | ||||
| 
 | ||||
| 		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 | ||||
|  | ||||
| @ -15,6 +15,7 @@ Here, the balance qty is 70. | ||||
| 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 | ||||
| @ -70,4 +71,39 @@ Date | Qty | Queue | ||||
| 2nd  | -60 | [[-10, 1-12-2021]] | ||||
| 3rd  | +5  | [[-5, 3-12-2021]] | ||||
| 4th  | +10 | [[5, 4-12-2021]] | ||||
| 4th  | +20 | [[5, 4-12-2021], [20, 4-12-2021]] | ||||
| 4th  | +20 | [[5, 4-12-2021], [20, 4-12-2021]] | ||||
| 
 | ||||
| ### Concept of Transfer Qty Bucket | ||||
| In the case of **Repack**, Quantity that comes in, isn't really incoming. It is just new stock repurposed from old stock, due to incoming-outgoing of the same warehouse. | ||||
| 
 | ||||
| Here, stock is consumed from the FIFO Queue. It is then re-added back to the queue. | ||||
| While adding stock back to the queue we need to know how much to add. | ||||
| For this we need to keep track of how much was previously consumed. | ||||
| Hence we use **Transfer Qty Bucket**. | ||||
| 
 | ||||
| While re-adding stock, we try to add buckets that were consumed earlier (date intact), to maintain correctness. | ||||
| 
 | ||||
| #### Case 1: Same Item-Warehouse in Repack | ||||
| Eg: | ||||
| ------------------------------------------------------------------------------------- | ||||
| Date | Qty   | Voucher |             FIFO Queue           	   | Transfer Qty Buckets | ||||
| ------------------------------------------------------------------------------------- | ||||
| 1st  | +500  |  PR     | [[500, 1-12-2021]]   				   | | ||||
| 2nd  | -50   |  Repack | [[450, 1-12-2021]]   				   | [[50, 1-12-2021]] | ||||
| 2nd  | +50   |  Repack | [[450, 1-12-2021], [50, 1-12-2021]]   | [] | ||||
| 
 | ||||
| - The balance at the end is restored back to 500 | ||||
| - However, the initial 500 qty bucket is now split into 450 and 50, with the same date | ||||
| - The net effect is the same as that before the Repack | ||||
| 
 | ||||
| #### Case 2: Same Item-Warehouse in Repack with Split Consumption rows | ||||
| Eg: | ||||
| ------------------------------------------------------------------------------------- | ||||
| Date | Qty   | Voucher |             FIFO Queue           	   | Transfer Qty Buckets | ||||
| ------------------------------------------------------------------------------------- | ||||
| 1st  | +500  |  PR     | [[500, 1-12-2021]]   				   | | ||||
| 2nd  | -50   |  Repack | [[450, 1-12-2021]]   				   | [[50, 1-12-2021]] | ||||
| 2nd  | -50   |  Repack | [[400, 1-12-2021]]   				   | [[50, 1-12-2021], | ||||
| -	 |		 |		   |									   |[50, 1-12-2021]] | ||||
| 2nd  | +100  |  Repack | [[400, 1-12-2021], [50, 1-12-2021],   | [] | ||||
| -	 |		 |		   | [50, 1-12-2021]]					   | | ||||
|  | ||||
| @ -3,7 +3,7 @@ | ||||
| 
 | ||||
| import frappe | ||||
| 
 | ||||
| from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots | ||||
| from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots, format_report_data | ||||
| from erpnext.tests.utils import ERPNextTestCase | ||||
| 
 | ||||
| 
 | ||||
| @ -11,15 +11,17 @@ class TestStockAgeing(ERPNextTestCase): | ||||
| 	def setUp(self) -> None: | ||||
| 		self.filters = frappe._dict( | ||||
| 			company="_Test Company", | ||||
| 			to_date="2021-12-10" | ||||
| 			to_date="2021-12-10", | ||||
| 			range1=30, range2=60, range3=90 | ||||
| 		) | ||||
| 
 | ||||
| 	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 +29,7 @@ class TestStockAgeing(ERPNextTestCase): | ||||
| 			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 +37,7 @@ class TestStockAgeing(ERPNextTestCase): | ||||
| 			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 +54,12 @@ class TestStockAgeing(ERPNextTestCase): | ||||
| 		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 +67,7 @@ class TestStockAgeing(ERPNextTestCase): | ||||
| 			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 +75,7 @@ class TestStockAgeing(ERPNextTestCase): | ||||
| 			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 +83,7 @@ class TestStockAgeing(ERPNextTestCase): | ||||
| 			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 +99,16 @@ class TestStockAgeing(ERPNextTestCase): | ||||
| 		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 +116,7 @@ class TestStockAgeing(ERPNextTestCase): | ||||
| 			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 +124,7 @@ class TestStockAgeing(ERPNextTestCase): | ||||
| 			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 +137,477 @@ class TestStockAgeing(ERPNextTestCase): | ||||
| 		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 test_repack_entry_same_item_split_rows(self): | ||||
| 		""" | ||||
| 		Split consumption rows and have single repacked item row (same warehouse). | ||||
| 		Ledger: | ||||
| 		Item	| Qty | Voucher | ||||
| 		------------------------ | ||||
| 		Item 1  | 500 | 001 | ||||
| 		Item 1  | -50 | 002 (repack) | ||||
| 		Item 1  | -50 | 002 (repack) | ||||
| 		Item 1  | 100 | 002 (repack) | ||||
| 
 | ||||
| 		Case most likely for batch items. Test time bucket computation. | ||||
| 		""" | ||||
| 		sle = [ | ||||
| 			frappe._dict( # stock up item | ||||
| 				name="Flask Item", | ||||
| 				actual_qty=500, qty_after_transaction=500, | ||||
| 				warehouse="WH 1", | ||||
| 				posting_date="2021-12-03", voucher_type="Stock Entry", | ||||
| 				voucher_no="001", | ||||
| 				has_serial_no=False, serial_no=None | ||||
| 			), | ||||
| 			frappe._dict( | ||||
| 				name="Flask Item", | ||||
| 				actual_qty=(-50), qty_after_transaction=450, | ||||
| 				warehouse="WH 1", | ||||
| 				posting_date="2021-12-04", voucher_type="Stock Entry", | ||||
| 				voucher_no="002", | ||||
| 				has_serial_no=False, serial_no=None | ||||
| 			), | ||||
| 			frappe._dict( | ||||
| 				name="Flask Item", | ||||
| 				actual_qty=(-50), qty_after_transaction=400, | ||||
| 				warehouse="WH 1", | ||||
| 				posting_date="2021-12-04", voucher_type="Stock Entry", | ||||
| 				voucher_no="002", | ||||
| 				has_serial_no=False, serial_no=None | ||||
| 			), | ||||
| 			frappe._dict( | ||||
| 				name="Flask Item", | ||||
| 				actual_qty=100, qty_after_transaction=500, | ||||
| 				warehouse="WH 1", | ||||
| 				posting_date="2021-12-04", voucher_type="Stock Entry", | ||||
| 				voucher_no="002", | ||||
| 				has_serial_no=False, serial_no=None | ||||
| 			), | ||||
| 		] | ||||
| 		slots = FIFOSlots(self.filters, sle).generate() | ||||
| 		item_result = slots["Flask Item"] | ||||
| 		queue = item_result["fifo_queue"] | ||||
| 
 | ||||
| 		self.assertEqual(item_result["total_qty"], 500.0) | ||||
| 		self.assertEqual(queue[0][0], 400.0) | ||||
| 		self.assertEqual(queue[1][0], 50.0) | ||||
| 		self.assertEqual(queue[2][0], 50.0) | ||||
| 		# check if time buckets add up to balance qty | ||||
| 		self.assertEqual(sum([i[0] for i in queue]), 500.0) | ||||
| 
 | ||||
| 	def test_repack_entry_same_item_overconsume(self): | ||||
| 		""" | ||||
| 		Over consume item and have less repacked item qty (same warehouse). | ||||
| 		Ledger: | ||||
| 		Item	| Qty  | Voucher | ||||
| 		------------------------ | ||||
| 		Item 1  | 500  | 001 | ||||
| 		Item 1  | -100 | 002 (repack) | ||||
| 		Item 1  | 50   | 002 (repack) | ||||
| 
 | ||||
| 		Case most likely for batch items. Test time bucket computation. | ||||
| 		""" | ||||
| 		sle = [ | ||||
| 			frappe._dict( # stock up item | ||||
| 				name="Flask Item", | ||||
| 				actual_qty=500, qty_after_transaction=500, | ||||
| 				warehouse="WH 1", | ||||
| 				posting_date="2021-12-03", voucher_type="Stock Entry", | ||||
| 				voucher_no="001", | ||||
| 				has_serial_no=False, serial_no=None | ||||
| 			), | ||||
| 			frappe._dict( | ||||
| 				name="Flask Item", | ||||
| 				actual_qty=(-100), qty_after_transaction=400, | ||||
| 				warehouse="WH 1", | ||||
| 				posting_date="2021-12-04", voucher_type="Stock Entry", | ||||
| 				voucher_no="002", | ||||
| 				has_serial_no=False, serial_no=None | ||||
| 			), | ||||
| 			frappe._dict( | ||||
| 				name="Flask Item", | ||||
| 				actual_qty=50, qty_after_transaction=450, | ||||
| 				warehouse="WH 1", | ||||
| 				posting_date="2021-12-04", voucher_type="Stock Entry", | ||||
| 				voucher_no="002", | ||||
| 				has_serial_no=False, serial_no=None | ||||
| 			), | ||||
| 		] | ||||
| 		slots = FIFOSlots(self.filters, sle).generate() | ||||
| 		item_result = slots["Flask Item"] | ||||
| 		queue = item_result["fifo_queue"] | ||||
| 
 | ||||
| 		self.assertEqual(item_result["total_qty"], 450.0) | ||||
| 		self.assertEqual(queue[0][0], 400.0) | ||||
| 		self.assertEqual(queue[1][0], 50.0) | ||||
| 		# check if time buckets add up to balance qty | ||||
| 		self.assertEqual(sum([i[0] for i in queue]), 450.0) | ||||
| 
 | ||||
| 	def test_repack_entry_same_item_overconsume_with_split_rows(self): | ||||
| 		""" | ||||
| 		Over consume item and have less repacked item qty (same warehouse). | ||||
| 		Ledger: | ||||
| 		Item	| Qty  | Voucher | ||||
| 		------------------------ | ||||
| 		Item 1  | 20   | 001 | ||||
| 		Item 1  | -50  | 002 (repack) | ||||
| 		Item 1  | -50  | 002 (repack) | ||||
| 		Item 1  | 50   | 002 (repack) | ||||
| 		""" | ||||
| 		sle = [ | ||||
| 			frappe._dict( # stock up item | ||||
| 				name="Flask Item", | ||||
| 				actual_qty=20, qty_after_transaction=20, | ||||
| 				warehouse="WH 1", | ||||
| 				posting_date="2021-12-03", voucher_type="Stock Entry", | ||||
| 				voucher_no="001", | ||||
| 				has_serial_no=False, serial_no=None | ||||
| 			), | ||||
| 			frappe._dict( | ||||
| 				name="Flask Item", | ||||
| 				actual_qty=(-50), qty_after_transaction=(-30), | ||||
| 				warehouse="WH 1", | ||||
| 				posting_date="2021-12-04", voucher_type="Stock Entry", | ||||
| 				voucher_no="002", | ||||
| 				has_serial_no=False, serial_no=None | ||||
| 			), | ||||
| 			frappe._dict( | ||||
| 				name="Flask Item", | ||||
| 				actual_qty=(-50), qty_after_transaction=(-80), | ||||
| 				warehouse="WH 1", | ||||
| 				posting_date="2021-12-04", voucher_type="Stock Entry", | ||||
| 				voucher_no="002", | ||||
| 				has_serial_no=False, serial_no=None | ||||
| 			), | ||||
| 			frappe._dict( | ||||
| 				name="Flask Item", | ||||
| 				actual_qty=50, qty_after_transaction=(-30), | ||||
| 				warehouse="WH 1", | ||||
| 				posting_date="2021-12-04", voucher_type="Stock Entry", | ||||
| 				voucher_no="002", | ||||
| 				has_serial_no=False, serial_no=None | ||||
| 			), | ||||
| 		] | ||||
| 		fifo_slots = FIFOSlots(self.filters, sle) | ||||
| 		slots = fifo_slots.generate() | ||||
| 		item_result = slots["Flask Item"] | ||||
| 		queue = item_result["fifo_queue"] | ||||
| 
 | ||||
| 		self.assertEqual(item_result["total_qty"], -30.0) | ||||
| 		self.assertEqual(queue[0][0], -30.0) | ||||
| 
 | ||||
| 		# check transfer bucket | ||||
| 		transfer_bucket = fifo_slots.transferred_item_details[('002', 'Flask Item', 'WH 1')] | ||||
| 		self.assertEqual(transfer_bucket[0][0], 50) | ||||
| 
 | ||||
| 	def test_repack_entry_same_item_overproduce(self): | ||||
| 		""" | ||||
| 		Under consume item and have more repacked item qty (same warehouse). | ||||
| 		Ledger: | ||||
| 		Item	| Qty  | Voucher | ||||
| 		------------------------ | ||||
| 		Item 1  | 500  | 001 | ||||
| 		Item 1  | -50  | 002 (repack) | ||||
| 		Item 1  | 100  | 002 (repack) | ||||
| 
 | ||||
| 		Case most likely for batch items. Test time bucket computation. | ||||
| 		""" | ||||
| 		sle = [ | ||||
| 			frappe._dict( # stock up item | ||||
| 				name="Flask Item", | ||||
| 				actual_qty=500, qty_after_transaction=500, | ||||
| 				warehouse="WH 1", | ||||
| 				posting_date="2021-12-03", voucher_type="Stock Entry", | ||||
| 				voucher_no="001", | ||||
| 				has_serial_no=False, serial_no=None | ||||
| 			), | ||||
| 			frappe._dict( | ||||
| 				name="Flask Item", | ||||
| 				actual_qty=(-50), qty_after_transaction=450, | ||||
| 				warehouse="WH 1", | ||||
| 				posting_date="2021-12-04", voucher_type="Stock Entry", | ||||
| 				voucher_no="002", | ||||
| 				has_serial_no=False, serial_no=None | ||||
| 			), | ||||
| 			frappe._dict( | ||||
| 				name="Flask Item", | ||||
| 				actual_qty=100, qty_after_transaction=550, | ||||
| 				warehouse="WH 1", | ||||
| 				posting_date="2021-12-04", voucher_type="Stock Entry", | ||||
| 				voucher_no="002", | ||||
| 				has_serial_no=False, serial_no=None | ||||
| 			), | ||||
| 		] | ||||
| 		slots = FIFOSlots(self.filters, sle).generate() | ||||
| 		item_result = slots["Flask Item"] | ||||
| 		queue = item_result["fifo_queue"] | ||||
| 
 | ||||
| 		self.assertEqual(item_result["total_qty"], 550.0) | ||||
| 		self.assertEqual(queue[0][0], 450.0) | ||||
| 		self.assertEqual(queue[1][0], 50.0) | ||||
| 		self.assertEqual(queue[2][0], 50.0) | ||||
| 		# check if time buckets add up to balance qty | ||||
| 		self.assertEqual(sum([i[0] for i in queue]), 550.0) | ||||
| 
 | ||||
| 	def test_repack_entry_same_item_overproduce_with_split_rows(self): | ||||
| 		""" | ||||
| 		Over consume item and have less repacked item qty (same warehouse). | ||||
| 		Ledger: | ||||
| 		Item	| Qty  | Voucher | ||||
| 		------------------------ | ||||
| 		Item 1  | 20   | 001 | ||||
| 		Item 1  | -50  | 002 (repack) | ||||
| 		Item 1  | 50  | 002 (repack) | ||||
| 		Item 1  | 50   | 002 (repack) | ||||
| 		""" | ||||
| 		sle = [ | ||||
| 			frappe._dict( # stock up item | ||||
| 				name="Flask Item", | ||||
| 				actual_qty=20, qty_after_transaction=20, | ||||
| 				warehouse="WH 1", | ||||
| 				posting_date="2021-12-03", voucher_type="Stock Entry", | ||||
| 				voucher_no="001", | ||||
| 				has_serial_no=False, serial_no=None | ||||
| 			), | ||||
| 			frappe._dict( | ||||
| 				name="Flask Item", | ||||
| 				actual_qty=(-50), qty_after_transaction=(-30), | ||||
| 				warehouse="WH 1", | ||||
| 				posting_date="2021-12-04", voucher_type="Stock Entry", | ||||
| 				voucher_no="002", | ||||
| 				has_serial_no=False, serial_no=None | ||||
| 			), | ||||
| 			frappe._dict( | ||||
| 				name="Flask Item", | ||||
| 				actual_qty=50, qty_after_transaction=20, | ||||
| 				warehouse="WH 1", | ||||
| 				posting_date="2021-12-04", voucher_type="Stock Entry", | ||||
| 				voucher_no="002", | ||||
| 				has_serial_no=False, serial_no=None | ||||
| 			), | ||||
| 			frappe._dict( | ||||
| 				name="Flask Item", | ||||
| 				actual_qty=50, qty_after_transaction=70, | ||||
| 				warehouse="WH 1", | ||||
| 				posting_date="2021-12-04", voucher_type="Stock Entry", | ||||
| 				voucher_no="002", | ||||
| 				has_serial_no=False, serial_no=None | ||||
| 			), | ||||
| 		] | ||||
| 		fifo_slots = FIFOSlots(self.filters, sle) | ||||
| 		slots = fifo_slots.generate() | ||||
| 		item_result = slots["Flask Item"] | ||||
| 		queue = item_result["fifo_queue"] | ||||
| 
 | ||||
| 		self.assertEqual(item_result["total_qty"], 70.0) | ||||
| 		self.assertEqual(queue[0][0], 20.0) | ||||
| 		self.assertEqual(queue[1][0], 50.0) | ||||
| 
 | ||||
| 		# check transfer bucket | ||||
| 		transfer_bucket = fifo_slots.transferred_item_details[('002', 'Flask Item', 'WH 1')] | ||||
| 		self.assertFalse(transfer_bucket) | ||||
| 
 | ||||
| 	def test_negative_stock_same_voucher(self): | ||||
| 		""" | ||||
| 		Test negative stock scenario in transfer bucket via repack entry (same wh). | ||||
| 		Ledger: | ||||
| 		Item	| Qty  | Voucher | ||||
| 		------------------------ | ||||
| 		Item 1  | -50  | 001 | ||||
| 		Item 1  | -50  | 001 | ||||
| 		Item 1  | 30   | 001 | ||||
| 		Item 1  | 80   | 001 | ||||
| 		""" | ||||
| 		sle = [ | ||||
| 			frappe._dict( # stock up item | ||||
| 				name="Flask Item", | ||||
| 				actual_qty=(-50), qty_after_transaction=(-50), | ||||
| 				warehouse="WH 1", | ||||
| 				posting_date="2021-12-01", voucher_type="Stock Entry", | ||||
| 				voucher_no="001", | ||||
| 				has_serial_no=False, serial_no=None | ||||
| 			), | ||||
| 			frappe._dict( # stock up item | ||||
| 				name="Flask Item", | ||||
| 				actual_qty=(-50), qty_after_transaction=(-100), | ||||
| 				warehouse="WH 1", | ||||
| 				posting_date="2021-12-01", voucher_type="Stock Entry", | ||||
| 				voucher_no="001", | ||||
| 				has_serial_no=False, serial_no=None | ||||
| 			), | ||||
| 			frappe._dict( # stock up item | ||||
| 				name="Flask Item", | ||||
| 				actual_qty=30, qty_after_transaction=(-70), | ||||
| 				warehouse="WH 1", | ||||
| 				posting_date="2021-12-01", voucher_type="Stock Entry", | ||||
| 				voucher_no="001", | ||||
| 				has_serial_no=False, serial_no=None | ||||
| 			), | ||||
| 		] | ||||
| 		fifo_slots = FIFOSlots(self.filters, sle) | ||||
| 		slots = fifo_slots.generate() | ||||
| 		item_result = slots["Flask Item"] | ||||
| 
 | ||||
| 		# check transfer bucket | ||||
| 		transfer_bucket = fifo_slots.transferred_item_details[('001', 'Flask Item', 'WH 1')] | ||||
| 		self.assertEqual(transfer_bucket[0][0], 20) | ||||
| 		self.assertEqual(transfer_bucket[1][0], 50) | ||||
| 		self.assertEqual(item_result["fifo_queue"][0][0], -70.0) | ||||
| 
 | ||||
| 		sle.append(frappe._dict( | ||||
| 			name="Flask Item", | ||||
| 			actual_qty=80, qty_after_transaction=10, | ||||
| 			warehouse="WH 1", | ||||
| 			posting_date="2021-12-01", voucher_type="Stock Entry", | ||||
| 			voucher_no="001", | ||||
| 			has_serial_no=False, serial_no=None | ||||
| 		)) | ||||
| 
 | ||||
| 		fifo_slots = FIFOSlots(self.filters, sle) | ||||
| 		slots = fifo_slots.generate() | ||||
| 		item_result = slots["Flask Item"] | ||||
| 
 | ||||
| 		transfer_bucket = fifo_slots.transferred_item_details[('001', 'Flask Item', 'WH 1')] | ||||
| 		self.assertFalse(transfer_bucket) | ||||
| 		self.assertEqual(item_result["fifo_queue"][0][0], 10.0) | ||||
| 
 | ||||
| 	def test_precision(self): | ||||
| 		"Test if final balance qty is rounded off correctly." | ||||
| 		sle = [ | ||||
| 			frappe._dict( # stock up item | ||||
| 				name="Flask Item", | ||||
| 				actual_qty=0.3, qty_after_transaction=0.3, | ||||
| 				warehouse="WH 1", | ||||
| 				posting_date="2021-12-01", voucher_type="Stock Entry", | ||||
| 				voucher_no="001", | ||||
| 				has_serial_no=False, serial_no=None | ||||
| 			), | ||||
| 			frappe._dict( # stock up item | ||||
| 				name="Flask Item", | ||||
| 				actual_qty=0.6, qty_after_transaction=0.9, | ||||
| 				warehouse="WH 1", | ||||
| 				posting_date="2021-12-01", voucher_type="Stock Entry", | ||||
| 				voucher_no="001", | ||||
| 				has_serial_no=False, serial_no=None | ||||
| 			), | ||||
| 		] | ||||
| 
 | ||||
| 		slots = FIFOSlots(self.filters, sle).generate() | ||||
| 		report_data = format_report_data(self.filters, slots, self.filters["to_date"]) | ||||
| 		row = report_data[0] # first row in report | ||||
| 		bal_qty = row[5] | ||||
| 		range_qty_sum = sum([i for i in row[7:11]]) # get sum of range balance | ||||
| 
 | ||||
| 		# check if value of Available Qty column matches with range bucket post format | ||||
| 		self.assertEqual(bal_qty, 0.9) | ||||
| 		self.assertEqual(bal_qty, range_qty_sum) | ||||
| 
 | ||||
| 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 | ||||
| @ -167,7 +167,7 @@ def get_columns(): | ||||
| 		{ | ||||
| 			"fieldname": "stock_queue", | ||||
| 			"fieldtype": "Data", | ||||
| 			"label": "FIFO Queue", | ||||
| 			"label": "FIFO/LIFO Queue", | ||||
| 		}, | ||||
| 
 | ||||
| 		{ | ||||
|  | ||||
| @ -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 @@ def update_bin_qty(item_code, warehouse, qty_dict=None): | ||||
| 			bin.set(field, flt(value)) | ||||
| 			mismatch = True | ||||
| 
 | ||||
| 	bin.modified = now() | ||||
| 	if mismatch: | ||||
| 		bin.set_projected_qty() | ||||
| 		bin.db_update() | ||||
| @ -227,8 +227,6 @@ def set_stock_balance_as_per_serial_no(item_code=None, posting_date=None, postin | ||||
| 			"sle_id": sle_doc.name | ||||
| 		}) | ||||
| 
 | ||||
| 		update_bin(args) | ||||
| 
 | ||||
| 		create_repost_item_valuation_entry({ | ||||
| 			"item_code": d[0], | ||||
| 			"warehouse": d[1], | ||||
|  | ||||
| @ -3,6 +3,7 @@ | ||||
| 
 | ||||
| import copy | ||||
| import json | ||||
| from typing import Optional | ||||
| 
 | ||||
| import frappe | ||||
| from frappe import _ | ||||
| @ -16,7 +17,7 @@ from erpnext.stock.utils import ( | ||||
| 	get_or_make_bin, | ||||
| 	get_valuation_method, | ||||
| ) | ||||
| from erpnext.stock.valuation import FIFOValuation | ||||
| from erpnext.stock.valuation import FIFOValuation, LIFOValuation | ||||
| 
 | ||||
| 
 | ||||
| class NegativeStockError(frappe.ValidationError): pass | ||||
| @ -268,11 +269,10 @@ class update_entries_after(object): | ||||
| 		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 | ||||
| 
 | ||||
| @ -461,7 +461,7 @@ class update_entries_after(object): | ||||
| 					self.wh_data.qty_after_transaction += flt(sle.actual_qty) | ||||
| 					self.wh_data.stock_value = flt(self.wh_data.qty_after_transaction) * flt(self.wh_data.valuation_rate) | ||||
| 				else: | ||||
| 					self.update_fifo_values(sle) | ||||
| 					self.update_queue_values(sle) | ||||
| 					self.wh_data.qty_after_transaction += flt(sle.actual_qty) | ||||
| 
 | ||||
| 		# rounding as per precision | ||||
| @ -701,14 +701,18 @@ class update_entries_after(object): | ||||
| 						sle.voucher_type, sle.voucher_no, self.allow_zero_rate, | ||||
| 						currency=erpnext.get_company_currency(sle.company), company=sle.company) | ||||
| 
 | ||||
| 	def update_fifo_values(self, sle): | ||||
| 	def update_queue_values(self, sle): | ||||
| 		incoming_rate = flt(sle.incoming_rate) | ||||
| 		actual_qty = flt(sle.actual_qty) | ||||
| 		outgoing_rate = flt(sle.outgoing_rate) | ||||
| 
 | ||||
| 		fifo_queue = FIFOValuation(self.wh_data.stock_queue) | ||||
| 		if self.valuation_method == "LIFO": | ||||
| 			stock_queue = LIFOValuation(self.wh_data.stock_queue) | ||||
| 		else: | ||||
| 			stock_queue = FIFOValuation(self.wh_data.stock_queue) | ||||
| 
 | ||||
| 		if actual_qty > 0: | ||||
| 			fifo_queue.add_stock(qty=actual_qty, rate=incoming_rate) | ||||
| 			stock_queue.add_stock(qty=actual_qty, rate=incoming_rate) | ||||
| 		else: | ||||
| 			def rate_generator() -> float: | ||||
| 				allow_zero_valuation_rate = self.check_if_allow_zero_valuation_rate(sle.voucher_type, sle.voucher_detail_no) | ||||
| @ -719,11 +723,11 @@ class update_entries_after(object): | ||||
| 				else: | ||||
| 					return 0.0 | ||||
| 
 | ||||
| 			fifo_queue.remove_stock(qty=abs(actual_qty), outgoing_rate=outgoing_rate, rate_generator=rate_generator) | ||||
| 			stock_queue.remove_stock(qty=abs(actual_qty), outgoing_rate=outgoing_rate, rate_generator=rate_generator) | ||||
| 
 | ||||
| 		stock_qty, stock_value = fifo_queue.get_total_stock_and_value() | ||||
| 		stock_qty, stock_value = stock_queue.get_total_stock_and_value() | ||||
| 
 | ||||
| 		self.wh_data.stock_queue = fifo_queue.get_state() | ||||
| 		self.wh_data.stock_queue = stock_queue.state | ||||
| 		self.wh_data.stock_value = stock_value | ||||
| 		if stock_qty: | ||||
| 			self.wh_data.valuation_rate = stock_value / stock_qty | ||||
| @ -1045,10 +1049,7 @@ def get_datetime_limit_condition(detail): | ||||
| 		)""" | ||||
| 
 | ||||
| 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 | ||||
| @ -1117,3 +1118,11 @@ def get_future_sle_with_negative_batch_qty(args): | ||||
| 			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 | ||||
|  | ||||
| @ -1,16 +1,21 @@ | ||||
| import json | ||||
| import unittest | ||||
| 
 | ||||
| import frappe | ||||
| from hypothesis import given | ||||
| from hypothesis import strategies as st | ||||
| 
 | ||||
| from erpnext.stock.valuation import FIFOValuation, _round_off_if_near_zero | ||||
| from erpnext.stock.doctype.item.test_item import make_item | ||||
| from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry | ||||
| from erpnext.stock.valuation import FIFOValuation, LIFOValuation, _round_off_if_near_zero | ||||
| from erpnext.tests.utils import ERPNextTestCase | ||||
| 
 | ||||
| qty_gen = st.floats(min_value=-1e6, max_value=1e6) | ||||
| value_gen = st.floats(min_value=1, max_value=1e6) | ||||
| stock_queue_generator = st.lists(st.tuples(qty_gen, value_gen), min_size=10) | ||||
| 
 | ||||
| 
 | ||||
| class TestFifoValuation(unittest.TestCase): | ||||
| class TestFIFOValuation(unittest.TestCase): | ||||
| 
 | ||||
| 	def setUp(self): | ||||
| 		self.queue = FIFOValuation([]) | ||||
| @ -164,3 +169,184 @@ class TestFifoValuation(unittest.TestCase): | ||||
| 				total_value -= sum(q * r for q, r in consumed) | ||||
| 			self.assertTotalQty(total_qty) | ||||
| 			self.assertTotalValue(total_value) | ||||
| 
 | ||||
| 
 | ||||
| class TestLIFOValuation(unittest.TestCase): | ||||
| 
 | ||||
| 	def setUp(self): | ||||
| 		self.stack = LIFOValuation([]) | ||||
| 
 | ||||
| 	def tearDown(self): | ||||
| 		qty, value = self.stack.get_total_stock_and_value() | ||||
| 		self.assertTotalQty(qty) | ||||
| 		self.assertTotalValue(value) | ||||
| 
 | ||||
| 	def assertTotalQty(self, qty): | ||||
| 		self.assertAlmostEqual(sum(q for q, _ in self.stack), qty, msg=f"stack: {self.stack}", places=4) | ||||
| 
 | ||||
| 	def assertTotalValue(self, value): | ||||
| 		self.assertAlmostEqual(sum(q * r for q, r in self.stack), value, msg=f"stack: {self.stack}", places=2) | ||||
| 
 | ||||
| 	def test_simple_addition(self): | ||||
| 		self.stack.add_stock(1, 10) | ||||
| 		self.assertTotalQty(1) | ||||
| 
 | ||||
| 	def test_merge_new_stock(self): | ||||
| 		self.stack.add_stock(1, 10) | ||||
| 		self.stack.add_stock(1, 10) | ||||
| 		self.assertEqual(self.stack, [[2, 10]]) | ||||
| 
 | ||||
| 	def test_simple_removal(self): | ||||
| 		self.stack.add_stock(1, 10) | ||||
| 		self.stack.remove_stock(1) | ||||
| 		self.assertTotalQty(0) | ||||
| 
 | ||||
| 	def test_adding_negative_stock_keeps_rate(self): | ||||
| 		self.stack = LIFOValuation([[-5.0, 100]]) | ||||
| 		self.stack.add_stock(1, 10) | ||||
| 		self.assertEqual(self.stack, [[-4, 100]]) | ||||
| 
 | ||||
| 	def test_adding_negative_stock_updates_rate(self): | ||||
| 		self.stack = LIFOValuation([[-5.0, 100]]) | ||||
| 		self.stack.add_stock(6, 10) | ||||
| 		self.assertEqual(self.stack, [[1, 10]]) | ||||
| 
 | ||||
| 	def test_rounding_off(self): | ||||
| 		self.stack.add_stock(1.0, 1.0) | ||||
| 		self.stack.remove_stock(1.0 - 1e-9) | ||||
| 		self.assertTotalQty(0) | ||||
| 
 | ||||
| 	def test_lifo_consumption(self): | ||||
| 		self.stack.add_stock(10, 10) | ||||
| 		self.stack.add_stock(10, 20) | ||||
| 		consumed = self.stack.remove_stock(15) | ||||
| 		self.assertEqual(consumed, [[10, 20], [5, 10]]) | ||||
| 		self.assertTotalQty(5) | ||||
| 
 | ||||
| 	def test_lifo_consumption_going_negative(self): | ||||
| 		self.stack.add_stock(10, 10) | ||||
| 		self.stack.add_stock(10, 20) | ||||
| 		consumed = self.stack.remove_stock(25) | ||||
| 		self.assertEqual(consumed, [[10, 20], [10, 10], [5, 10]]) | ||||
| 		self.assertTotalQty(-5) | ||||
| 
 | ||||
| 	def test_lifo_consumption_multiple(self): | ||||
| 		self.stack.add_stock(1, 1) | ||||
| 		self.stack.add_stock(2, 2) | ||||
| 		consumed = self.stack.remove_stock(1) | ||||
| 		self.assertEqual(consumed, [[1, 2]]) | ||||
| 
 | ||||
| 		self.stack.add_stock(3, 3) | ||||
| 		consumed = self.stack.remove_stock(4) | ||||
| 		self.assertEqual(consumed, [[3, 3], [1, 2]]) | ||||
| 
 | ||||
| 		self.stack.add_stock(4, 4) | ||||
| 		consumed = self.stack.remove_stock(5) | ||||
| 		self.assertEqual(consumed, [[4, 4], [1, 1]]) | ||||
| 
 | ||||
| 		self.stack.add_stock(5, 5) | ||||
| 		consumed = self.stack.remove_stock(5) | ||||
| 		self.assertEqual(consumed, [[5, 5]]) | ||||
| 
 | ||||
| 
 | ||||
| 	@given(stock_queue_generator) | ||||
| 	def test_lifo_qty_hypothesis(self, stock_stack): | ||||
| 		self.stack = LIFOValuation([]) | ||||
| 		total_qty = 0 | ||||
| 
 | ||||
| 		for qty, rate in stock_stack: | ||||
| 			if qty == 0: | ||||
| 				continue | ||||
| 			if qty > 0: | ||||
| 				self.stack.add_stock(qty, rate) | ||||
| 				total_qty += qty | ||||
| 			else: | ||||
| 				qty = abs(qty) | ||||
| 				consumed = self.stack.remove_stock(qty) | ||||
| 				self.assertAlmostEqual(qty, sum(q for q, _ in consumed), msg=f"incorrect consumption {consumed}") | ||||
| 				total_qty -= qty | ||||
| 			self.assertTotalQty(total_qty) | ||||
| 
 | ||||
| 	@given(stock_queue_generator) | ||||
| 	def test_lifo_qty_value_nonneg_hypothesis(self, stock_stack): | ||||
| 		self.stack = LIFOValuation([]) | ||||
| 		total_qty = 0.0 | ||||
| 		total_value = 0.0 | ||||
| 
 | ||||
| 		for qty, rate in stock_stack: | ||||
| 			# don't allow negative stock | ||||
| 			if qty == 0 or total_qty + qty < 0 or abs(qty) < 0.1: | ||||
| 				continue | ||||
| 			if qty > 0: | ||||
| 				self.stack.add_stock(qty, rate) | ||||
| 				total_qty += qty | ||||
| 				total_value += qty * rate | ||||
| 			else: | ||||
| 				qty = abs(qty) | ||||
| 				consumed = self.stack.remove_stock(qty) | ||||
| 				self.assertAlmostEqual(qty, sum(q for q, _ in consumed), msg=f"incorrect consumption {consumed}") | ||||
| 				total_qty -= qty | ||||
| 				total_value -= sum(q * r for q, r in consumed) | ||||
| 			self.assertTotalQty(total_qty) | ||||
| 			self.assertTotalValue(total_value) | ||||
| 
 | ||||
| class TestLIFOValuationSLE(ERPNextTestCase): | ||||
| 	ITEM_CODE = "_Test LIFO item" | ||||
| 	WAREHOUSE = "_Test Warehouse - _TC" | ||||
| 
 | ||||
| 	@classmethod | ||||
| 	def setUpClass(cls) -> None: | ||||
| 		super().setUpClass() | ||||
| 		make_item(cls.ITEM_CODE, {"valuation_method": "LIFO"}) | ||||
| 
 | ||||
| 	def _make_stock_entry(self, qty, rate=None): | ||||
| 		kwargs = { | ||||
| 			"item_code": self.ITEM_CODE, | ||||
| 			"from_warehouse" if qty < 0 else "to_warehouse": self.WAREHOUSE, | ||||
| 			"rate": rate, | ||||
| 			"qty": abs(qty), | ||||
| 		} | ||||
| 		return make_stock_entry(**kwargs) | ||||
| 
 | ||||
| 	def assertStockQueue(self, se, expected_queue): | ||||
| 		sle_name = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": se.name, "is_cancelled": 0, "voucher_type": "Stock Entry"}) | ||||
| 		sle = frappe.get_doc("Stock Ledger Entry", sle_name) | ||||
| 
 | ||||
| 		stock_queue = json.loads(sle.stock_queue) | ||||
| 
 | ||||
| 		total_qty, total_value = LIFOValuation(stock_queue).get_total_stock_and_value() | ||||
| 		self.assertEqual(sle.qty_after_transaction, total_qty) | ||||
| 		self.assertEqual(sle.stock_value, total_value) | ||||
| 
 | ||||
| 		if total_qty > 0: | ||||
| 			self.assertEqual(stock_queue, expected_queue) | ||||
| 
 | ||||
| 
 | ||||
| 	def test_lifo_values(self): | ||||
| 
 | ||||
| 		in1 = self._make_stock_entry(1, 1) | ||||
| 		self.assertStockQueue(in1, [[1, 1]]) | ||||
| 
 | ||||
| 		in2 = self._make_stock_entry(2, 2) | ||||
| 		self.assertStockQueue(in2, [[1, 1], [2, 2]]) | ||||
| 
 | ||||
| 		out1 = self._make_stock_entry(-1) | ||||
| 		self.assertStockQueue(out1, [[1, 1], [1, 2]]) | ||||
| 
 | ||||
| 		in3 = self._make_stock_entry(3, 3) | ||||
| 		self.assertStockQueue(in3, [[1, 1], [1, 2], [3, 3]]) | ||||
| 
 | ||||
| 		out2 = self._make_stock_entry(-4) | ||||
| 		self.assertStockQueue(out2, [[1, 1]]) | ||||
| 
 | ||||
| 		in4 = self._make_stock_entry(4, 4) | ||||
| 		self.assertStockQueue(in4, [[1, 1], [4,4]]) | ||||
| 
 | ||||
| 		out3 = self._make_stock_entry(-5) | ||||
| 		self.assertStockQueue(out3, []) | ||||
| 
 | ||||
| 		in5 = self._make_stock_entry(5, 5) | ||||
| 		self.assertStockQueue(in5, [[5, 5]]) | ||||
| 
 | ||||
| 		out5 = self._make_stock_entry(-5) | ||||
| 		self.assertStockQueue(out5, []) | ||||
|  | ||||
| @ -9,6 +9,7 @@ from frappe import _ | ||||
| from frappe.utils import cstr, flt, get_link_to_form, nowdate, nowtime | ||||
| 
 | ||||
| import erpnext | ||||
| from erpnext.stock.valuation import FIFOValuation, LIFOValuation | ||||
| 
 | ||||
| 
 | ||||
| class InvalidWarehouseCompany(frappe.ValidationError): pass | ||||
| @ -205,16 +206,6 @@ def _create_bin(item_code, warehouse): | ||||
| 
 | ||||
| 	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""" | ||||
| @ -228,10 +219,10 @@ def get_incoming_rate(args, raise_error_if_no_rate=True): | ||||
| 	else: | ||||
| 		valuation_method = get_valuation_method(args.get("item_code")) | ||||
| 		previous_sle = get_previous_sle(args) | ||||
| 		if valuation_method == 'FIFO': | ||||
| 		if valuation_method in ('FIFO', 'LIFO'): | ||||
| 			if previous_sle: | ||||
| 				previous_stock_queue = json.loads(previous_sle.get('stock_queue', '[]') or '[]') | ||||
| 				in_rate = get_fifo_rate(previous_stock_queue, args.get("qty") or 0) if previous_stock_queue else 0 | ||||
| 				in_rate = _get_fifo_lifo_rate(previous_stock_queue, args.get("qty") or 0, valuation_method) if previous_stock_queue else 0 | ||||
| 		elif valuation_method == 'Moving Average': | ||||
| 			in_rate = previous_sle.get('valuation_rate') or 0 | ||||
| 
 | ||||
| @ -261,29 +252,25 @@ def get_valuation_method(item_code): | ||||
| 
 | ||||
| def get_fifo_rate(previous_stock_queue, qty): | ||||
| 	"""get FIFO (average) Rate from Queue""" | ||||
| 	if flt(qty) >= 0: | ||||
| 		total = sum(f[0] for f in previous_stock_queue) | ||||
| 		return sum(flt(f[0]) * flt(f[1]) for f in previous_stock_queue) / flt(total) if total else 0.0 | ||||
| 	else: | ||||
| 		available_qty_for_outgoing, outgoing_cost = 0, 0 | ||||
| 		qty_to_pop = abs(flt(qty)) | ||||
| 		while qty_to_pop and previous_stock_queue: | ||||
| 			batch = previous_stock_queue[0] | ||||
| 			if 0 < batch[0] <= qty_to_pop: | ||||
| 				# if batch qty > 0 | ||||
| 				# not enough or exactly same qty in current batch, clear batch | ||||
| 				available_qty_for_outgoing += flt(batch[0]) | ||||
| 				outgoing_cost += flt(batch[0]) * flt(batch[1]) | ||||
| 				qty_to_pop -= batch[0] | ||||
| 				previous_stock_queue.pop(0) | ||||
| 			else: | ||||
| 				# all from current batch | ||||
| 				available_qty_for_outgoing += flt(qty_to_pop) | ||||
| 				outgoing_cost += flt(qty_to_pop) * flt(batch[1]) | ||||
| 				batch[0] -= qty_to_pop | ||||
| 				qty_to_pop = 0 | ||||
| 	return _get_fifo_lifo_rate(previous_stock_queue, qty, "FIFO") | ||||
| 
 | ||||
| 		return outgoing_cost / available_qty_for_outgoing | ||||
| def get_lifo_rate(previous_stock_queue, qty): | ||||
| 	"""get LIFO (average) Rate from Queue""" | ||||
| 	return _get_fifo_lifo_rate(previous_stock_queue, qty, "LIFO") | ||||
| 
 | ||||
| 
 | ||||
| def _get_fifo_lifo_rate(previous_stock_queue, qty, method): | ||||
| 	ValuationKlass = LIFOValuation if method == "LIFO" else FIFOValuation | ||||
| 
 | ||||
| 	stock_queue = ValuationKlass(previous_stock_queue) | ||||
| 	if flt(qty) >= 0: | ||||
| 		total_qty, total_value = stock_queue.get_total_stock_and_value() | ||||
| 		return total_value / total_qty if total_qty else 0.0 | ||||
| 	else: | ||||
| 		popped_bins = stock_queue.remove_stock(abs(flt(qty))) | ||||
| 
 | ||||
| 		total_qty, total_value = ValuationKlass(popped_bins).get_total_stock_and_value() | ||||
| 		return total_value / total_qty if total_qty else 0.0 | ||||
| 
 | ||||
| def get_valid_serial_nos(sr_nos, qty=0, item_code=''): | ||||
| 	"""split serial nos, validate and return list of valid serial nos""" | ||||
|  | ||||
| @ -1,15 +1,54 @@ | ||||
| from abc import ABC, abstractmethod, abstractproperty | ||||
| from typing import Callable, List, NewType, Optional, Tuple | ||||
| 
 | ||||
| from frappe.utils import flt | ||||
| 
 | ||||
| FifoBin = NewType("FifoBin", List[float]) | ||||
| StockBin = NewType("StockBin", List[float])  # [[qty, rate], ...] | ||||
| 
 | ||||
| # Indexes of values inside FIFO bin 2-tuple | ||||
| QTY = 0 | ||||
| RATE = 1 | ||||
| 
 | ||||
| 
 | ||||
| class FIFOValuation: | ||||
| class BinWiseValuation(ABC): | ||||
| 
 | ||||
| 	@abstractmethod | ||||
| 	def add_stock(self, qty: float, rate: float) -> None: | ||||
| 		pass | ||||
| 
 | ||||
| 	@abstractmethod | ||||
| 	def remove_stock( | ||||
| 		self, qty: float, outgoing_rate: float = 0.0, rate_generator: Callable[[], float] = None | ||||
| 	) -> List[StockBin]: | ||||
| 		pass | ||||
| 
 | ||||
| 	@abstractproperty | ||||
| 	def state(self) -> List[StockBin]: | ||||
| 		pass | ||||
| 
 | ||||
| 	def get_total_stock_and_value(self) -> Tuple[float, float]: | ||||
| 		total_qty = 0.0 | ||||
| 		total_value = 0.0 | ||||
| 
 | ||||
| 		for qty, rate in self.state: | ||||
| 			total_qty += flt(qty) | ||||
| 			total_value += flt(qty) * flt(rate) | ||||
| 
 | ||||
| 		return _round_off_if_near_zero(total_qty), _round_off_if_near_zero(total_value) | ||||
| 
 | ||||
| 	def __repr__(self): | ||||
| 		return str(self.state) | ||||
| 
 | ||||
| 	def __iter__(self): | ||||
| 		return iter(self.state) | ||||
| 
 | ||||
| 	def __eq__(self, other): | ||||
| 		if isinstance(other, list): | ||||
| 			return self.state == other | ||||
| 		return type(self) == type(other) and self.state == other.state | ||||
| 
 | ||||
| 
 | ||||
| class FIFOValuation(BinWiseValuation): | ||||
| 	"""Valuation method where a queue of all the incoming stock is maintained. | ||||
| 
 | ||||
| 	New stock is added at end of the queue. | ||||
| @ -24,34 +63,14 @@ class FIFOValuation: | ||||
| 	# ref: https://docs.python.org/3/reference/datamodel.html#slots | ||||
| 	__slots__ = ["queue",] | ||||
| 
 | ||||
| 	def __init__(self, state: Optional[List[FifoBin]]): | ||||
| 		self.queue: List[FifoBin] = state if state is not None else [] | ||||
| 	def __init__(self, state: Optional[List[StockBin]]): | ||||
| 		self.queue: List[StockBin] = state if state is not None else [] | ||||
| 
 | ||||
| 	def __repr__(self): | ||||
| 		return str(self.queue) | ||||
| 
 | ||||
| 	def __iter__(self): | ||||
| 		return iter(self.queue) | ||||
| 
 | ||||
| 	def __eq__(self, other): | ||||
| 		if isinstance(other, list): | ||||
| 			return self.queue == other | ||||
| 		return self.queue == other.queue | ||||
| 
 | ||||
| 	def get_state(self) -> List[FifoBin]: | ||||
| 	@property | ||||
| 	def state(self) -> List[StockBin]: | ||||
| 		"""Get current state of queue.""" | ||||
| 		return self.queue | ||||
| 
 | ||||
| 	def get_total_stock_and_value(self) -> Tuple[float, float]: | ||||
| 		total_qty = 0.0 | ||||
| 		total_value = 0.0 | ||||
| 
 | ||||
| 		for qty, rate in self.queue: | ||||
| 			total_qty += flt(qty) | ||||
| 			total_value += flt(qty) * flt(rate) | ||||
| 
 | ||||
| 		return _round_off_if_near_zero(total_qty), _round_off_if_near_zero(total_value) | ||||
| 
 | ||||
| 	def add_stock(self, qty: float, rate: float) -> None: | ||||
| 		"""Update fifo queue with new stock. | ||||
| 
 | ||||
| @ -78,7 +97,7 @@ class FIFOValuation: | ||||
| 
 | ||||
| 	def remove_stock( | ||||
| 		self, qty: float, outgoing_rate: float = 0.0, rate_generator: Callable[[], float] = None | ||||
| 	) -> List[FifoBin]: | ||||
| 	) -> List[StockBin]: | ||||
| 		"""Remove stock from the queue and return popped bins. | ||||
| 
 | ||||
| 		args: | ||||
| @ -136,6 +155,101 @@ class FIFOValuation: | ||||
| 		return consumed_bins | ||||
| 
 | ||||
| 
 | ||||
| class LIFOValuation(BinWiseValuation): | ||||
| 	"""Valuation method where a *stack* of all the incoming stock is maintained. | ||||
| 
 | ||||
| 	New stock is added at top of the stack. | ||||
| 	Qty consumption happens on Last In First Out basis. | ||||
| 
 | ||||
| 	Stack is implemented using "bins" of [qty, rate]. | ||||
| 
 | ||||
| 	ref: https://en.wikipedia.org/wiki/FIFO_and_LIFO_accounting | ||||
| 	Implementation detail: appends and pops both at end of list. | ||||
| 	""" | ||||
| 
 | ||||
| 	# specifying the attributes to save resources | ||||
| 	# ref: https://docs.python.org/3/reference/datamodel.html#slots | ||||
| 	__slots__ = ["stack",] | ||||
| 
 | ||||
| 	def __init__(self, state: Optional[List[StockBin]]): | ||||
| 		self.stack: List[StockBin] = state if state is not None else [] | ||||
| 
 | ||||
| 	@property | ||||
| 	def state(self) -> List[StockBin]: | ||||
| 		"""Get current state of stack.""" | ||||
| 		return self.stack | ||||
| 
 | ||||
| 	def add_stock(self, qty: float, rate: float) -> None: | ||||
| 		"""Update lifo stack with new stock. | ||||
| 
 | ||||
| 			args: | ||||
| 				qty: new quantity to add | ||||
| 				rate: incoming rate of new quantity. | ||||
| 
 | ||||
| 			Behaviour of this is same as FIFO valuation. | ||||
| 		""" | ||||
| 		if not len(self.stack): | ||||
| 			self.stack.append([0, 0]) | ||||
| 
 | ||||
| 		# last row has the same rate, merge new bin. | ||||
| 		if self.stack[-1][RATE] == rate: | ||||
| 			self.stack[-1][QTY] += qty | ||||
| 		else: | ||||
| 			# Item has a positive balance qty, add new entry | ||||
| 			if self.stack[-1][QTY] > 0: | ||||
| 				self.stack.append([qty, rate]) | ||||
| 			else:  # negative balance qty | ||||
| 				qty = self.stack[-1][QTY] + qty | ||||
| 				if qty > 0:  # new balance qty is positive | ||||
| 					self.stack[-1] = [qty, rate] | ||||
| 				else:  # new balance qty is still negative, maintain same rate | ||||
| 					self.stack[-1][QTY] = qty | ||||
| 
 | ||||
| 
 | ||||
| 	def remove_stock( | ||||
| 		self, qty: float, outgoing_rate: float = 0.0, rate_generator: Callable[[], float] = None | ||||
| 	) -> List[StockBin]: | ||||
| 		"""Remove stock from the stack and return popped bins. | ||||
| 
 | ||||
| 		args: | ||||
| 			qty: quantity to remove | ||||
| 			rate: outgoing rate - ignored. Kept for backwards compatibility. | ||||
| 			rate_generator: function to be called if stack is not found and rate is required. | ||||
| 		""" | ||||
| 		if not rate_generator: | ||||
| 			rate_generator = lambda : 0.0  # noqa | ||||
| 
 | ||||
| 		consumed_bins = [] | ||||
| 		while qty: | ||||
| 			if not len(self.stack): | ||||
| 				# rely on rate generator. | ||||
| 				self.stack.append([0, rate_generator()]) | ||||
| 
 | ||||
| 			# start at the end. | ||||
| 			index = -1 | ||||
| 
 | ||||
| 			stock_bin = self.stack[index] | ||||
| 			if qty >= stock_bin[QTY]: | ||||
| 				# consume current bin | ||||
| 				qty = _round_off_if_near_zero(qty - stock_bin[QTY]) | ||||
| 				to_consume = self.stack.pop(index) | ||||
| 				consumed_bins.append(list(to_consume)) | ||||
| 
 | ||||
| 				if not self.stack and qty: | ||||
| 					# stock finished, qty still remains to be withdrawn | ||||
| 					# negative stock, keep in as a negative bin | ||||
| 					self.stack.append([-qty, outgoing_rate or stock_bin[RATE]]) | ||||
| 					consumed_bins.append([qty, outgoing_rate or stock_bin[RATE]]) | ||||
| 					break | ||||
| 			else: | ||||
| 				# qty found in current bin consume it and exit | ||||
| 				stock_bin[QTY] = _round_off_if_near_zero(stock_bin[QTY] - qty) | ||||
| 				consumed_bins.append([qty, stock_bin[RATE]]) | ||||
| 				qty = 0 | ||||
| 
 | ||||
| 		return consumed_bins | ||||
| 
 | ||||
| 
 | ||||
| def _round_off_if_near_zero(number: float, precision: int = 7) -> float: | ||||
| 	"""Rounds off the number to zero only if number is close to zero for decimal | ||||
| 	specified in precision. Precision defaults to 7. | ||||
|  | ||||
| @ -1,15 +1,25 @@ | ||||
| # Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors | ||||
| # MIT License. See license.txt | ||||
| 
 | ||||
| import unittest | ||||
| 
 | ||||
| import frappe | ||||
| 
 | ||||
| from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile | ||||
| from erpnext.selling.page.point_of_sale.point_of_sale import get_items | ||||
| from erpnext.stock.doctype.item.test_item import make_item | ||||
| from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry | ||||
| from erpnext.tests.utils import ERPNextTestCase | ||||
| 
 | ||||
| 
 | ||||
| class TestPointOfSale(ERPNextTestCase): | ||||
| class TestPointOfSale(unittest.TestCase): | ||||
| 	@classmethod | ||||
| 	def setUpClass(cls) -> None: | ||||
| 		frappe.db.savepoint('before_test_point_of_sale') | ||||
| 
 | ||||
| 	@classmethod | ||||
| 	def tearDownClass(cls) -> None: | ||||
| 		frappe.db.rollback(save_point='before_test_point_of_sale') | ||||
| 
 | ||||
| 	def test_item_search(self): | ||||
| 		""" | ||||
| 		Test Stock and Service Item Search. | ||||
|  | ||||
| @ -913,6 +913,7 @@ Email Account,E-Mail-Konto, | ||||
| 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, | ||||
| @ -1596,6 +1597,7 @@ Method,Methode, | ||||
| Middle Income,Mittleres Einkommen, | ||||
| Middle Name,Zweiter Vorname, | ||||
| Middle Name (Optional),Weiterer Vorname (optional), | ||||
| Milestonde,Meilenstein, | ||||
| Min Amt can not be greater than Max Amt,Min. Amt kann nicht größer als Max. Amt sein, | ||||
| Min Qty can not be greater than Max Qty,Mindestmenge kann nicht größer als Maximalmenge sein, | ||||
| Minimum Lead Age (Days),Mindest Lead-Alter (in Tagen), | ||||
| @ -2944,7 +2946,7 @@ Temporary Accounts,Temporäre Konten, | ||||
| 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!, | ||||
|  | ||||
| Can't render this file because it is too large. | 
Some files were not shown because too many files have changed in this diff Show More
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user