Odoo18-Base/addons/sale_stock/tests/test_sale_stock_report.py
2025-01-06 10:57:38 +07:00

567 lines
26 KiB
Python

# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime, timedelta
from odoo.tools import html2plaintext
from odoo import Command
from odoo.tests import Form, tagged
from odoo.exceptions import AccessError
from odoo.addons.stock.tests.test_report import TestReportsCommon
from odoo.addons.sale.tests.common import TestSaleCommon
class TestSaleStockReports(TestReportsCommon):
def test_report_forecast_1_sale_order_replenishment(self):
""" Create and confirm two sale orders: one for the next week and one
for tomorrow. Then check in the report it's the most urgent who is
linked to the qty. on stock.
"""
# make sure first picking doesn't auto-assign
self.picking_type_out.reservation_method = 'manual'
today = datetime.today()
# Put some quantity in stock.
quant_vals = {
'product_id': self.product.id,
'product_uom_id': self.product.uom_id.id,
'location_id': self.stock_location.id,
'quantity': 5,
'reserved_quantity': 0,
}
self.env['stock.quant'].create(quant_vals)
# Create a first SO for the next week.
so_form = Form(self.env['sale.order'])
so_form.partner_id = self.partner
# so_form.validity_date = today + timedelta(days=7)
with so_form.order_line.new() as so_line:
so_line.product_id = self.product
so_line.product_uom_qty = 5
so_1 = so_form.save()
so_1.action_confirm()
so_1.picking_ids.scheduled_date = today + timedelta(days=7)
# Create a second SO for tomorrow.
so_form = Form(self.env['sale.order'])
so_form.partner_id = self.partner
# so_form.validity_date = today + timedelta(days=1)
with so_form.order_line.new() as so_line:
so_line.product_id = self.product
so_line.product_uom_qty = 5
so_2 = so_form.save()
so_2.action_confirm()
so_2.picking_ids.scheduled_date = today + timedelta(days=1)
report_values, docs, lines = self.get_report_forecast(product_template_ids=self.product_template.ids)
self.assertEqual(len(lines), 2)
line_1 = lines[0]
line_2 = lines[1]
self.assertEqual(line_1['quantity'], 5)
self.assertTrue(line_1['replenishment_filled'])
self.assertEqual(line_1['document_out']['id'], so_2.id)
self.assertEqual(line_2['quantity'], 5)
self.assertEqual(line_2['replenishment_filled'], False)
self.assertEqual(line_2['document_out']['id'], so_1.id)
def test_report_forecast_2_report_line_corresponding_to_so_line_highlighted(self):
""" When accessing the report from a SO line, checks if the correct SO line is highlighted in the report
"""
# We create 2 identical SO
so_form = Form(self.env['sale.order'])
so_form.partner_id = self.partner
with so_form.order_line.new() as line:
line.product_id = self.product
line.product_uom_qty = 5
so1 = so_form.save()
so1.action_confirm()
so2 = so1.copy()
so2.action_confirm()
# Check for both SO if the highlight (is_matched) corresponds to the correct SO
for so in [so1, so2]:
context = {"move_to_match_ids": so.order_line.move_ids.ids}
_, _, lines = self.get_report_forecast(product_template_ids=self.product_template.ids, context=context)
for line in lines:
if line['document_out']['id'] == so.id:
self.assertTrue(line['is_matched'], "The corresponding SO line should be matched in the forecast report.")
else:
self.assertFalse(line['is_matched'], "A line of the forecast report not linked to the SO shoud not be matched.")
def test_report_forecast_3_unreserve_2_step_delivery(self):
"""
Check that the forecast correctly reconciles the outgoing moves
that are part of a chain with stock availability when unreserved.
"""
warehouse = self.env.ref("stock.warehouse0")
warehouse.delivery_steps = 'pick_ship'
product = self.product
# Put 5 units in stock
self.env['stock.quant']._update_available_quantity(product, warehouse.lot_stock_id, 5)
# Create and confirm an SO for 3 units
so = self.env['sale.order'].create({
'partner_id': self.partner.id,
'order_line': [
Command.create({
'name': product.name,
'product_id': product.id,
'product_uom_qty': 3,
}),
],
})
so.action_confirm()
_, _, lines = self.get_report_forecast(product_template_ids=product.product_tmpl_id.ids)
outgoing_line = next(filter(lambda line: line.get('document_out'), lines))
self.assertEqual(
(outgoing_line['document_out']['id'], outgoing_line['quantity'], outgoing_line['replenishment_filled'], outgoing_line['reservation']['id']),
(so.id, 3.0, True, so.picking_ids.filtered(lambda p: p.picking_type_id == warehouse.pick_type_id).id)
)
stock_line = next(filter(lambda line: not line.get('document_out'), lines))
self.assertEqual(
(stock_line['quantity'], stock_line['replenishment_filled'], stock_line['reservation']),
(2.0, True, False)
)
# unrerseve the PICK delivery
pick_delivery = so.picking_ids.filtered(lambda p: p.picking_type_id == warehouse.pick_type_id)
pick_delivery.do_unreserve()
_, _, lines = self.get_report_forecast(product_template_ids=product.product_tmpl_id.ids)
outgoing_line = next(filter(lambda line: line.get('document_out'), lines))
self.assertEqual(
(outgoing_line['document_out']['id'], outgoing_line['quantity'], outgoing_line['replenishment_filled'], outgoing_line['reservation']),
(so.id, 3.0, True, False)
)
stock_line = next(filter(lambda line: not line.get('document_out'), lines))
self.assertEqual(
(stock_line['quantity'], stock_line['replenishment_filled'], stock_line['reservation']),
(2.0, True, False)
)
def test_report_forecast_4_so_from_another_salesman(self):
""" Try accessing the forecast with a user that has only access to his SO while another user has created:
- A draft Sale Order
- A confirmed Sale Order
The report shoud be usable by that user, and while he cannot open those SO, he should still see them to have the correct
informations in the report.
"""
# Create the SO & confirm it with first user
with Form(self.env['sale.order']) as so_form:
so_form.partner_id = self.partner
with so_form.order_line.new() as line:
line.product_id = self.product
line.product_uom_qty = 3
sale_order = so_form.save()
sale_order.action_confirm()
# Create a draft SO with the same user for the same product
with Form(self.env['sale.order']) as so_form:
so_form.partner_id = self.partner
with so_form.order_line.new() as line:
line.product_id = self.product
line.product_uom_qty = 2
draft = so_form.save()
# Create second user which only has access to its own documents
other = self.env['res.users'].create({
'name': 'Other Salesman',
'login': 'other',
'groups_id': [
Command.link(self.env.ref('sales_team.group_sale_salesman').id),
Command.link(self.env.ref('stock.group_stock_user').id),
],
})
# Need to reset the cache otherwise it wouldn't trigger an Access Error anyway as the Sale Order is already there.
sale_order.env.invalidate_all()
report_values = self.env['stock.forecasted_product_product'].with_user(other).get_report_values(docids=self.product.ids)
self.assertEqual(len(report_values['docs']['lines']), 1)
self.assertEqual(report_values['docs']['lines'][0]['document_out']['name'], sale_order.name)
self.assertEqual(len(report_values['docs']['draft_sale_orders']), 1)
self.assertEqual(report_values['docs']['draft_sale_orders'][0]['name'], draft.name)
# While 'other' can see these SO on the report, they shouldn't be able to access them.
with self.assertRaises(AccessError):
sale_order.with_user(other).check_access('read')
with self.assertRaises(AccessError):
draft.with_user(other).check_access('read')
@tagged('post_install', '-at_install')
class TestSaleStockInvoices(TestSaleCommon):
def setUp(self):
super(TestSaleStockInvoices, self).setUp()
self.env.ref('base.group_user').write({'implied_ids': [(4, self.env.ref('stock.group_production_lot').id)]})
self.product_by_lot = self.env['product.product'].create({
'name': 'Product By Lot',
'is_storable': True,
'tracking': 'lot',
})
self.product_by_usn = self.env['product.product'].create({
'name': 'Product By USN',
'is_storable': True,
'tracking': 'serial',
})
self.warehouse = self.env['stock.warehouse'].search([('company_id', '=', self.env.company.id)], limit=1)
self.stock_location = self.warehouse.lot_stock_id
lot = self.env['stock.lot'].create({
'name': 'LOT0001',
'product_id': self.product_by_lot.id,
})
self.usn01 = self.env['stock.lot'].create({
'name': 'USN0001',
'product_id': self.product_by_usn.id,
})
self.usn02 = self.env['stock.lot'].create({
'name': 'USN0002',
'product_id': self.product_by_usn.id,
})
self.env['stock.quant']._update_available_quantity(self.product_by_lot, self.stock_location, 10, lot_id=lot)
self.env['stock.quant']._update_available_quantity(self.product_by_usn, self.stock_location, 1, lot_id=self.usn01)
self.env['stock.quant']._update_available_quantity(self.product_by_usn, self.stock_location, 1, lot_id=self.usn02)
def test_invoice_less_than_delivered(self):
"""
Suppose the lots are printed on the invoices.
A user invoice a tracked product with a smaller quantity than delivered.
On the invoice, the quantity of the used lot should be the invoiced one.
"""
display_lots = self.env.ref('stock_account.group_lot_on_invoice')
display_uom = self.env.ref('uom.group_uom')
self.env.user.write({'groups_id': [(4, display_lots.id), (4, display_uom.id)]})
so = self.env['sale.order'].create({
'partner_id': self.partner_a.id,
'order_line': [
(0, 0, {'name': self.product_by_lot.name, 'product_id': self.product_by_lot.id, 'product_uom_qty': 5}),
],
})
so.action_confirm()
picking = so.picking_ids
picking.move_ids.write({'quantity': 5, 'picked': True})
picking.button_validate()
invoice = so._create_invoices()
with Form(invoice) as form:
with form.invoice_line_ids.edit(0) as line:
line.quantity = 2
invoice.action_post()
html = self.env['ir.actions.report']._render_qweb_html(
'account.report_invoice_with_payments', invoice.ids)[0]
text = html2plaintext(html)
self.assertRegex(text, r'Product By Lot\n2.00Units\nLOT0001', "There should be a line that specifies 2 x LOT0001")
def test_invoice_before_delivery(self):
"""
Suppose the lots are printed on the invoices.
The user sells a tracked product, its invoicing policy is "Ordered quantities"
A user invoice a tracked product with a smaller quantity than delivered.
On the invoice, the quantity of the used lot should be the invoiced one.
"""
display_lots = self.env.ref('stock_account.group_lot_on_invoice')
display_uom = self.env.ref('uom.group_uom')
self.env.user.write({'groups_id': [(4, display_lots.id), (4, display_uom.id)]})
self.product_by_lot.invoice_policy = "order"
so = self.env['sale.order'].create({
'partner_id': self.partner_a.id,
'order_line': [
(0, 0, {'name': self.product_by_lot.name, 'product_id': self.product_by_lot.id, 'product_uom_qty': 4}),
],
})
so.action_confirm()
invoice = so._create_invoices()
invoice.action_post()
picking = so.picking_ids
picking.move_ids.write({'quantity': 4, 'picked': True})
picking.button_validate()
html = self.env['ir.actions.report']._render_qweb_html(
'account.report_invoice_with_payments', invoice.ids)[0]
text = html2plaintext(html)
self.assertRegex(text, r'Product By Lot\n4.00Units\nLOT0001', "There should be a line that specifies 4 x LOT0001")
def test_backorder_and_several_invoices(self):
"""
Suppose the lots are printed on the invoices.
The user sells 2 tracked-by-usn products, he delivers 1 product and invoices it
Then, he delivers the other one and invoices it too. Each invoice should have the
correct USN
"""
display_lots = self.env.ref('stock_account.group_lot_on_invoice')
display_uom = self.env.ref('uom.group_uom')
self.env.user.write({'groups_id': [(4, display_lots.id), (4, display_uom.id)]})
so = self.env['sale.order'].create({
'partner_id': self.partner_a.id,
'order_line': [
(0, 0, {'name': self.product_by_usn.name, 'product_id': self.product_by_usn.id, 'product_uom_qty': 2}),
],
})
so.action_confirm()
picking = so.picking_ids
picking.move_ids.move_line_ids[0].quantity = 1
picking.button_validate()
invoice01 = so._create_invoices()
with Form(invoice01) as form:
with form.invoice_line_ids.edit(0) as line:
line.quantity = 1
invoice01.action_post()
backorder = picking.backorder_ids
backorder.move_ids.move_line_ids.quantity = 1
backorder.button_validate()
IrActionsReport = self.env['ir.actions.report']
html = IrActionsReport._render_qweb_html('account.report_invoice_with_payments', invoice01.ids)[0]
text = html2plaintext(html)
self.assertRegex(text, r'Product By USN\n1.00Units\nUSN0001', "There should be a line that specifies 1 x USN0001")
self.assertNotIn('USN0002', text)
invoice02 = so._create_invoices()
invoice02.action_post()
html = IrActionsReport._render_qweb_html('account.report_invoice_with_payments', invoice02.ids)[0]
text = html2plaintext(html)
self.assertRegex(text, r'Product By USN\n1.00Units\nUSN0002', "There should be a line that specifies 1 x USN0002")
self.assertNotIn('USN0001', text)
# Posting the second invoice shouldn't change the result of the first one
html = IrActionsReport._render_qweb_html('account.report_invoice_with_payments', invoice01.ids)[0]
text = html2plaintext(html)
self.assertRegex(text, r'Product By USN\n1.00Units\nUSN0001', "There should still be a line that specifies 1 x USN0001")
self.assertNotIn('USN0002', text)
# Resetting and posting again the first invoice shouldn't change the results
invoice01.button_draft()
invoice01.action_post()
html = IrActionsReport._render_qweb_html('account.report_invoice_with_payments', invoice01.ids)[0]
text = html2plaintext(html)
self.assertRegex(text, r'Product By USN\n1.00Units\nUSN0001', "There should still be a line that specifies 1 x USN0001")
self.assertNotIn('USN0002', text)
html = IrActionsReport._render_qweb_html('account.report_invoice_with_payments', invoice02.ids)[0]
text = html2plaintext(html)
self.assertRegex(text, r'Product By USN\n1.00Units\nUSN0002', "There should be a line that specifies 1 x USN0002")
self.assertNotIn('USN0001', text)
def test_invoice_with_several_returns(self):
"""
Mix of returns and partial invoice
- Product P tracked by lot
- SO with 10 x P
- Deliver 10 x Lot01
- Return 10 x Lot01
- Deliver 03 x Lot02
- Invoice 02 x P
- Deliver 05 x Lot02 + 02 x Lot03
- Invoice 08 x P
"""
display_lots = self.env.ref('stock_account.group_lot_on_invoice')
display_uom = self.env.ref('uom.group_uom')
self.env.user.write({'groups_id': [(4, display_lots.id), (4, display_uom.id)]})
lot01 = self.env['stock.lot'].search([('name', '=', 'LOT0001')])
lot02, lot03 = self.env['stock.lot'].create([{
'name': name,
'product_id': self.product_by_lot.id,
} for name in ['LOT0002', 'LOT0003']])
self.env['stock.quant']._update_available_quantity(self.product_by_lot, self.stock_location, 8, lot_id=lot02)
self.env['stock.quant']._update_available_quantity(self.product_by_lot, self.stock_location, 2, lot_id=lot03)
so = self.env['sale.order'].create({
'partner_id': self.partner_a.id,
'order_line': [
(0, 0, {'name': self.product_by_lot.name, 'product_id': self.product_by_lot.id, 'product_uom_qty': 10}),
],
})
so.action_confirm()
# Deliver 10 x LOT0001
delivery01 = so.picking_ids
delivery01.move_ids.write({'quantity': 10, 'picked': True})
delivery01.button_validate()
self.assertEqual(delivery01.move_line_ids.lot_id.name, 'LOT0001')
# Return delivery01 (-> 10 x LOT0001)
return_form = Form(self.env['stock.return.picking'].with_context(active_ids=[delivery01.id], active_id=delivery01.id, active_model='stock.picking'))
return_wizard = return_form.save()
return_wizard.product_return_moves.quantity = 10
action = return_wizard.action_create_returns()
pick_return = self.env['stock.picking'].browse(action['res_id'])
move_form = Form(pick_return.move_ids, view='stock.view_stock_move_operations')
with move_form.move_line_ids.edit(0) as line:
line.lot_id = lot01
line.quantity = 10
move_form.save()
pick_return.move_ids.picked = True
pick_return.button_validate()
# Return pick_return
return_form = Form(self.env['stock.return.picking'].with_context(active_ids=[pick_return.id], active_id=pick_return.id, active_model='stock.picking'))
return_wizard = return_form.save()
return_wizard.product_return_moves.quantity = 10
action = return_wizard.action_create_returns()
delivery02 = self.env['stock.picking'].browse(action['res_id'])
# Deliver 3 x LOT0002
delivery02.do_unreserve()
move_form = Form(delivery02.move_ids, view='stock.view_stock_move_operations')
with move_form.move_line_ids.new() as line:
line.lot_id = lot02
line.quantity = 3
move_form.save()
delivery02.move_ids.picked = True
Form.from_action(self.env, delivery02.button_validate()).save().process()
# Invoice 2 x P
invoice01 = so._create_invoices()
with Form(invoice01) as form:
with form.invoice_line_ids.edit(0) as line:
line.quantity = 2
invoice01.action_post()
html = self.env['ir.actions.report']._render_qweb_html(
'account.report_invoice_with_payments', invoice01.ids)[0]
text = html2plaintext(html)
self.assertRegex(text, r'Product By Lot\n2.00Units\nLOT0002', "There should be a line that specifies 2 x LOT0002")
self.assertNotIn('LOT0001', text)
# Deliver 5 x LOT0002 + 2 x LOT0003
delivery03 = delivery02.backorder_ids
delivery03.do_unreserve()
move_form = Form(delivery03.move_ids, view='stock.view_stock_move_operations')
with move_form.move_line_ids.new() as line:
line.lot_id = lot02
line.quantity = 5
with move_form.move_line_ids.new() as line:
line.lot_id = lot03
line.quantity = 2
move_form.save()
delivery03.move_ids.picked = True
delivery03.button_validate()
# Invoice 8 x P
invoice02 = so._create_invoices()
invoice02.action_post()
html = self.env['ir.actions.report']._render_qweb_html(
'account.report_invoice_with_payments', invoice02.ids)[0]
text = html2plaintext(html)
self.assertRegex(text, r'Product By Lot\n6.00Units\nLOT0002', "There should be a line that specifies 6 x LOT0002")
self.assertRegex(text, r'Product By Lot\n2.00Units\nLOT0003', "There should be a line that specifies 2 x LOT0003")
self.assertNotIn('LOT0001', text)
def test_refund_cancel_invoices(self):
"""
Suppose the lots are printed on the invoices.
The user sells 2 tracked-by-usn products, he delivers 2 products and invoices them
Then he adds credit notes and issues a full refund. Receive the products.
The reversed invoice should also have correct USN
"""
display_lots = self.env.ref('stock_account.group_lot_on_invoice')
display_uom = self.env.ref('uom.group_uom')
self.env.user.write({'groups_id': [(4, display_lots.id), (4, display_uom.id)]})
so = self.env['sale.order'].create({
'partner_id': self.partner_a.id,
'order_line': [
(0, 0, {'name': self.product_by_usn.name, 'product_id': self.product_by_usn.id, 'product_uom_qty': 2}),
],
})
so.action_confirm()
picking = so.picking_ids
picking.move_ids.move_line_ids[0].quantity = 1
picking.move_ids.move_line_ids[1].quantity = 1
picking.move_ids.picked = True
picking.button_validate()
invoice01 = so._create_invoices()
invoice01.action_post()
html = self.env['ir.actions.report']._render_qweb_html('account.report_invoice_with_payments', invoice01.ids)[0]
text = html2plaintext(html)
self.assertRegex(text, r'Product By USN\n1.00Units\nUSN0001', "There should be a line that specifies 1 x USN0001")
self.assertRegex(text, r'Product By USN\n1.00Units\nUSN0002', "There should be a line that specifies 1 x USN0002")
# Refund the invoice
refund_wizard = self.env['account.move.reversal'].with_context(active_model="account.move", active_ids=invoice01.ids).create({
'journal_id': invoice01.journal_id.id,
})
res = refund_wizard.refund_moves()
refund_invoice = self.env['account.move'].browse(res['res_id'])
refund_invoice.action_post()
# recieve the returned product
stock_return_picking_form = Form(self.env['stock.return.picking'].with_context(active_ids=picking.ids, active_id=picking.sorted().ids[0], active_model='stock.picking'))
return_wiz = stock_return_picking_form.save()
return_wiz.product_return_moves.quantity = 2
res = return_wiz.action_create_returns()
pick_return = self.env['stock.picking'].browse(res['res_id'])
move_form = Form(pick_return.move_ids, view='stock.view_stock_move_operations')
with move_form.move_line_ids.edit(0) as line:
line.lot_id = self.usn01
line.quantity = 1
with move_form.move_line_ids.edit(1) as line:
line.lot_id = self.usn02
line.quantity = 1
move_form.save()
pick_return.move_ids.picked = True
pick_return.button_validate()
# reversed invoice
html = self.env['ir.actions.report']._render_qweb_html('account.report_invoice_with_payments', refund_invoice.ids)[0]
text = html2plaintext(html)
self.assertRegex(text, r'Product By USN\n1.00Units\nUSN0001', "There should be a line that specifies 1 x USN0001")
self.assertRegex(text, r'Product By USN\n1.00Units\nUSN0002', "There should be a line that specifies 1 x USN0002")
def test_refund_modify_invoices(self):
"""
Suppose the lots are printed on the invoices.
The user sells 1 tracked-by-usn products, he delivers 1 and invoices it
Then he adds credit notes and issues full refund and new draft invoice.
The new draft invoice should have correct USN
"""
display_lots = self.env.ref('stock_account.group_lot_on_invoice')
display_uom = self.env.ref('uom.group_uom')
self.env.user.write({'groups_id': [(4, display_lots.id), (4, display_uom.id)]})
so = self.env['sale.order'].create({
'partner_id': self.partner_a.id,
'order_line': [
(0, 0, {'name': self.product_by_usn.name, 'product_id': self.product_by_usn.id, 'product_uom_qty': 1}),
],
})
so.action_confirm()
picking = so.picking_ids
picking.move_ids.move_line_ids[0].quantity = 1
picking.move_ids.picked = True
picking.button_validate()
invoice01 = so._create_invoices()
invoice01.action_post()
html = self.env['ir.actions.report']._render_qweb_html('account.report_invoice_with_payments', invoice01.ids)[0]
text = html2plaintext(html)
self.assertRegex(text, r'Product By USN\n1.00Units\nUSN0001', "There should be a line that specifies 1 x USN0001")
# Refund the invoice with full refund and new draft invoice
refund_wizard = self.env['account.move.reversal'].with_context(active_model="account.move", active_ids=invoice01.ids).create({
'journal_id': invoice01.journal_id.id,
})
res = refund_wizard.modify_moves()
invoice02 = self.env['account.move'].browse(res['res_id'])
invoice02.action_post()
# new draft invoice
html = self.env['ir.actions.report']._render_qweb_html('account.report_invoice_with_payments', invoice02.ids)[0]
text = html2plaintext(html)
self.assertRegex(text, r'Product By USN\n1.00Units\nUSN0001', "There should be a line that specifies 1 x USN0001")