[IMP] purchase(_stock): Add more and update PO dates

Adds "Expected Date" and "Effective Date" fields to purchase orders to
match what exists in sales orders. "Effective Date" mirrors sales logic
(= first completed stock move). "Expected Date" has simplier logic due
to simplier nature of receiving stock vs shipping (= earliest "Scheduled
Date" of all PO lines). Form view has been updated to include these new fields.

Additionally code was changed to not have PO 'order_line.date_planned' be
overwritten by the 'purchase_order.date_planned' value. Previous behavior
was both confusing and made it so info was lost. Also logic for related
stock move picking line expected date values has been updated to now consider
'purchase_order.date_planned' value if set. Related changes include:
    - not overwriting when PO is edited+saved (this only occurred in write()
      logic, not create() which caused inconsistent behavior anyways.),
    - updating related tests,
    - removing PO line date_planned editing restriction when PO date_planned
      is set
    - updating of relevant stock moves/picking expected date due to
      PO.line.propogate_date = True ('purchase_order.date_planned' if
      exists, otherwise 'order_line.date_planned')

This change also includes some small related improvements:
- Removal of outdated code comment.
- Addition of Help strings to better explain fields.
- Prevent copy of 'date_planned' value into new POs (now mirrors sales
  logic).

This change corresponds to "Receipt Date on PO's" subsection of Purchase
KPIs task.

Task #2198420
This commit is contained in:
Tiffany Chang (tic)
2020-03-31 10:31:34 +00:00
parent f7d7c53cd5
commit 18e96c6be2
6 changed files with 70 additions and 78 deletions
+15 -14
View File
@@ -96,10 +96,11 @@ class PurchaseOrder(models.Model):
('to invoice', 'Waiting Bills'),
('invoiced', 'Fully Billed'),
], string='Billing Status', compute='_get_invoiced', store=True, readonly=True, copy=False, default='no')
# There is no inverse function on purpose since the date may be different on each line
date_planned = fields.Datetime(string='Receipt Date', index=True,
help='This is the Receipt Date promised by the supplier. If set, the receipt will be scheduled at this date.')
date_planned = fields.Datetime(
string='Receipt Date', index=True, copy=False,
help="Delivery date promised by vendor. This date is used to determine expected arrival of products.")
expected_date = fields.Datetime("Expected Date", compute='_compute_expected_date', store=True,
help="Delivery date expected by vendor, computed from the minimum lead time of the order lines.")
date_calendar_start = fields.Datetime(compute='_compute_date_calendar_start', readonly=True, store=True)
amount_untaxed = fields.Monetary(string='Untaxed Amount', store=True, readonly=True, compute='_amount_all', tracking=True)
@@ -149,6 +150,13 @@ class PurchaseOrder(models.Model):
for order in self:
order.currency_rate = self.env['res.currency']._get_conversion_rate(order.company_id.currency_id, order.currency_id, order.company_id, order.date_order)
@api.depends('order_line.date_planned')
def _compute_expected_date(self):
""" expected_date = the earliest date_planned across all order lines. """
for order in self:
dates_list = order.order_line.mapped('date_planned')
order.expected_date = fields.Datetime.to_string(min(dates_list)) if dates_list else False
@api.depends('name', 'partner_ref')
def name_get(self):
result = []
@@ -170,12 +178,6 @@ class PurchaseOrder(models.Model):
vals['name'] = self.env['ir.sequence'].next_by_code('purchase.order', sequence_date=seq_date) or '/'
return super(PurchaseOrder, self).create(vals)
def write(self, vals):
res = super(PurchaseOrder, self).write(vals)
if vals.get('date_planned'):
self.order_line.filtered(lambda line: not line.display_type).date_planned = vals['date_planned']
return res
def unlink(self):
for order in self:
if not order.state == 'cancel':
@@ -188,9 +190,7 @@ class PurchaseOrder(models.Model):
self = self.with_context(ctx)
new_po = super(PurchaseOrder, self).copy(default=default)
for line in new_po.order_line:
if new_po.date_planned:
line.date_planned = new_po.date_planned
elif line.product_id:
if line.product_id:
seller = line.product_id._select_seller(
partner_id=line.partner_id, quantity=line.product_qty,
date=line.order_id.date_order and line.order_id.date_order.date(), uom_id=line.product_uom)
@@ -539,7 +539,8 @@ class PurchaseOrderLine(models.Model):
sequence = fields.Integer(string='Sequence', default=10)
product_qty = fields.Float(string='Quantity', digits='Product Unit of Measure', required=True)
product_uom_qty = fields.Float(string='Total Quantity', compute='_compute_product_uom_qty', store=True)
date_planned = fields.Datetime(string='Scheduled Date', index=True)
date_planned = fields.Datetime(string='Scheduled Date', index=True,
help="Delivery date expected from vendor. This date respectively defaults to vendor pricelist lead time then today's date.")
taxes_id = fields.Many2many('account.tax', string='Taxes', domain=['|', ('active', '=', False), ('active', '=', True)])
product_uom = fields.Many2one('uom.uom', string='Unit of Measure', domain="[('category_id', '=', product_uom_category_id)]")
product_uom_category_id = fields.Many2one(related='product_id.uom_id.category_id')
+14 -50
View File
@@ -22,11 +22,10 @@ class TestPurchase(SavepointCase):
cls.vendor = cls.env['res.partner'].create({'name': 'vendor1'})
cls.uom_unit = cls.env.ref('uom.product_uom_unit')
def test_date_planned_1(self):
"""Set a date planned on a PO, see that it is set on the PO lines. Try to edit the date
planned of the PO line, see that it is not possible. Unset the date planned on the PO and
edit the date planned on the PO lines. Validate the PO and see that it isn't possible to
set the date planned on the PO nor on the PO lines.
def test_expected_date(self):
"""Set a date planned on 2 PO lines. Check that the PO expected date is the earliest PO line date
planned. Change one of the dates so it is even earlier and check that the expected date is set to
this earlier date.
"""
po = Form(self.env['purchase.order'])
po.partner_id = self.vendor
@@ -40,57 +39,22 @@ class TestPurchase(SavepointCase):
po_line.price_unit = 200
po = po.save()
# Check there is no date planned on the PO and the same date planned on both PO lines.
self.assertEqual(po.date_planned, False)
# Check that the same date is planned on both PO lines.
self.assertNotEqual(po.order_line[0].date_planned, False)
self.assertAlmostEqual(po.order_line[0].date_planned, po.order_line[1].date_planned, delta=timedelta(seconds=10))
self.assertAlmostEqual(po.order_line[0].date_planned, po.expected_date, delta=timedelta(seconds=10))
orig_date_planned = po.order_line[0].date_planned
# Set a date planned on a PO, see that it is set on the PO lines.
new_date_planned = orig_date_planned + timedelta(hours=1)
po.date_planned = new_date_planned
self.assertAlmostEqual(po.order_line[0].date_planned, new_date_planned, delta=timedelta(seconds=10))
self.assertAlmostEqual(po.order_line[1].date_planned, new_date_planned, delta=timedelta(seconds=10))
# Set an earlier date planned on a PO line and check that the PO expected date matches it.
new_date_planned = orig_date_planned - timedelta(hours=1)
po.order_line[0].date_planned = new_date_planned
self.assertAlmostEqual(po.order_line[0].date_planned, po.expected_date, delta=timedelta(seconds=10))
# Try to edit the date planned of the PO line, see that it is not possible
po = Form(po)
with self.assertRaises(AssertionError):
po.order_line.edit(0).date_planned = orig_date_planned
with self.assertRaises(AssertionError):
po.order_line.edit(1).date_planned = orig_date_planned
po = po.save()
self.assertAlmostEqual(po.order_line[0].date_planned, new_date_planned, delta=timedelta(seconds=10))
self.assertAlmostEqual(po.order_line[1].date_planned, new_date_planned, delta=timedelta(seconds=10))
# Unset the date planned on the PO and edit the date planned on the PO line.
po = Form(po)
po.date_planned = False
with po.order_line.edit(0) as po_line:
po_line.date_planned = orig_date_planned
with po.order_line.edit(1) as po_line:
po_line.date_planned = orig_date_planned
po = po.save()
self.assertAlmostEqual(po.order_line[0].date_planned, orig_date_planned, delta=timedelta(seconds=10))
self.assertAlmostEqual(po.order_line[1].date_planned, orig_date_planned, delta=timedelta(seconds=10))
# Validate the PO and see that it isn't possible to set the date planned on the PO
# nor on the PO lines.
po.button_confirm()
po.button_done()
po = Form(po)
with self.assertRaises(AssertionError):
po.date_planned = new_date_planned
with self.assertRaises(AssertionError):
with po.order_line.edit(0) as po_line:
po_line.date_planned = orig_date_planned
with self.assertRaises(AssertionError):
with po.order_line.edit(1) as po_line:
po_line.date_planned = orig_date_planned
po.save()
# Set an even earlier date planned on the other PO line and check that the PO expected date matches it.
new_date_planned = orig_date_planned - timedelta(hours=72)
po.order_line[1].date_planned = new_date_planned
self.assertAlmostEqual(po.order_line[1].date_planned, po.expected_date, delta=timedelta(seconds=10))
def test_purchase_order_sequence(self):
PurchaseOrder = self.env['purchase.order'].with_context(tracking_disable=True)
+4 -3
View File
@@ -209,7 +209,7 @@
context="{'partner_id':parent.partner_id, 'quantity':product_qty,'uom':product_uom, 'company_id': parent.company_id}"
force_save="1" domain="[('purchase_ok', '=', True), '|', ('company_id', '=', False), ('company_id', '=', parent.company_id)]"/>
<field name="name" widget="section_and_note_text"/>
<field name="date_planned" optional="hide" attrs="{'required': [('display_type', '=', False)], 'readonly': [('parent.date_planned', '!=', False)]}"/>
<field name="date_planned" optional="hide" attrs="{'required': [('display_type', '=', False)]}"/>
<field name="account_analytic_id" optional="hide" context="{'default_partner_id':parent.partner_id}" groups="analytic.group_analytic_accounting" domain="['|', ('company_id', '=', False), ('company_id', '=', parent.company_id)]"/>
<field name="analytic_tag_ids" optional="hide" groups="analytic.group_analytic_tags" widget="many2many_tags" options="{'color_field': 'color'}" domain="['|', ('company_id', '=', False), ('company_id', '=', parent.company_id)]"/>
<field name="product_qty"/>
@@ -342,8 +342,9 @@
<group>
<group name="planning">
<label for="date_planned"/>
<div>
<field name="date_planned" attrs="{'readonly': [('state', 'not in', ('draft', 'sent'))]}"/>
<div name="date_planned_div" class="o_row">
<field name="date_planned" attrs="{'readonly': [('state', 'not in', ('draft', 'sent', 'to approve', 'purchase'))]}"/>
<span class="text-muted">Expected: <field name="expected_date" widget="date"/></span>
</div>
</group>
<group name="other_info">
+30 -10
View File
@@ -27,6 +27,7 @@ class PurchaseOrder(models.Model):
help="Technical field used to display the Drop Ship Address", readonly=True)
group_id = fields.Many2one('procurement.group', string="Procurement Group", copy=False)
is_shipped = fields.Boolean(compute="_compute_is_shipped")
effective_date = fields.Date("Effective Date", compute='_compute_effective_date', help="Completion date of the first receipt order.")
@api.depends('order_line.move_ids.returned_move_ids',
'order_line.move_ids.state',
@@ -42,6 +43,13 @@ class PurchaseOrder(models.Model):
order.picking_ids = pickings
order.picking_count = len(pickings)
@api.depends('picking_ids.date_done')
def _compute_effective_date(self):
for order in self:
pickings = order.picking_ids.filtered(lambda x: x.state == 'done' and x.location_dest_id.usage == 'internal' and x.date_done)
dates_list = pickings.mapped('date_done')
order.effective_date = min(dates_list).date() if dates_list else False
@api.depends('picking_ids', 'picking_ids.state')
def _compute_is_shipped(self):
for order in self:
@@ -78,6 +86,14 @@ class PurchaseOrder(models.Model):
to_log[order_line] = (order_line.product_qty, pre_order_line_qty[order_line])
if to_log:
order._log_decrease_ordered_quantity(to_log)
# if date_planned value is set, use this to set move line expected dates
if vals.get('date_planned'):
new_date = fields.Datetime.to_datetime(vals['date_planned'])
# let's make sure it's not "no date"
if new_date:
order_lines_to_update = self.order_line.filtered(lambda l: l.propagate_date)
for order_line in order_lines_to_update:
order_line._update_move_expected_date(new_date)
return res
# --------------------------------------------------
@@ -287,16 +303,10 @@ class PurchaseOrderLine(models.Model):
def write(self, values):
for line in self.filtered(lambda l: not l.display_type):
if values.get('date_planned') and line.propagate_date:
# PO date_planned overrides any PO line date_planned values
if not line.order_id.date_planned and values.get('date_planned') and line.propagate_date:
new_date = fields.Datetime.to_datetime(values['date_planned'])
delta_days = (new_date - line.date_planned).total_seconds() / 86400
if abs(delta_days) < line.propagate_date_minimum_delta:
continue
moves_to_update = line.move_ids.filtered(lambda m: m.state not in ('done', 'cancel'))
if not moves_to_update:
moves_to_update = line.move_dest_ids.filtered(lambda m: m.state not in ('done', 'cancel'))
for move in moves_to_update:
move.date_expected = move.date_expected + relativedelta.relativedelta(days=delta_days)
self._update_move_expected_date(new_date)
result = super(PurchaseOrderLine, self).write(values)
if 'product_qty' in values:
self.filtered(lambda l: l.order_id.state == 'purchase')._create_or_update_picking()
@@ -306,6 +316,16 @@ class PurchaseOrderLine(models.Model):
# Business methods
# --------------------------------------------------
def _update_move_expected_date(self, new_date):
""" Updates corresponding move picking line expected dates that are not yet completed. """
moves_to_update = self.move_ids.filtered(lambda m: m.state not in ('done', 'cancel'))
if not moves_to_update:
moves_to_update = self.move_dest_ids.filtered(lambda m: m.state not in ('done', 'cancel'))
for move in moves_to_update:
delta_days = (new_date - move.date_expected).total_seconds() / 86400
if abs(delta_days) >= self.propagate_date_minimum_delta:
move.date_expected = move.date_expected + relativedelta.relativedelta(days=delta_days)
def _create_or_update_picking(self):
for line in self:
if line.product_id and line.product_id.type in ('product', 'consu'):
@@ -396,7 +416,7 @@ class PurchaseOrderLine(models.Model):
'name': (self.name or '')[:2000],
'product_id': self.product_id.id,
'date': self.order_id.date_order,
'date_expected': self.date_planned,
'date_expected': self.order_id.date_planned or self.date_planned,
'location_id': self.order_id.partner_id.property_stock_supplier.id,
'location_dest_id': (self.orderpoint_id and not (self.move_ids | self.move_dest_ids)) and self.orderpoint_id.location_id.id or self.order_id._get_destination_location(),
'picking_id': picking.id,
@@ -24,7 +24,6 @@ class TestPurchaseLeadTime(PurchaseTestCommon):
date_planned = fields.Datetime.to_string(fields.datetime.now() + timedelta(days=10))
self._create_make_procurement(self.product_1, 15.00, date_planned=date_planned)
purchase = self.env['purchase.order.line'].search([('product_id', '=', self.product_1.id)], limit=1).order_id
# Confirm purchase order
purchase.button_confirm()
@@ -24,6 +24,13 @@
<xpath expr="//field[@name='order_line']/tree//field[@name='date_planned']" position="after">
<field name="move_dest_ids" invisible="1"/>
</xpath>
<xpath expr="//div[@name='date_planned_div']" position="replace">
<div class="o_row">
<field name="date_planned" attrs="{'readonly': [('state', 'not in', ('draft', 'sent', 'to approve', 'purchase'))]}"/>
<span class="text-muted" attrs="{'invisible': [('effective_date', '!=', False), ('date_planned', '!=', False)]}">Expected: <field name="expected_date" widget="date"/></span>
</div>
<field name="effective_date" attrs="{'invisible': [('effective_date', '=', False)]}"/>
</xpath>
<xpath expr="//field[@name='order_line']/form//field[@name='invoice_lines']" position="after">
<field name="move_ids"/>
</xpath>