[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:
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user