diff options
| author | Tiffany Chang (tic) <tic@odoo.com> | 2020-02-28 14:45:18 +0000 |
|---|---|---|
| committer | Tiffany Chang (tic) <tic@odoo.com> | 2020-03-31 10:31:34 +0000 |
| commit | 18e96c6be2cc1d74c98ad04bd7995c1f6ab73660 (patch) | |
| tree | 9e3043e16550244dae142d1c3ee63faeacc0467e /addons | |
| parent | f7d7c53cd51c402672a49f98ddebab2cce7cb6ad (diff) | |
[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
Diffstat (limited to 'addons')
| -rw-r--r-- | addons/purchase/models/purchase.py | 29 | ||||
| -rw-r--r-- | addons/purchase/tests/test_purchase.py | 64 | ||||
| -rw-r--r-- | addons/purchase/views/purchase_views.xml | 7 | ||||
| -rw-r--r-- | addons/purchase_stock/models/purchase.py | 40 | ||||
| -rw-r--r-- | addons/purchase_stock/tests/test_purchase_lead_time.py | 1 | ||||
| -rw-r--r-- | addons/purchase_stock/views/purchase_views.xml | 7 |
6 files changed, 70 insertions, 78 deletions
diff --git a/addons/purchase/models/purchase.py b/addons/purchase/models/purchase.py index dc52d85cae7..5b492df2691 100644 --- a/addons/purchase/models/purchase.py +++ b/addons/purchase/models/purchase.py @@ -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') diff --git a/addons/purchase/tests/test_purchase.py b/addons/purchase/tests/test_purchase.py index 996eefa6640..d25436d5562 100644 --- a/addons/purchase/tests/test_purchase.py +++ b/addons/purchase/tests/test_purchase.py @@ -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) diff --git a/addons/purchase/views/purchase_views.xml b/addons/purchase/views/purchase_views.xml index da5d47989b4..7c2ddb8e1a3 100644 --- a/addons/purchase/views/purchase_views.xml +++ b/addons/purchase/views/purchase_views.xml @@ -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"> diff --git a/addons/purchase_stock/models/purchase.py b/addons/purchase_stock/models/purchase.py index 7bca1d03090..229af1728f5 100644 --- a/addons/purchase_stock/models/purchase.py +++ b/addons/purchase_stock/models/purchase.py @@ -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, diff --git a/addons/purchase_stock/tests/test_purchase_lead_time.py b/addons/purchase_stock/tests/test_purchase_lead_time.py index fd8f81ac201..a6026a511f2 100644 --- a/addons/purchase_stock/tests/test_purchase_lead_time.py +++ b/addons/purchase_stock/tests/test_purchase_lead_time.py @@ -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() diff --git a/addons/purchase_stock/views/purchase_views.xml b/addons/purchase_stock/views/purchase_views.xml index f42a82c73f2..942cdcbf688 100644 --- a/addons/purchase_stock/views/purchase_views.xml +++ b/addons/purchase_stock/views/purchase_views.xml @@ -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> |
