# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import copy
import unittest

import frappe
from frappe import _

from erpnext.accounts.doctype.mode_of_payment.test_mode_of_payment import (
	set_default_account_for_mode_of_payment,
)
from erpnext.accounts.doctype.pos_invoice.pos_invoice import make_sales_return
from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile
from erpnext.accounts.doctype.sales_invoice.sales_invoice import PartialPaymentValidationError
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
	get_batch_from_bundle,
	get_serial_nos_from_bundle,
	make_serial_batch_bundle,
)
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.tests.utils import ERPNextTestSuite


class POSInvoiceTestMixin(ERPNextTestSuite):
	def setUp(self):
		from erpnext.accounts.doctype.pos_closing_entry.test_pos_closing_entry import init_user_and_profile

		self.load_test_records("Stock Entry")
		self.test_user, self.pos_profile = init_user_and_profile()

		if frappe.session.user != "Administrator":
			frappe.set_user("Administrator")

		frappe.db.set_single_value("Selling Settings", "validate_selling_price", 0)
		frappe.db.set_single_value("POS Settings", "invoice_type", "POS Invoice")
		make_stock_entry(target="_Test Warehouse - _TC", item_code="_Test Item", qty=800, basic_rate=100)

		mode_of_payment = frappe.get_doc("Mode of Payment", "Bank Draft")
		set_default_account_for_mode_of_payment(mode_of_payment, "_Test Company", "_Test Bank - _TC")


