From 21c9918cef834c78f901827df34f3e8d6f646474 Mon Sep 17 00:00:00 2001
From: prlu-odoo <prlu@odoo.com>
Date: Fri, 21 Mar 2025 12:40:38 +0530
Subject: [PATCH] [ADD] revised_promise_date: added revised promise date  with
 history tracking

- Created `promise.date.record` model to track promise date changes.
- Added `original_promise_date` and `revised_promise_date` fields in `sale.order`.
- Logged changes to `revised_promise_date` in `promise.date.record`.
- Updated `stock.picking` to display `original_promise_date` and highlight discrepancies.
- Added views and access control for promise date history.
---
 revised_promise_date/__init__.py              |  1 +
 revised_promise_date/__manifest__.py          | 11 +++
 revised_promise_date/models/__init__.py       |  3 +
 .../models/promise_date_record.py             | 12 +++
 revised_promise_date/models/sale_order.py     | 82 +++++++++++++++++++
 revised_promise_date/models/stock_picking.py  | 20 +++++
 .../security/ir.model.access.csv              |  2 +
 revised_promise_date/tests/__init__.py        |  1 +
 revised_promise_date/tests/test_sale_order.py | 73 +++++++++++++++++
 .../views/sale_order_views.xml                | 28 +++++++
 .../views/stock_picking_views.xml             | 16 ++++
 11 files changed, 249 insertions(+)
 create mode 100644 revised_promise_date/__init__.py
 create mode 100644 revised_promise_date/__manifest__.py
 create mode 100644 revised_promise_date/models/__init__.py
 create mode 100644 revised_promise_date/models/promise_date_record.py
 create mode 100644 revised_promise_date/models/sale_order.py
 create mode 100644 revised_promise_date/models/stock_picking.py
 create mode 100644 revised_promise_date/security/ir.model.access.csv
 create mode 100644 revised_promise_date/tests/__init__.py
 create mode 100644 revised_promise_date/tests/test_sale_order.py
 create mode 100644 revised_promise_date/views/sale_order_views.xml
 create mode 100644 revised_promise_date/views/stock_picking_views.xml