class TestPOSInvoice(POSInvoiceTestMixin):
	def setUp(self):
		super().setUp()
		from erpnext.accounts.doctype.pos_opening_entry.test_pos_opening_entry import create_opening_entry

		self.opening_entry = create_opening_entry(self.pos_profile, self.test_user.name)

	def test_timestamp_change(self):
		w = create_pos_invoice(do_not_save=1)
		w.docstatus = 0
		w.insert()

		w2 = frappe.get_doc(w.doctype, w.name)

		import time

		time.sleep(1)
		w.save()

		import time

		time.sleep(1)
		self.assertRaises(frappe.TimestampMismatchError, w2.save)

	def test_change_naming_series(self):
		inv = create_pos_invoice(do_not_submit=1)
		inv.naming_series = "TEST-"

		self.assertRaises(frappe.CannotChangeConstantError, inv.save)

	def test_discount_and_inclusive_tax(self):
		inv = create_pos_invoice(qty=100, rate=50, do_not_save=1)
		inv.append(
			"taxes",
			{
				"charge_type": "On Net Total",
				"account_head": "_Test Account Service Tax - _TC",
				"cost_center": "_Test Cost Center - _TC",
				"description": "Service Tax",
				"rate": 14,
				"included_in_print_rate": 1,
			},
		)
		inv.insert()

		self.assertEqual(inv.net_total, 4385.96)
		self.assertEqual(inv.grand_total, 5000)

		inv.reload()

		inv.discount_amount = 100
		inv.apply_discount_on = "Net Total"
		inv.payment_schedule = []

		inv.save()

		self.assertEqual(inv.net_total, 4285.96)
		self.assertEqual(inv.grand_total, 4885.99)

		inv.reload()

		inv.discount_amount = 100
		inv.apply_discount_on = "Grand Total"
		inv.payment_schedule = []

		inv.save()

		self.assertEqual(inv.net_total, 4298.24)
		self.assertEqual(inv.grand_total, 4900.00)

	def test_tax_calculation_with_multiple_items(self):
		inv = create_pos_invoice(qty=84, rate=4.6, do_not_save=True)
		item_row = inv.get("items")[0]
		for qty in (54, 288, 144, 430):
			item_row_copy = copy.deepcopy(item_row)
			item_row_copy.qty = qty
			inv.append("items", item_row_copy)

		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": 19,
			},
		)
		inv.insert()

		self.assertEqual(inv.net_total, 4600)

		self.assertEqual(inv.get("taxes")[0].tax_amount, 874.0)
		self.assertEqual(inv.get("taxes")[0].total, 5474.0)

		self.assertEqual(inv.grand_total, 5474.0)

	def test_tax_calculation_with_item_tax_template(self):
		inv = create_pos_invoice(qty=84, rate=4.6, do_not_save=1)
		item_row = inv.get("items")[0]

		add_items = [
			(54, "_Test Account Excise Duty @ 12 - _TC"),
			(288, "_Test Account Excise Duty @ 15 - _TC"),
			(144, "_Test Account Excise Duty @ 20 - _TC"),
			(430, "_Test Item Tax Template 1 - _TC"),
		]
		for qty, item_tax_template in add_items:
			item_row_copy = copy.deepcopy(item_row)
			item_row_copy.qty = qty
			item_row_copy.item_tax_template = item_tax_template
			inv.append("items", item_row_copy)

		inv.append(
			"taxes",
			{
				"account_head": "_Test Account Excise Duty - _TC",
				"charge_type": "On Net Total",
				"cost_center": "_Test Cost Center - _TC",
				"description": "Excise Duty",
				"doctype": "Sales Taxes and Charges",
				"rate": 11,
			},
		)
		inv.append(
			"taxes",
			{
				"account_head": "_Test Account Education Cess - _TC",
				"charge_type": "On Net Total",
				"cost_center": "_Test Cost Center - _TC",
				"description": "Education Cess",
				"doctype": "Sales Taxes and Charges",
				"rate": 0,
			},
		)
		inv.append(
			"taxes",
			{
				"account_head": "_Test Account S&H Education Cess - _TC",
				"charge_type": "On Net Total",
				"cost_center": "_Test Cost Center - _TC",
				"description": "S&H Education Cess",
				"doctype": "Sales Taxes and Charges",
				"rate": 3,
			},
		)
		inv.insert()

		self.assertEqual(inv.net_total, 4600)

		self.assertEqual(inv.get("taxes")[0].tax_amount, 502.41)
		self.assertEqual(inv.get("taxes")[0].total, 5102.41)

		self.assertEqual(inv.get("taxes")[1].tax_amount, 197.80)
		self.assertEqual(inv.get("taxes")[1].total, 5300.21)

		self.assertEqual(inv.get("taxes")[2].tax_amount, 375.36)
		self.assertEqual(inv.get("taxes")[2].total, 5675.57)

		self.assertEqual(inv.grand_total, 5675.57)
		self.assertEqual(inv.rounding_adjustment, 0.43)
		self.assertEqual(inv.rounded_total, 5676.0)

	def test_tax_calculation_with_multiple_items_and_discount(self):
		inv = create_pos_invoice(qty=1, rate=75, do_not_save=True)
		item_row = inv.get("items")[0]
		for rate in (500, 200, 100, 50, 50):
			item_row_copy = copy.deepcopy(item_row)
			item_row_copy.price_list_rate = rate
			item_row_copy.rate = rate
			inv.append("items", item_row_copy)

		inv.apply_discount_on = "Net Total"
		inv.discount_amount = 75.0

		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": 24,
			},
		)
		inv.insert()

		self.assertEqual(inv.total, 975)
		self.assertEqual(inv.net_total, 900)

		self.assertEqual(inv.get("taxes")[0].tax_amount, 216.0)
		self.assertEqual(inv.get("taxes")[0].total, 1116.0)

		self.assertEqual(inv.grand_total, 1116.0)

	def test_pos_returns_with_repayment(self):
		pos = create_pos_invoice(qty=10, do_not_save=True)

		pos.set("payments", [])
		pos.append("payments", {"mode_of_payment": "Bank Draft", "amount": 500})
		pos.append("payments", {"mode_of_payment": "Cash", "amount": 500, "default": 1})
		pos.insert()
		pos.submit()

		pos_return = make_sales_return(pos.name)

		pos_return.insert()
		pos_return.submit()

		self.assertEqual(pos_return.get("payments")[0].amount, -500)
		self.assertEqual(pos_return.get("payments")[1].amount, -500)

	def test_pos_return_for_serialized_item(self):
		from erpnext.stock.doctype.stock_entry.test_stock_entry import make_serialized_item

		se = make_serialized_item(
			self,
			company="_Test Company",
			target_warehouse="Stores - _TC",
			cost_center="Main - _TC",
			expense_account="Cost of Goods Sold - _TC",
		)

		serial_nos = get_serial_nos_from_bundle(se.get("items")[0].serial_and_batch_bundle)

		pos = create_pos_invoice(
			company="_Test Company",
			debit_to="Debtors - _TC",
			account_for_change_amount="Cash - _TC",
			warehouse="Stores - _TC",
			income_account="Sales - _TC",
			expense_account="Cost of Goods Sold - _TC",
			cost_center="Main - _TC",
			item=se.get("items")[0].item_code,
			serial_no=[serial_nos[0]],
			rate=1000,
			do_not_save=1,
		)

		pos.append("payments", {"mode_of_payment": "Cash", "amount": 1000, "default": 1})

		pos.insert()
		pos.submit()

		pos_return = make_sales_return(pos.name)

		pos_return.insert()
		pos_return.submit()
		self.assertEqual(
			get_serial_nos_from_bundle(pos_return.get("items")[0].serial_and_batch_bundle)[0], serial_nos[0]
		)

	def test_partial_pos_returns(self):
		from erpnext.stock.doctype.stock_entry.test_stock_entry import make_serialized_item

		se = make_serialized_item(
			self,
			company="_Test Company",
			target_warehouse="Stores - _TC",
			cost_center="Main - _TC",
			expense_account="Cost of Goods Sold - _TC",
		)

		serial_nos = get_serial_nos_from_bundle(se.get("items")[0].serial_and_batch_bundle)

		pos = create_pos_invoice(
			company="_Test Company",
			debit_to="Debtors - _TC",
			account_for_change_amount="Cash - _TC",
			warehouse="Stores - _TC",
			income_account="Sales - _TC",
			expense_account="Cost of Goods Sold - _TC",
			cost_center="Main - _TC",
			item=se.get("items")[0].item_code,
			serial_no=serial_nos,
			qty=2,
			rate=1000,
			do_not_save=1,
		)

		pos.append("payments", {"mode_of_payment": "Cash", "amount": 2000, "default": 1})

		pos.insert()
		pos.submit()
		pos.reload()

		pos_return1 = make_sales_return(pos.name)

		# partial return 1
		pos_return1.get("items")[0].qty = -1
		pos_return1.set("payments", [])
		pos_return1.append("payments", {"mode_of_payment": "Cash", "amount": -1000, "default": 1})
		pos_return1.paid_amount = -1000
		pos_return1.submit()
		pos_return1.reload()

		bundle_id = frappe.get_doc(
			"Serial and Batch Bundle", pos_return1.get("items")[0].serial_and_batch_bundle
		)

		bundle_id.load_from_db()

		serial_no = bundle_id.entries[0].serial_no
		self.assertEqual(serial_no, serial_nos[0])

		# partial return 2
		pos_return2 = make_sales_return(pos.name)
		pos_return2.set("payments", [])
		pos_return2.append("payments", {"mode_of_payment": "Cash", "amount": -1000, "default": 1})
		pos_return2.paid_amount = -1000
		pos_return2.submit()

		self.assertEqual(pos_return2.get("items")[0].qty, -1)
		serial_no = get_serial_nos_from_bundle(pos_return2.get("items")[0].serial_and_batch_bundle)[0]
		self.assertEqual(serial_no, serial_nos[1])

	def test_pos_change_amount(self):
		pos = create_pos_invoice(
			company="_Test Company",
			debit_to="Debtors - _TC",
			income_account="Sales - _TC",
			expense_account="Cost of Goods Sold - _TC",
			rate=105,
			cost_center="Main - _TC",
			do_not_save=True,
		)

		pos.set("payments", [])
		pos.append("payments", {"mode_of_payment": "Bank Draft", "amount": 50})
		pos.append("payments", {"mode_of_payment": "Cash", "amount": 60, "default": 1})

		pos.insert()
		pos.submit()

		self.assertEqual(pos.grand_total, 105.0)
		self.assertEqual(pos.change_amount, 5.0)

	def test_without_payment(self):
		inv = create_pos_invoice(do_not_save=1)
		# Check that the invoice cannot be submitted without payments
		inv.payments = []
		self.assertRaises(frappe.ValidationError, inv.insert)

	def test_partial_payment(self):
		pos_inv = create_pos_invoice(rate=10000, do_not_save=1)
		pos_inv.append(
			"payments",
			{"mode_of_payment": "Cash", "amount": 9000},
		)
		pos_inv.insert()
		self.assertRaises(PartialPaymentValidationError, pos_inv.submit)

	def test_partly_paid_invoices(self):
		set_allow_partial_payment(self.pos_profile, 1)

		pos_inv = create_pos_invoice(pos_profile=self.pos_profile.name, rate=100, do_not_save=1)
		pos_inv.append(
			"payments",
			{"mode_of_payment": "Cash", "amount": 90},
		)
		pos_inv.save()
		pos_inv.submit()

		self.assertEqual(pos_inv.paid_amount, 90)
		self.assertEqual(pos_inv.status, "Partly Paid")

		pos_inv.update_payments(payments=[{"mode_of_payment": "Cash", "amount": 10}])
		self.assertEqual(pos_inv.paid_amount, 100)
		self.assertEqual(pos_inv.status, "Paid")

		set_allow_partial_payment(self.pos_profile, 0)

	def test_multi_payment_for_partly_paid_invoices(self):
		set_allow_partial_payment(self.pos_profile, 1)

		pos_inv = create_pos_invoice(pos_profile=self.pos_profile.name, rate=100, do_not_save=1)
		pos_inv.append(
			"payments",
			{"mode_of_payment": "Cash", "amount": 90},
		)
		pos_inv.save()
		pos_inv.submit()

		self.assertEqual(pos_inv.paid_amount, 90)
		self.assertEqual(pos_inv.status, "Partly Paid")

		pos_inv.update_payments(payments=[{"mode_of_payment": "Cash", "amount": 5}])
		self.assertEqual(pos_inv.paid_amount, 95)
		self.assertEqual(pos_inv.status, "Partly Paid")

		pos_inv.update_payments(payments=[{"mode_of_payment": "Cash", "amount": 5}])
		self.assertEqual(pos_inv.paid_amount, 100)
		self.assertEqual(pos_inv.status, "Paid")

		set_allow_partial_payment(self.pos_profile, 0)

	def test_serialized_item_transaction(self):
		from erpnext.stock.doctype.stock_entry.test_stock_entry import make_serialized_item

		se = make_serialized_item(
			self,
			company="_Test Company",
			target_warehouse="Stores - _TC",
			cost_center="Main - _TC",
			expense_account="Cost of Goods Sold - _TC",
		)

		serial_nos = get_serial_nos_from_bundle(se.get("items")[0].serial_and_batch_bundle)

		pos = create_pos_invoice(
			company="_Test Company",
			debit_to="Debtors - _TC",
			account_for_change_amount="Cash - _TC",
			warehouse="Stores - _TC",
			income_account="Sales - _TC",
			expense_account="Cost of Goods Sold - _TC",
			cost_center="Main - _TC",
			item=se.get("items")[0].item_code,
			rate=1000,
			serial_no=[serial_nos[0]],
			do_not_save=1,
		)

		pos.append("payments", {"mode_of_payment": "Bank Draft", "amount": 1000})

		pos.insert()
		pos.submit()

		pos2 = create_pos_invoice(
			company="_Test Company",
			debit_to="Debtors - _TC",
			account_for_change_amount="Cash - _TC",
			warehouse="Stores - _TC",
			income_account="Sales - _TC",
			expense_account="Cost of Goods Sold - _TC",
			cost_center="Main - _TC",
			item=se.get("items")[0].item_code,
			rate=1000,
			serial_no=[serial_nos[0]],
			do_not_save=1,
		)

		pos2.append("payments", {"mode_of_payment": "Bank Draft", "amount": 1000})

		pos2.insert()
		self.assertRaises(frappe.ValidationError, pos2.submit)

	def test_delivered_serialized_item_transaction(self):
		from erpnext.stock.doctype.stock_entry.test_stock_entry import make_serialized_item

		se = make_serialized_item(
			self,
			company="_Test Company",
			target_warehouse="Stores - _TC",
			cost_center="Main - _TC",
			expense_account="Cost of Goods Sold - _TC",
		)

		serial_nos = get_serial_nos_from_bundle(se.get("items")[0].serial_and_batch_bundle)

		si = create_sales_invoice(
			company="_Test Company",
			debit_to="Debtors - _TC",
			account_for_change_amount="Cash - _TC",
			warehouse="Stores - _TC",
			income_account="Sales - _TC",
			expense_account="Cost of Goods Sold - _TC",
			cost_center="Main - _TC",
			item=se.get("items")[0].item_code,
			rate=1000,
			update_stock=1,
			serial_no=[serial_nos[0]],
			do_not_save=1,
		)

		si.insert()
		si.submit()

		pos2 = create_pos_invoice(
			company="_Test Company",
			debit_to="Debtors - _TC",
			account_for_change_amount="Cash - _TC",
			warehouse="Stores - _TC",
			income_account="Sales - _TC",
			expense_account="Cost of Goods Sold - _TC",
			cost_center="Main - _TC",
			item=se.get("items")[0].item_code,
			rate=1000,
			serial_no=[serial_nos[0]],
			do_not_save=1,
			ignore_sabb_validation=True,
		)

		pos2.append("payments", {"mode_of_payment": "Bank Draft", "amount": 1000})

		pos2.insert()
		self.assertRaises(frappe.ValidationError, pos2.submit)

	def test_invalid_serial_no_validation(self):
		from erpnext.stock.doctype.stock_entry.test_stock_entry import make_serialized_item

		se = make_serialized_item(
			self,
			company="_Test Company",
			target_warehouse="Stores - _TC",
			cost_center="Main - _TC",
			expense_account="Cost of Goods Sold - _TC",
		)
		serial_nos = get_serial_nos_from_bundle(se.get("items")[0].serial_and_batch_bundle)[0] + "wrong"

		pos = create_pos_invoice(
			company="_Test Company",
			debit_to="Debtors - _TC",
			account_for_change_amount="Cash - _TC",
			warehouse="Stores - _TC",
			income_account="Sales - _TC",
			expense_account="Cost of Goods Sold - _TC",
			cost_center="Main - _TC",
			item=se.get("items")[0].item_code,
			rate=1000,
			qty=2,
			serial_nos=[serial_nos],
			do_not_save=1,
		)

		pos.get("items")[0].has_serial_no = 1

		self.assertRaises(frappe.ValidationError, pos.insert)

	def test_value_error_on_serial_no_validation(self):
		from erpnext.stock.doctype.stock_entry.test_stock_entry import make_serialized_item

		se = make_serialized_item(
			self,
			company="_Test Company",
			target_warehouse="Stores - _TC",
			cost_center="Main - _TC",
			expense_account="Cost of Goods Sold - _TC",
		)
		serial_nos = get_serial_nos_from_bundle(se.get("items")[0].serial_and_batch_bundle)

		# make a pos invoice
		pos = create_pos_invoice(
			company="_Test Company",
			debit_to="Debtors - _TC",
			account_for_change_amount="Cash - _TC",
			warehouse="Stores - _TC",
			income_account="Sales - _TC",
			expense_account="Cost of Goods Sold - _TC",
			cost_center="Main - _TC",
			item=se.get("items")[0].item_code,
			rate=1000,
			serial_no=[serial_nos[0]],
			qty=1,
			do_not_save=1,
		)
		pos.get("items")[0].has_serial_no = 1
		pos.set("payments", [])
		pos.append("payments", {"mode_of_payment": "Cash", "amount": 1000, "default": 1})
		pos = pos.save().submit()

		# make a return
		pos_return = make_sales_return(pos.name)
		pos_return.paid_amount = pos_return.grand_total
		pos_return.save()
		pos_return.submit()

		# set docstatus to 2 for pos to trigger this issue
		frappe.db.set_value("POS Invoice", pos.name, "docstatus", 2)

		pos2 = create_pos_invoice(
			company="_Test Company",
			debit_to="Debtors - _TC",
			account_for_change_amount="Cash - _TC",
			warehouse="Stores - _TC",
			income_account="Sales - _TC",
			expense_account="Cost of Goods Sold - _TC",
			cost_center="Main - _TC",
			item=se.get("items")[0].item_code,
			rate=1000,
			serial_no=[serial_nos[0]],
			qty=1,
			do_not_save=1,
		)

		pos2.get("items")[0].has_serial_no = 1
		# Value error should not be triggered on validation
		pos2.save()

	def test_loyalty_points(self):
		from erpnext.accounts.doctype.loyalty_program.loyalty_program import (
			get_loyalty_program_details_with_points,
		)

		frappe.db.set_value("Customer", "Test Loyalty Customer", "loyalty_program", "Test Single Loyalty")
		before_lp_details = get_loyalty_program_details_with_points(
			"Test Loyalty Customer", company="_Test Company", loyalty_program="Test Single Loyalty"
		)

		inv = create_pos_invoice(customer="Test Loyalty Customer", rate=10000, do_not_save=1)
		inv.append(
			"payments",
			{"mode_of_payment": "Cash", "amount": 10000},
		)
		inv.insert()
		inv.submit()

		lpe = frappe.get_doc(
			"Loyalty Point Entry",
			{"invoice_type": "POS Invoice", "invoice": inv.name, "customer": inv.customer},
		)
		after_lp_details = get_loyalty_program_details_with_points(
			inv.customer, company=inv.company, loyalty_program=inv.loyalty_program
		)

		self.assertEqual(inv.get("loyalty_program"), "Test Single Loyalty")
		self.assertEqual(lpe.loyalty_points, 10)
		self.assertEqual(after_lp_details.loyalty_points, before_lp_details.loyalty_points + 10)

		inv.cancel()
		after_cancel_lp_details = get_loyalty_program_details_with_points(
			inv.customer, company=inv.company, loyalty_program=inv.loyalty_program
		)
		self.assertEqual(after_cancel_lp_details.loyalty_points, before_lp_details.loyalty_points)

	def test_loyalty_points_redeemption(self):
		from erpnext.accounts.doctype.loyalty_program.loyalty_program import (
			get_loyalty_program_details_with_points,
		)

		frappe.db.set_value("Customer", "Test Loyalty Customer", "loyalty_program", "Test Single Loyalty")
		# add 10 loyalty points
		pos_inv = create_pos_invoice(customer="Test Loyalty Customer", rate=10000, do_not_save=1)
		pos_inv.append(
			"payments",
			{"mode_of_payment": "Cash", "amount": 10000},
		)
		pos_inv.paid_amount = 10000
		pos_inv.submit()

		before_lp_details = get_loyalty_program_details_with_points(
			"Test Loyalty Customer", company="_Test Company", loyalty_program="Test Single Loyalty"
		)

		inv = create_pos_invoice(customer="Test Loyalty Customer", rate=10000, do_not_save=1)
		inv.redeem_loyalty_points = 1
		inv.loyalty_points = before_lp_details.loyalty_points
		inv.loyalty_amount = inv.loyalty_points * before_lp_details.conversion_factor
		inv.append(
			"payments",
			{"mode_of_payment": "Cash", "amount": 10000 - inv.loyalty_amount},
		)
		inv.paid_amount = 10000
		inv.submit()

		after_redeem_lp_details = get_loyalty_program_details_with_points(
			inv.customer, company=inv.company, loyalty_program=inv.loyalty_program
		)
		self.assertEqual(after_redeem_lp_details.loyalty_points, 9)

	def test_pos_batch_reservation(self):
		from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
			get_auto_batch_nos,
		)
		from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
			create_batch_item_with_batch,
		)

		create_batch_item_with_batch("_BATCH ITEM Test For Reserve", "TestBatch-RS 02")
		se = make_stock_entry(
			target="_Test Warehouse - _TC",
			item_code="_BATCH ITEM Test For Reserve",
			qty=30,
			basic_rate=100,
		)

		se.reload()

		batch_no = get_batch_from_bundle(se.items[0].serial_and_batch_bundle)

		# POS Invoice 1, for the batch without bundle
		pos_inv1 = create_pos_invoice(item="_BATCH ITEM Test For Reserve", rate=300, qty=15, do_not_save=1)
		pos_inv1.append(
			"payments",
			{"mode_of_payment": "Cash", "amount": 4500},
		)
		pos_inv1.items[0].batch_no = batch_no
		pos_inv1.save()
		pos_inv1.submit()
		pos_inv1.reload()

		batches = get_auto_batch_nos(
			frappe._dict({"item_code": "_BATCH ITEM Test For Reserve", "warehouse": "_Test Warehouse - _TC"})
		)

		for batch in batches:
			if batch.batch_no == batch_no and batch.warehouse == "_Test Warehouse - _TC":
				self.assertEqual(batch.qty, 15)

		# POS Invoice 2, for the batch with bundle
		pos_inv2 = create_pos_invoice(
			item="_BATCH ITEM Test For Reserve", rate=300, qty=10, batch_no=batch_no, do_not_save=1
		)
		pos_inv2.append(
			"payments",
			{"mode_of_payment": "Cash", "amount": 3000},
		)
		pos_inv2.save()
		pos_inv2.submit()
		pos_inv2.reload()
		self.assertTrue(pos_inv2.items[0].serial_and_batch_bundle)

		batches = get_auto_batch_nos(
			frappe._dict({"item_code": "_BATCH ITEM Test For Reserve", "warehouse": "_Test Warehouse - _TC"})
		)

		for batch in batches:
			if batch.batch_no == batch_no and batch.warehouse == "_Test Warehouse - _TC":
				self.assertEqual(batch.qty, 5)

	def test_pos_batch_reservation_with_return_qty(self):
		"""
		Test POS Invoice reserved qty for batch without bundle with return invoices.
		"""
		from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
			get_auto_batch_nos,
		)
		from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
			create_batch_item_with_batch,
		)

		create_batch_item_with_batch("_Batch Item Reserve Return", "TestBatch-RR 01")
		se = make_stock_entry(
			target="_Test Warehouse - _TC",
			item_code="_Batch Item Reserve Return",
			qty=30,
			basic_rate=100,
		)

		se.reload()

		batch_no = get_batch_from_bundle(se.items[0].serial_and_batch_bundle)

		# POS Invoice for the batch without bundle
		pos_inv = create_pos_invoice(item="_Batch Item Reserve Return", rate=300, qty=15, do_not_save=1)
		pos_inv.append(
			"payments",
			{"mode_of_payment": "Cash", "amount": 4500},
		)
		pos_inv.items[0].batch_no = batch_no
		pos_inv.save()
		pos_inv.submit()

		# POS Invoice return
		pos_return = make_sales_return(pos_inv.name)

		pos_return.insert()
		pos_return.submit()

		batches = get_auto_batch_nos(
			frappe._dict({"item_code": "_Batch Item Reserve Return", "warehouse": "_Test Warehouse - _TC"})
		)

		for batch in batches:
			if batch.batch_no == batch_no and batch.warehouse == "_Test Warehouse - _TC":
				self.assertEqual(batch.qty, 30)

	def test_pos_batch_item_qty_validation(self):
		from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
			BatchNegativeStockError,
		)
		from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
			create_batch_item_with_batch,
		)
		from erpnext.stock.serial_batch_bundle import SerialBatchCreation

		create_batch_item_with_batch("_BATCH ITEM", "TestBatch 01")
		item = frappe.get_doc("Item", "_BATCH ITEM")

		se = make_stock_entry(
			target="_Test Warehouse - _TC",
			item_code="_BATCH ITEM",
			qty=2,
			basic_rate=100,
			batch_no="TestBatch 01",
		)

		pos_inv1 = create_pos_invoice(
			item=item.name, rate=300, qty=1, do_not_submit=1, batch_no="TestBatch 01"
		)
		pos_inv1.append(
			"payments",
			{"mode_of_payment": "Cash", "amount": 300},
		)
		pos_inv1.save()
		pos_inv1.submit()

		pos_inv2 = create_pos_invoice(item=item.name, rate=300, qty=2, do_not_submit=1)

		sn_doc = SerialBatchCreation(
			{
				"item_code": item.name,
				"warehouse": pos_inv2.items[0].warehouse,
				"voucher_type": "POS Invoice",
				"voucher_no": pos_inv2.name,
				"qty": 2,
				"avg_rate": 300,
				"batches": frappe._dict({"TestBatch 01": 2}),
				"type_of_transaction": "Outward",
				"company": pos_inv2.company,
			}
		)

		self.assertRaises(BatchNegativeStockError, sn_doc.make_serial_and_batch_bundle)

		# teardown
		pos_inv1.reload()
		pos_inv1.cancel()
		pos_inv1.delete()
		pos_inv2.reload()
		pos_inv2.delete()
		se.cancel()

	def test_ignore_pricing_rule(self):
		from erpnext.accounts.doctype.pricing_rule.test_pricing_rule import make_pricing_rule

		if not frappe.db.exists(
			"Item Price",
			{"item_code": "_Test Item", "price_list": "_Test Price List", "price_list_rate": "450"},
		):
			item_price = frappe.get_doc(
				{
					"doctype": "Item Price",
					"item_code": "_Test Item",
					"price_list": "_Test Price List",
					"price_list_rate": "450",
				}
			)
			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.assertEqual(pos_inv.items[0].discount_percentage, 10)
		# rate shouldn't change
		self.assertEqual(pos_inv.items[0].rate, 405)

		pos_inv.ignore_pricing_rule = 1
		pos_inv.save()
		self.assertEqual(pos_inv.ignore_pricing_rule, 1)
		# rate should reset since pricing rules are ignored
		self.assertEqual(pos_inv.items[0].rate, 450)

		pos_inv.items[0].rate = 300
		pos_inv.save()
		self.assertEqual(pos_inv.items[0].rate, 300)

	def test_delivered_serial_no_case(self):
		from erpnext.accounts.doctype.pos_invoice_merge_log.test_pos_invoice_merge_log import (
			init_user_and_profile,
		)
		from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
		from erpnext.stock.doctype.stock_entry.test_stock_entry import make_serialized_item

		se = make_serialized_item(self)
		serial_no = get_serial_nos_from_bundle(se.get("items")[0].serial_and_batch_bundle)[0]

		dn = create_delivery_note(item_code="_Test Serialized Item With Series", serial_no=[serial_no])
		delivered_serial_no = get_serial_nos_from_bundle(dn.get("items")[0].serial_and_batch_bundle)[0]

		self.assertEqual(serial_no, delivered_serial_no)

		init_user_and_profile()

		pos_inv = create_pos_invoice(
			item_code="_Test Serialized Item With Series",
			serial_no=[serial_no],
			qty=1,
			rate=100,
			do_not_submit=True,
			ignore_sabb_validation=True,
		)

		self.assertRaises(frappe.ValidationError, pos_inv.submit)

	def test_bundle_stock_availability_validation(self):
		from erpnext.accounts.doctype.pos_invoice.pos_invoice import ProductBundleStockValidationError
		from erpnext.accounts.doctype.pos_invoice_merge_log.test_pos_invoice_merge_log import (
			init_user_and_profile,
		)
		from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
		from erpnext.stock.doctype.item.test_item import create_item

		init_user_and_profile()

		frappe.set_user("Administrator")

		warehouse = "_Test Warehouse - _TC"
		company = "_Test Company"

		# Create stock sub-items
		sub_item_a = "_Test Bundle SubA"
		if not frappe.db.exists("Item", sub_item_a):
			create_item(
				item_code=sub_item_a,
				is_stock_item=1,
			)

		sub_item_b = "_Test Bundle SubB"
		if not frappe.db.exists("Item", sub_item_b):
			create_item(
				item_code=sub_item_b,
				is_stock_item=1,
			)

		# Add initial stock: SubA=5, SubB=2
		make_stock_entry(item_code=sub_item_a, target=warehouse, qty=5, company=company)
		make_stock_entry(item_code=sub_item_b, target=warehouse, qty=2, company=company)

		# Create Product Bundle: Test Bundle (SubA x2 + SubB x1)
		bundle_item = "_Test Bundle"
		if not frappe.db.exists("Item", bundle_item):
			create_item(
				item_code=bundle_item,
				is_stock_item=0,
			)

		if not frappe.db.exists("Product Bundle", bundle_item):
			make_product_bundle(parent=bundle_item, items=[sub_item_a, sub_item_b])

		# Test Case 1: Sufficient stock (bundle qty=1: requires SubA=2 (<=5), SubB=1 (<=2)) -> No error
		pos_inv_sufficient = create_pos_invoice(
			item=bundle_item,
			qty=1,
			rate=100,
			warehouse=warehouse,
			pos_profile=self.pos_profile.name,
			do_not_save=1,
		)
		pos_inv_sufficient.append("payments", {"mode_of_payment": "Cash", "amount": 100, "default": 1})
		pos_inv_sufficient.insert()
		pos_inv_sufficient.submit()

		pos_inv_sufficient.cancel()
		pos_inv_sufficient.delete()

		# Test Case 2: Insufficient stock (reduce SubB to 1, bundle qty=2: requires SubB=2 >1) -> Error with details
		make_stock_entry(item_code=sub_item_b, from_warehouse=warehouse, qty=1, company=company)

		pos_inv_insufficient = create_pos_invoice(
			item=bundle_item,
			qty=2,
			rate=100,
			warehouse=warehouse,
			pos_profile=self.pos_profile.name,
			do_not_save=1,
		)
		pos_inv_insufficient.append("payments", {"mode_of_payment": "Cash", "amount": 200, "default": 1})
		pos_inv_insufficient.save()
		self.assertRaises(ProductBundleStockValidationError, pos_inv_insufficient.submit)

		frappe.set_user("test@example.com")


def create_pos_invoice(**args):
	args = frappe._dict(args)
	pos_profile = None
	if not args.pos_profile:
		pos_profile = make_pos_profile()
		pos_profile.save()

	pos_inv = frappe.new_doc("POS Invoice")
	pos_inv.update(args)
	pos_inv.update_stock = 1
	pos_inv.is_pos = 1
	pos_inv.pos_profile = args.pos_profile or pos_profile.name

	if args.posting_date:
		pos_inv.set_posting_time = 1
	pos_inv.posting_date = args.posting_date or frappe.utils.nowdate()

	pos_inv.company = args.company or "_Test Company"
	pos_inv.customer = args.customer or "_Test Customer"
	pos_inv.debit_to = args.debit_to or "Debtors - _TC"
	pos_inv.is_return = args.is_return
	pos_inv.return_against = args.return_against
	pos_inv.currency = args.currency or "INR"
	pos_inv.conversion_rate = args.conversion_rate or 1
	pos_inv.account_for_change_amount = args.account_for_change_amount or "Cash - _TC"

	pos_inv.set_missing_values()

	bundle_id = None
	if not args.use_serial_batch_fields and (args.get("batch_no") or args.get("serial_no")):
		type_of_transaction = args.type_of_transaction or "Outward"

		if pos_inv.is_return:
			type_of_transaction = "Inward"

		qty = args.get("qty") or 1
		qty *= -1 if type_of_transaction == "Outward" else 1
		batches = {}
		if args.get("batch_no"):
			batches = frappe._dict({args.batch_no: qty})

		bundle_id = make_serial_batch_bundle(
			frappe._dict(
				{
					"item_code": args.item or args.item_code or "_Test Item",
					"warehouse": args.warehouse or "_Test Warehouse - _TC",
					"qty": qty,
					"batches": batches,
					"voucher_type": "Delivery Note",
					"serial_nos": args.serial_no,
					"posting_date": pos_inv.posting_date,
					"posting_time": pos_inv.posting_time,
					"type_of_transaction": type_of_transaction,
					"do_not_submit": True,
					"ignore_sabb_validation": args.ignore_sabb_validation,
				}
			)
		).name

		if not bundle_id:
			msg = f"Serial No {args.serial_no} not available for Item {args.item}"
			frappe.throw(_(msg))

	pos_invoice_item = {
		"warehouse": args.warehouse or "_Test Warehouse - _TC",
		"qty": args.qty or 1,
		"rate": args.rate if args.get("rate") is not None else 100,
		"income_account": args.income_account or "Sales - _TC",
		"expense_account": args.expense_account or "Cost of Goods Sold - _TC",
		"cost_center": args.cost_center or "_Test Cost Center - _TC",
		"serial_and_batch_bundle": bundle_id,
		"use_serial_batch_fields": args.use_serial_batch_fields,
		"serial_no": args.serial_no if args.use_serial_batch_fields else None,
		"batch_no": args.batch_no if args.use_serial_batch_fields else None,
	}
	# append in pos invoice items without item_code by checking flag without_item_code
	if args.without_item_code:
		pos_inv.append(
			"items",
			{
				**pos_invoice_item,
				"item_name": args.item_name or "_Test Item",
				"description": args.item_name or "_Test Item",
			},
		)

	else:
		pos_inv.append(
			"items",
			{
				**pos_invoice_item,
				"item_code": args.item or args.item_code or "_Test Item",
			},
		)

	if not args.do_not_save:
		pos_inv.insert()
		if not args.do_not_submit:
			pos_inv.submit()
			if args.use_serial_batch_fields:
				pos_inv.reload()
		else:
			pos_inv.payment_schedule = []
	else:
		pos_inv.payment_schedule = []

	return pos_inv


def set_allow_partial_payment(pos_profile, value):
	pos_profile.reload()
	pos_profile.allow_partial_payment = value
	pos_profile.save()