diff --git a/revised_promise_date/__init__.py b/revised_promise_date/__init__.py
new file mode 100644
index 00000000000..0a45e674f6a
--- /dev/null
+++ b/revised_promise_date/__init__.py
@@ -0,0 +1 @@
+from . import models 
diff --git a/revised_promise_date/__manifest__.py b/revised_promise_date/__manifest__.py
new file mode 100644
index 00000000000..4d70e0ae36c
--- /dev/null
+++ b/revised_promise_date/__manifest__.py
@@ -0,0 +1,11 @@
+{
+    'name':'Revised Promise Date' , 
+    'version' : '1.0',
+    'author' : "Lucky Prajapati" ,  
+    'category' : 'Sale/Inventory',
+    'depends' : ["sale_management",'stock','sale_stock'] , 
+    'data' : ["security/ir.model.access.csv","views/sale_order_views.xml","views/stock_picking_views.xml"],
+    'installable' : True , 
+    'application' : True ,
+    'license' : 'LGPL-3'
+}
diff --git a/revised_promise_date/models/__init__.py b/revised_promise_date/models/__init__.py
new file mode 100644
index 00000000000..65b33be322d
--- /dev/null
+++ b/revised_promise_date/models/__init__.py
@@ -0,0 +1,3 @@
+from . import sale_order
+from . import promise_date_record
+from . import stock_picking 
diff --git a/revised_promise_date/models/promise_date_record.py b/revised_promise_date/models/promise_date_record.py
new file mode 100644
index 00000000000..f7c93b0f2d4
--- /dev/null
+++ b/revised_promise_date/models/promise_date_record.py
@@ -0,0 +1,12 @@
+from odoo import models , fields 
+
+class PromiseDateRecord(models.Model):
+    _name='promise.date.record'
+    _description="Store records of the promise date"
+
+    sale_order_id = fields.Many2one('sale.order', string="Sale Order", ondelete='cascade')
+    changed_by = fields.Many2one('res.users', string="Changed By", default=lambda self: self.env.user)
+    changed_on = fields.Date(string="Changed On", default=fields.Datetime.now)
+    from_date = fields.Date(string="Previous Revised Promise Date")
+    to_date = fields.Date(string="New Revised Promise Date")
+    
\ No newline at end of file
diff --git a/revised_promise_date/models/sale_order.py b/revised_promise_date/models/sale_order.py
new file mode 100644
index 00000000000..6d219d3d9a3
--- /dev/null
+++ b/revised_promise_date/models/sale_order.py
@@ -0,0 +1,82 @@
+from odoo import models, fields, api
+from odoo.exceptions import ValidationError 
+
+class SaleOrder(models.Model): 
+    _inherit = "sale.order"
+
+    original_promise_date = fields.Date()
+    revised_promise_date = fields.Date(tracking=True)
+    promise_date_history_ids = fields.One2many('promise.date.record', 'sale_order_id', string="Promise Date History")
+    
+    @api.onchange('original_promise_date')
+    def _onchange_original_promise_date(self):
+        """Changes the commitment(delivery) date on changes of original promise date when order is in quatation state"""
+        self.commitment_date = self.original_promise_date
+    
+    def action_confirm(self):
+        for order in self:
+            """Raise error if original promise date is not set"""
+            if not order.original_promise_date:
+                raise ValidationError("You cannot confirm this quotation without setting the Original Promise Date.")
+            
+            """Stores the first change in revised promise date None to set date"""
+            self.env['promise.date.record'].create({
+                'sale_order_id': order.id,
+                'changed_by': self.env.user.id,
+                'from_date': None,
+                'to_date': order.original_promise_date,
+            })
+
+            """Changes the commitment(delivery) date when first time original promise date is set"""
+            order.commitment_date = order.original_promise_date
+            message = f"Revised Promise Date changed from {None} to {order.original_promise_date} by {self.env.user.name}"
+            order.message_post(body=message)
+
+        return super(SaleOrder, self).action_confirm()
+
+    def write(self,vals):
+        for record in self : 
+            """Raise error on changing the original promise date after the confirmation of sale order"""
+            if 'original_promise_date' in vals and record.state == 'sale':
+                raise ValidationError("You cannot modify the Original Promise Date once the order is confirmed.")
+
+            """Store the value of revised promise date before saving the record to the database"""
+            old_date = record.revised_promise_date
+
+        result = super(SaleOrder,self).write(vals)
+
+        """Store the new revised promise date and save that record to the promise.date.record model"""
+        for record in self: 
+            new_date = record.revised_promise_date
+            if old_date != new_date :
+                record.commitment_date = new_date
+                if record.id : 
+                    self.env['promise.date.record'].create({
+                        'sale_order_id':record.id,
+                        'changed_by':self.env.user.id,
+                        'from_date':old_date,
+                        'to_date':new_date,
+                    })
+                message = f"Revised Promise Date changed from {old_date or 'Empty'} to {new_date} by {self.env.user.name}"
+                record.message_post(body=message)
+        
+        return result 
+
+    def create(self, vals):
+        """Raise error on not setting original promise date"""
+        if  not vals.get('original_promise_date') : 
+            raise ValidationError("Set the Original Promise Date")
+
+        """ Raise error on the set of original promise date lower than the order date"""
+        date_order_value = fields.Date.to_date(vals.get('date_order')) if vals.get('date_order') else None
+        original_promise_date_value = fields.Date.to_date(vals.get('original_promise_date')) if vals.get('original_promise_date') else None
+
+        if date_order_value and original_promise_date_value and date_order_value > original_promise_date_value:
+            raise ValidationError("The Original Promise date must be greater than the order date")
+        
+        """Set the revised promise date on the first creation of the sale order"""
+        if not vals.get('revised_promise_date') and vals.get('original_promise_date'):
+            vals['revised_promise_date'] = vals['original_promise_date']
+
+        return super(SaleOrder, self).create(vals)
+    
\ No newline at end of file
diff --git a/revised_promise_date/models/stock_picking.py b/revised_promise_date/models/stock_picking.py
new file mode 100644
index 00000000000..9a0f4df0c24
--- /dev/null
+++ b/revised_promise_date/models/stock_picking.py
@@ -0,0 +1,20 @@
+from odoo import models, fields ,api
+
+class StockPicking(models.Model):
+    _inherit = "stock.picking"
+
+    original_promise_date = fields.Date("Original Promise Date" , compute='_compute_original_promise_date')
+    sale_order_date = fields.Datetime(related="sale_id.commitment_date", string="Sale Order Date", store=True)
+    sale_order_date_only = fields.Date(string="Sale Order Date Only",compute='_compute_order_date_only',store=True)
+
+    @api.depends('sale_id.original_promise_date','date_deadline')
+    def _compute_original_promise_date(self):
+        """Set the original promise date of stock.picking model with the value of sale.order model's original promise date"""
+        for record in self : 
+            record.original_promise_date = record.sale_id.original_promise_date
+
+    @api.depends('sale_order_date')
+    def _compute_order_date_only(self): 
+        """Converts the date_deadline field from datetime to date"""
+        for record in self: 
+            record.sale_order_date_only = record.sale_order_date.date() if record.sale_order_date else False 
diff --git a/revised_promise_date/security/ir.model.access.csv b/revised_promise_date/security/ir.model.access.csv
new file mode 100644
index 00000000000..335d5f66408
--- /dev/null
+++ b/revised_promise_date/security/ir.model.access.csv
@@ -0,0 +1,2 @@
+id,name,model_id/id,group_id/id,perm_read,perm_write,perm_create,perm_unlink
+access_promise_date_record,access_promise_date_record,model_promise_date_record,base.group_user,1,1,1,1
diff --git a/revised_promise_date/tests/__init__.py b/revised_promise_date/tests/__init__.py
new file mode 100644
index 00000000000..6f699d0d8ba
--- /dev/null
+++ b/revised_promise_date/tests/__init__.py
@@ -0,0 +1 @@
+from . import test_sale_order
diff --git a/revised_promise_date/tests/test_sale_order.py b/revised_promise_date/tests/test_sale_order.py
new file mode 100644
index 00000000000..2074adfdbb6
--- /dev/null
+++ b/revised_promise_date/tests/test_sale_order.py
@@ -0,0 +1,73 @@
+import logging
+from odoo.tests.common import TransactionCase
+from odoo.exceptions import ValidationError
+
+_logger = logging.getLogger(__name__)
+
+class TestSaleOrder(TransactionCase):
+
+    def setUp(self):
+        """Set up test records before running test cases."""
+        super(TestSaleOrder, self).setUp()
+        self.partner = self.env['res.partner'].create({
+            'name': 'Test Customer'
+        })
+        self.sale_order = self.env['sale.order'].create({
+            'partner_id': self.partner.id,
+            'original_promise_date': '2024-03-15',
+            'revised_promise_date': '2024-03-15',
+        })
+        _logger.info("\n✅ Setup: Sale Order Created Successfully")
+
+    def test_sale_order_creation(self):
+        """Test if a Sale Order is created successfully."""
+        self.assertTrue(self.sale_order, "Sale Order should be created.")
+        self.assertEqual(str(self.sale_order.original_promise_date), '2024-03-15')
+        _logger.info("\n✅ Test Passed: Sale Order Creation")
+
+    def test_original_promise_date_required(self):
+        """Test that a Sale Order cannot be confirmed without an Original Promise Date."""
+        self.sale_order.original_promise_date = False
+        with self.assertRaises(ValidationError):
+            self.sale_order.action_confirm()
+        _logger.info("\n✅ Test Passed: Original Promise Date Required for Confirmation")
+
+    def test_original_promise_date_readonly_after_confirmation(self):
+        """Test that Original Promise Date cannot be changed after order is confirmed."""
+        self.sale_order.action_confirm()
+        with self.assertRaises(ValidationError):
+            self.sale_order.write({'original_promise_date': '2024-03-20'})
+        _logger.info("\n✅ Test Passed: Original Promise Date Cannot be Modified After Confirmation")
+
+    def test_revised_promise_date_defaults(self):
+        """Test that `revised_promise_date` defaults to `original_promise_date` if empty."""
+        sale_order = self.env['sale.order'].create({
+            'partner_id': self.partner.id,
+            'original_promise_date': '2024-04-01',
+            'revised_promise_date': None, 
+        })
+        self.assertEqual(str(sale_order.revised_promise_date), '2024-04-01', 
+                        "Revised Promise Date should default to Original Promise Date")
+        _logger.info("\n✅ Test Passed: Revised Promise Date Defaults to Original Promise Date")
+
+    def test_revised_promise_date_change_logs_history(self):
+        """Test if changing `revised_promise_date` logs it in the history table."""
+        old_date = self.sale_order.revised_promise_date
+        new_date = '2024-03-20'
+        self.sale_order.write({'revised_promise_date': new_date})
+        history_record = self.env['promise.date.record'].search([
+            ('sale_order_id', '=', self.sale_order.id)
+        ], order="id desc", limit=1)
+        self.assertEqual(str(history_record.from_date), str(old_date))
+        self.assertEqual(str(history_record.to_date), new_date)
+        _logger.info("\n✅ Test Passed: Revised Promise Date Change Logged in History")
+
+    def test_promise_date_record_creation(self):
+        """Test that a promise date record is created when revised_promise_date changes."""
+        self.sale_order.write({'revised_promise_date': '2024-03-25'})
+        history_record = self.env['promise.date.record'].search([
+            ('sale_order_id', '=', self.sale_order.id)
+        ], order="id desc", limit=1)
+        self.assertTrue(history_record, "A promise date record should be created.")
+        self.assertEqual(str(history_record.to_date), '2024-03-25')
+        _logger.info("\n✅ Test Passed: Promise Date Record Created")
diff --git a/revised_promise_date/views/sale_order_views.xml b/revised_promise_date/views/sale_order_views.xml
new file mode 100644
index 00000000000..38755786d37
--- /dev/null
+++ b/revised_promise_date/views/sale_order_views.xml
@@ -0,0 +1,28 @@
+<odoo>
+	<record id="sale_order_form_view_inherit" model="ir.ui.view">
+		<field name="name">sale.order.form.inherit.revised.date</field>
+		<field name="model">sale.order</field>
+		<field name="inherit_id" ref="sale.view_order_form" />
+		<field name="arch" type="xml">
+			<xpath expr="//field[@name='payment_term_id']" position="after">
+				<field name="original_promise_date" string="Original Promise Date"	/>
+				<field name="revised_promise_date" string="Revised Promise Date" invisible="state != 'sale'" />
+			</xpath>
+			<xpath expr="//field[@name='commitment_date']" position="attributes">
+				<attribute name="readonly">True</attribute>
+			</xpath>
+			<xpath expr="//page" position="after">
+				<page string="Promise Date History" name="promise_date_history">
+					<field name="promise_date_history_ids" nolabel="1">
+						<list>
+							<field name="changed_on"/>
+							<field name="changed_by"/>
+							<field name="from_date"/>
+							<field name="to_date"/>
+						</list>
+					</field>
+				</page>
+			</xpath>
+		</field>
+	</record>
+</odoo>
diff --git a/revised_promise_date/views/stock_picking_views.xml b/revised_promise_date/views/stock_picking_views.xml
new file mode 100644
index 00000000000..0c63dbbea4e
--- /dev/null
+++ b/revised_promise_date/views/stock_picking_views.xml
@@ -0,0 +1,16 @@
+<odoo>
+<record id="stock_picking_form_view" model='ir.ui.view'>
+    <field name='name'>stock.picking.form.view.inherit.revised.date</field>
+    <field name='model'>stock.picking</field>
+    <field name='inherit_id' ref='stock.view_picking_form'/>
+    <field name='arch' type='xml'>
+        <xpath expr="//field[@name='date_deadline']" position='before'>
+        <field name="original_promise_date" readonly='1'/>
+        </xpath>
+        <xpath expr="//field[@name='date_deadline']" position="attributes">
+            <attribute name="string">Revised Promise Date</attribute>
+            <attribute name="decoration-danger">sale_order_date_only != original_promise_date</attribute>
+        </xpath>
+    </field>
+</record>
+</odoo>