def do_produce(self): # Nothing to do for lots since values are created using default data (stock.move.lots) quantity = self.product_qty if float_compare(quantity, 0, precision_rounding=self.product_uom_id.rounding) <= 0: raise UserError( _("The production order for '%s' has no quantity specified.") % self.product_id.display_name) for move in self.production_id.move_finished_ids: if move.product_id.tracking == 'none' and move.state not in ( 'done', 'cancel'): rounding = move.product_uom.rounding if move.product_id.id == self.production_id.product_id.id: move.quantity_done += float_round( quantity, precision_rounding=rounding) elif move.unit_factor: # byproducts handling move.quantity_done += float_round( quantity * move.unit_factor, precision_rounding=rounding) self.check_finished_move_lots() if self.production_id.state == 'confirmed': self.production_id.write({ 'state': 'progress', 'date_start': datetime.now(), }) return {'type': 'ir.actions.act_window_close'}
def test_rounding_invalid(self): """ verify that invalid parameters are forbidden """ with self.assertRaises(AssertionError): float_is_zero(0.01, precision_digits=3, precision_rounding=0.01) with self.assertRaises(AssertionError): float_compare(0.01, 0.02, precision_digits=3, precision_rounding=0.01) with self.assertRaises(AssertionError): float_round(0.01, precision_digits=3, precision_rounding=0.01)
def _get_price(self, bom, factor, product): price = 0 if bom.routing_id: # routing are defined on a BoM and don't have a concept of quantity. # It means that the operation time are defined for the quantity on # the BoM (the user produces a batch of products). E.g the user # product a batch of 10 units with a 5 minutes operation, the time # will be the 5 for a quantity between 1-10, then doubled for # 11-20,... operation_cycle = float_round(factor, precision_rounding=1, rounding_method='UP') operations = self._get_operation_line(bom.routing_id, operation_cycle, 0) price += sum([op['total'] for op in operations]) for line in bom.bom_line_ids: if line._skip_bom_line(product): continue if line.child_bom_id: qty = line.product_uom_id._compute_quantity(line.product_qty * factor, line.child_bom_id.product_uom_id) / line.child_bom_id.product_qty sub_price = self._get_price(line.child_bom_id, qty, line.product_id) price += sub_price else: prod_qty = line.product_qty * factor not_rounded_price = line.product_id.uom_id._compute_price(line.product_id.standard_price, line.product_uom_id) * prod_qty price += self.env.user.company_id.currency_id.round(not_rounded_price) return price
def _get_price(self, pricelist, product, qty): sale_price_digits = self.env['decimal.precision'].precision_get( 'Product Price') price = pricelist.get_product_price(product, qty, False) if not price: price = product.list_price return float_round(price, precision_digits=sale_price_digits)
def _compute_quantity(self, qty, to_unit, round=True, rounding_method='UP', raise_if_failure=True): """ Convert the given quantity from the current UoM `self` into a given one :param qty: the quantity to convert :param to_unit: the destination UoM record (uom.uom) :param raise_if_failure: only if the conversion is not possible - if true, raise an exception if the conversion is not possible (different UoM category), - otherwise, return the initial quantity """ if not self: return qty self.ensure_one() if self.category_id.id != to_unit.category_id.id: if raise_if_failure: raise UserError( _('The unit of measure %s defined on the order line doesn\'t belong to the same category than the unit of measure %s defined on the product. Please correct the unit of measure defined on the order line or on the product, they should belong to the same category.' ) % (self.name, to_unit.name)) else: return qty amount = qty / self.factor if to_unit: amount = amount * to_unit.factor if round: amount = tools.float_round(amount, precision_rounding=to_unit.rounding, rounding_method=rounding_method) return amount
def round(self, amount): """Compute the rounding on the amount passed as parameter. :param amount: the amount to round :return: the rounded amount depending the rounding value and the rounding method """ return float_round(amount, precision_rounding=self.rounding, rounding_method=self.rounding_method)
def check_finished_move_lots(self): """ Handle by product tracked """ by_product_moves = self.production_id.move_finished_ids.filtered( lambda m: m.product_id != self.product_id and m.product_id.tracking != 'none' and m.state not in ('done', 'cancel')) for by_product_move in by_product_moves: rounding = by_product_move.product_uom.rounding quantity = float_round(self.product_qty * by_product_move.unit_factor, precision_rounding=rounding) location_dest_id = by_product_move.location_dest_id.get_putaway_strategy( by_product_move.product_id ).id or by_product_move.location_dest_id.id values = { 'move_id': by_product_move.id, 'product_id': by_product_move.product_id.id, 'production_id': self.production_id.id, 'product_uom_id': by_product_move.product_uom.id, 'location_id': by_product_move.location_id.id, 'location_dest_id': location_dest_id, } if by_product_move.product_id.tracking == 'lot': values.update({ 'product_uom_qty': quantity, 'qty_done': quantity, }) self.env['stock.move.line'].create(values) else: values.update({ 'product_uom_qty': 1.0, 'qty_done': 1.0, }) for i in range(0, int(quantity)): self.env['stock.move.line'].create(values) return super(MrpProductProduce, self).check_finished_move_lots()
def round(self, amount): """Return ``amount`` rounded according to ``self``'s rounding rules. :param float amount: the amount to round :return: rounded float """ self.ensure_one() return tools.float_round(amount, precision_rounding=self.rounding)
def try_round(amount, expected, digits=3, method='HALF-UP'): value = float_round(amount, precision_digits=digits, rounding_method=method) result = float_repr(value, precision_digits=digits) self.assertEqual( result, expected, 'Rounding error: got %s, expected %s' % (result, expected))
def _adyen_convert_amount(self, amount, currency): """ Adyen requires the amount to be multiplied by 10^k, where k depends on the currency code. """ k = CURRENCY_CODE_MAPS.get(currency.name, 2) paymentAmount = int(tools.float_round(amount, k) * (10**k)) return paymentAmount
def get_wallet_balance(self, user, include_config=True): result = float_round(sum( move['amount'] for move in self.env['lunch.cashmove.report'].search_read([( 'user_id', '=', user.id)], ['amount'])), precision_digits=2) if include_config: result += user.company_id.lunch_minimum_threshold return result
def get_operations(self, bom_id=False, qty=0, level=0): bom = self.env['mrp.bom'].browse(bom_id) lines = self._get_operation_line(bom.routing_id, float_round(qty / bom.product_qty, precision_rounding=1, rounding_method='UP'), level) values = { 'bom_id': bom_id, 'currency': self.env.user.company_id.currency_id, 'operations': lines, } return self.env.ref('mrp.report_mrp_operation_line').render({'data': values})
def round(self, amount): """Return ``amount`` rounded according to ``self``'s rounding rules. :param float amount: the amount to round :return: rounded float """ # TODO: Need to check why it calls round() from sale.py, _amount_all() with *No* ID after below commits, # https://github.com/eagle/eagle/commit/36ee1ad813204dcb91e9f5f20d746dff6f080ac2 # https://github.com/eagle/eagle/commit/0b6058c585d7d9a57bd7581b8211f20fca3ec3f7 # Removing self.ensure_one() will make few test cases to break of modules event_sale, sale_mrp and stock_dropshipping. #self.ensure_one() return tools.float_round(amount, precision_rounding=self.rounding)
def float_to_time(hours, moment='am', tz=None): """ Convert a number of hours into a time object. """ if hours == 12.0 and moment == 'pm': return time.max fractional, integral = math.modf(hours) if moment == 'pm': integral += 12 res = time(int(integral), int(float_round(60 * fractional, precision_digits=0)), 0) if tz: res = res.replace(tzinfo=pytz.timezone(tz)) return res
def test_move_picking_with_package(self): """ 355.4 rounded with 0.001 precision is 355.40000000000003. check that nonetheless, moving a picking is accepted """ self.assertEqual(self.productA.uom_id.rounding, 0.001) self.assertEqual( float_round(355.4, precision_rounding=self.productA.uom_id.rounding), 355.40000000000003, ) location_dict = { 'location_id': self.stock_location.id, } quant = self.env['stock.quant'].create({ **location_dict, **{ 'product_id': self.productA.id, 'quantity': 355.4 }, # important number }) package = self.env['stock.quant.package'].create({ **location_dict, **{ 'quant_ids': [(6, 0, [quant.id])] }, }) location_dict.update({ 'state': 'draft', 'location_dest_id': self.ship_location.id, }) move = self.env['stock.move'].create({ **location_dict, **{ 'name': "XXX", 'product_id': self.productA.id, 'product_uom': self.productA.uom_id.id, 'product_uom_qty': 355.40000000000003, # other number } }) picking = self.env['stock.picking'].create({ **location_dict, **{ 'picking_type_id': self.warehouse.in_type_id.id, 'move_lines': [(6, 0, [move.id])], } }) picking.action_confirm() picking.action_assign() move.quantity_done = move.reserved_availability picking.action_done()
def _get_operation_line(self, routing, qty, level): operations = [] total = 0.0 for operation in routing.operation_ids: operation_cycle = float_round(qty / operation.workcenter_id.capacity, precision_rounding=1, rounding_method='UP') duration_expected = operation_cycle * operation.time_cycle + operation.workcenter_id.time_stop + operation.workcenter_id.time_start total = ((duration_expected / 60.0) * operation.workcenter_id.costs_hour) operations.append({ 'level': level or 0, 'operation': operation, 'name': operation.name + ' - ' + operation.workcenter_id.name, 'duration_expected': duration_expected, 'total': self.env.user.company_id.currency_id.round(total), }) return operations
def infos(self, user_id=None): self._check_user_impersonification(user_id) user = request.env['res.users'].browse(user_id) if user_id else request.env.user infos = self._make_infos(user, order=False) lines = self._get_current_lines(user.id) if lines: lines = [{'id': line.id, 'product': (line.product_id.id, line.product_id.name, float_repr(float_round(line.product_id.price, 2), 2)), 'toppings': [(topping.name, float_repr(float_round(topping.price, 2), 2)) for topping in line.topping_ids_1 | line.topping_ids_2 | line.topping_ids_3], 'quantity': line.quantity, 'price': line.price, 'state': line.state, # Only used for _get_state 'note': line.note} for line in lines] raw_state, state = self._get_state(lines) infos.update({ 'total': float_repr(float_round(sum(line['price'] for line in lines), 2), 2), 'raw_state': raw_state, 'state': state, 'lines': lines, }) return infos
def _workorders_create(self, bom, bom_data): """ :param bom: in case of recursive boms: we could create work orders for child BoMs """ workorders = self.env['mrp.workorder'] bom_qty = bom_data['qty'] # Initial qty producing if self.product_id.tracking == 'serial': quantity = 1.0 else: quantity = self.product_qty - sum(self.move_finished_ids.mapped('quantity_done')) quantity = quantity if (quantity > 0) else 0 for operation in bom.routing_id.operation_ids: # create workorder cycle_number = float_round(bom_qty / operation.workcenter_id.capacity, precision_digits=0, rounding_method='UP') duration_expected = (operation.workcenter_id.time_start + operation.workcenter_id.time_stop + cycle_number * operation.time_cycle * 100.0 / operation.workcenter_id.time_efficiency) workorder = workorders.create({ 'name': operation.name, 'production_id': self.id, 'workcenter_id': operation.workcenter_id.id, 'operation_id': operation.id, 'duration_expected': duration_expected, 'state': len(workorders) == 0 and 'ready' or 'pending', 'qty_producing': quantity, 'capacity': operation.workcenter_id.capacity, }) if workorders: workorders[-1].next_work_order_id = workorder.id workorders[-1]._start_nextworkorder() workorders += workorder # assign moves; last operation receive all unassigned moves (which case ?) moves_raw = self.move_raw_ids.filtered(lambda move: move.operation_id == operation) if len(workorders) == len(bom.routing_id.operation_ids): moves_raw |= self.move_raw_ids.filtered(lambda move: not move.operation_id) moves_finished = self.move_finished_ids.filtered(lambda move: move.operation_id == operation) #TODO: code does nothing, unless maybe by_products? moves_raw.mapped('move_line_ids').write({'workorder_id': workorder.id}) (moves_finished + moves_raw).write({'workorder_id': workorder.id}) workorder._generate_lot_ids() return workorders
def _get_bom(self, bom_id=False, product_id=False, line_qty=False, line_id=False, level=False): bom = self.env['mrp.bom'].browse(bom_id) bom_quantity = line_qty if line_id: current_line = self.env['mrp.bom.line'].browse(int(line_id)) bom_quantity = current_line.product_uom_id._compute_quantity(line_qty, bom.product_uom_id) # Display bom components for current selected product variant if product_id: product = self.env['product.product'].browse(int(product_id)) else: product = bom.product_id or bom.product_tmpl_id.product_variant_id if product: attachments = self.env['mrp.document'].search(['|', '&', ('res_model', '=', 'product.product'), ('res_id', '=', product.id), '&', ('res_model', '=', 'product.template'), ('res_id', '=', product.product_tmpl_id.id)]) else: product = bom.product_tmpl_id attachments = self.env['mrp.document'].search([('res_model', '=', 'product.template'), ('res_id', '=', product.id)]) operations = self._get_operation_line(bom.routing_id, float_round(bom_quantity / bom.product_qty, precision_rounding=1, rounding_method='UP'), 0) company = bom.company_id or self.env.company lines = { 'bom': bom, 'bom_qty': bom_quantity, 'bom_prod_name': product.display_name, 'currency': company.currency_id, 'product': product, 'code': bom and bom.display_name or '', 'price': product.uom_id._compute_price(product.with_context(force_company=company.id).standard_price, bom.product_uom_id) * bom_quantity, 'total': sum([op['total'] for op in operations]), 'level': level or 0, 'operations': operations, 'operations_cost': sum([op['total'] for op in operations]), 'attachments': attachments, 'operations_time': sum([op['duration_expected'] for op in operations]) } components, total = self._get_bom_lines(bom, bom_quantity, product, line_id, level) lines['components'] = components lines['total'] += total return lines
def _update_finished_move(self): """ Update the finished move & move lines in order to set the finished product lot on it as well as the produced quantity. This method get the information either from the last workorder or from the Produce wizard.""" production_move = self.production_id.move_finished_ids.filtered( lambda move: move.product_id == self.product_id and move.state not in ('done', 'cancel')) if production_move and production_move.product_id.tracking != 'none': if not self.finished_lot_id: raise UserError( _('You need to provide a lot for the finished product.')) move_line = production_move.move_line_ids.filtered( lambda line: line.lot_id.id == self.finished_lot_id.id) if move_line: if self.product_id.tracking == 'serial': raise UserError( _('You cannot produce the same serial number twice.')) move_line.product_uom_qty += self.qty_producing move_line.qty_done += self.qty_producing else: location_dest_id = production_move.location_dest_id._get_putaway_strategy( self.product_id).id or production_move.location_dest_id.id move_line.create({ 'move_id': production_move.id, 'product_id': production_move.product_id.id, 'lot_id': self.finished_lot_id.id, 'product_uom_qty': self.qty_producing, 'product_uom_id': self.product_uom_id.id, 'qty_done': self.qty_producing, 'location_id': production_move.location_id.id, 'location_dest_id': location_dest_id, }) else: rounding = production_move.product_uom.rounding production_move._set_quantity_done( float_round(self.qty_producing, precision_rounding=rounding))
def _onchange_product_qty(self): lines = [] qty_todo = self.product_uom_id._compute_quantity( self.product_qty, self.production_id.product_uom_id, round=False) for move in self.production_id.move_raw_ids.filtered( lambda m: m.state not in ('done', 'cancel') and m.bom_line_id): qty_to_consume = float_round( qty_todo * move.unit_factor, precision_rounding=move.product_uom.rounding) for move_line in move.move_line_ids: if float_compare( qty_to_consume, 0.0, precision_rounding=move.product_uom.rounding) <= 0: break if move_line.lot_produced_id or float_compare( move_line.product_uom_qty, move_line.qty_done, precision_rounding=move.product_uom.rounding) <= 0: continue to_consume_in_line = min(qty_to_consume, move_line.product_uom_qty) lines.append({ 'move_id': move.id, 'qty_to_consume': to_consume_in_line, 'qty_done': to_consume_in_line, 'lot_id': move_line.lot_id.id, 'product_uom_id': move.product_uom.id, 'product_id': move.product_id.id, 'qty_reserved': min(to_consume_in_line, move_line.product_uom_qty), }) qty_to_consume -= to_consume_in_line if float_compare(qty_to_consume, 0.0, precision_rounding=move.product_uom.rounding) > 0: if move.product_id.tracking == 'serial': while float_compare( qty_to_consume, 0.0, precision_rounding=move.product_uom.rounding) > 0: lines.append({ 'move_id': move.id, 'qty_to_consume': 1, 'qty_done': 1, 'product_uom_id': move.product_uom.id, 'product_id': move.product_id.id, }) qty_to_consume -= 1 else: lines.append({ 'move_id': move.id, 'qty_to_consume': qty_to_consume, 'qty_done': qty_to_consume, 'product_uom_id': move.product_uom.id, 'product_id': move.product_id.id, }) self.produce_line_ids = [(5, )] + [(0, 0, x) for x in lines]
def _procure_orderpoint_confirm(self, use_new_cursor=False, company_id=False): """ Create procurements based on orderpoints. :param bool use_new_cursor: if set, use a dedicated cursor and auto-commit after processing 1000 orderpoints. This is appropriate for batch jobs only. """ if company_id and self.env.company.id != company_id: # To ensure that the company_id is taken into account for # all the processes triggered by this method # i.e. If a PO is generated by the run of the procurements the # sequence to use is the one for the specified company not the # one of the user's company self = self.with_context(company_id=company_id, force_company=company_id) OrderPoint = self.env['stock.warehouse.orderpoint'] domain = self._get_orderpoint_domain(company_id=company_id) orderpoints_noprefetch = OrderPoint.with_context( prefetch_fields=False).search( domain, order=self._procurement_from_orderpoint_get_order()).ids while orderpoints_noprefetch: if use_new_cursor: cr = registry(self._cr.dbname).cursor() self = self.with_env(self.env(cr=cr)) OrderPoint = self.env['stock.warehouse.orderpoint'] orderpoints = OrderPoint.browse(orderpoints_noprefetch[:1000]) orderpoints_noprefetch = orderpoints_noprefetch[1000:] # Calculate groups that can be executed together location_data = OrderedDict() def makedefault(): return { 'products': self.env['product.product'], 'orderpoints': self.env['stock.warehouse.orderpoint'], 'groups': [] } for orderpoint in orderpoints: key = self._procurement_from_orderpoint_get_grouping_key( [orderpoint.id]) if not location_data.get(key): location_data[key] = makedefault() location_data[key]['products'] += orderpoint.product_id location_data[key]['orderpoints'] += orderpoint location_data[key][ 'groups'] = self._procurement_from_orderpoint_get_groups( [orderpoint.id]) for location_id, location_data in location_data.items(): location_orderpoints = location_data['orderpoints'] product_context = dict( self._context, location=location_orderpoints[0].location_id.id) substract_quantity = location_orderpoints._quantity_in_progress( ) for group in location_data['groups']: if group.get('from_date'): product_context['from_date'] = group[ 'from_date'].strftime( DEFAULT_SERVER_DATETIME_FORMAT) if group['to_date']: product_context['to_date'] = group['to_date'].strftime( DEFAULT_SERVER_DATETIME_FORMAT) product_quantity = location_data['products'].with_context( product_context)._product_available() for orderpoint in location_orderpoints: try: op_product_virtual = product_quantity[ orderpoint.product_id.id]['virtual_available'] if op_product_virtual is None: continue if float_compare(op_product_virtual, orderpoint.product_min_qty, precision_rounding=orderpoint. product_uom.rounding) <= 0: qty = max(orderpoint.product_min_qty, orderpoint.product_max_qty ) - op_product_virtual remainder = orderpoint.qty_multiple > 0 and qty % orderpoint.qty_multiple or 0.0 if float_compare(remainder, 0.0, precision_rounding=orderpoint. product_uom.rounding) > 0: qty += orderpoint.qty_multiple - remainder if float_compare(qty, 0.0, precision_rounding=orderpoint. product_uom.rounding) < 0: continue qty -= substract_quantity[orderpoint.id] qty_rounded = float_round( qty, precision_rounding=orderpoint.product_uom. rounding) if qty_rounded > 0: values = orderpoint._prepare_procurement_values( qty_rounded, **group['procurement_values']) try: with self._cr.savepoint(): #TODO: make it batch self.env['procurement.group'].run([ self.env['procurement.group']. Procurement( orderpoint.product_id, qty_rounded, orderpoint.product_uom, orderpoint.location_id, orderpoint.name, orderpoint.name, orderpoint.company_id, values) ]) except UserError as error: self.env[ 'stock.rule']._log_next_activity( orderpoint.product_id, error.name) self._procurement_from_orderpoint_post_process( [orderpoint.id]) if use_new_cursor: cr.commit() except OperationalError: if use_new_cursor: orderpoints_noprefetch += [orderpoint.id] cr.rollback() continue else: raise try: if use_new_cursor: cr.commit() except OperationalError: if use_new_cursor: cr.rollback() continue else: raise if use_new_cursor: cr.commit() cr.close() return {}
def explode(self, product, quantity, picking_type=False): """ Explodes the BoM and creates two lists with all the information you need: bom_done and line_done Quantity describes the number of times you need the BoM: so the quantity divided by the number created by the BoM and converted into its UoM """ from collections import defaultdict graph = defaultdict(list) V = set() def check_cycle(v, visited, recStack, graph): visited[v] = True recStack[v] = True for neighbour in graph[v]: if visited[neighbour] == False: if check_cycle(neighbour, visited, recStack, graph) == True: return True elif recStack[neighbour] == True: return True recStack[v] = False return False boms_done = [(self, { 'qty': quantity, 'product': product, 'original_qty': quantity, 'parent_line': False })] lines_done = [] V |= set([product.product_tmpl_id.id]) bom_lines = [(bom_line, product, quantity, False) for bom_line in self.bom_line_ids] for bom_line in self.bom_line_ids: V |= set([bom_line.product_id.product_tmpl_id.id]) graph[product.product_tmpl_id.id].append( bom_line.product_id.product_tmpl_id.id) while bom_lines: current_line, current_product, current_qty, parent_line = bom_lines[ 0] bom_lines = bom_lines[1:] if current_line._skip_bom_line(current_product): continue line_quantity = current_qty * current_line.product_qty bom = self._bom_find(product=current_line.product_id, picking_type=picking_type or self.picking_type_id, company_id=self.company_id.id, bom_type='phantom') if bom: converted_line_quantity = current_line.product_uom_id._compute_quantity( line_quantity / bom.product_qty, bom.product_uom_id) bom_lines = [(line, current_line.product_id, converted_line_quantity, current_line) for line in bom.bom_line_ids] + bom_lines for bom_line in bom.bom_line_ids: graph[current_line.product_id.product_tmpl_id.id].append( bom_line.product_id.product_tmpl_id.id) if bom_line.product_id.product_tmpl_id.id in V and check_cycle( bom_line.product_id.product_tmpl_id.id, {key: False for key in V}, {key: False for key in V}, graph): raise UserError( _('Recursion error! A product with a Bill of Material should not have itself in its BoM or child BoMs!' )) V |= set([bom_line.product_id.product_tmpl_id.id]) boms_done.append((bom, { 'qty': converted_line_quantity, 'product': current_product, 'original_qty': quantity, 'parent_line': current_line })) else: # We round up here because the user expects that if he has to consume a little more, the whole UOM unit # should be consumed. rounding = current_line.product_uom_id.rounding line_quantity = float_round(line_quantity, precision_rounding=rounding, rounding_method='UP') lines_done.append((current_line, { 'qty': line_quantity, 'product': current_product, 'original_qty': quantity, 'parent_line': parent_line })) return boms_done, lines_done
def test_bom_report(self): """ Simulate a crumble receipt with mrp and open the bom structure report and check that data insde are correct. """ uom_kg = self.env.ref('uom.product_uom_kgm') uom_litre = self.env.ref('uom.product_uom_litre') crumble = self.env['product.product'].create({ 'name': 'Crumble', 'type': 'product', 'uom_id': uom_kg.id, 'uom_po_id': uom_kg.id, }) butter = self.env['product.product'].create({ 'name': 'Butter', 'type': 'product', 'uom_id': uom_kg.id, 'uom_po_id': uom_kg.id, 'standard_price': 7.01 }) biscuit = self.env['product.product'].create({ 'name': 'Biscuit', 'type': 'product', 'uom_id': uom_kg.id, 'uom_po_id': uom_kg.id, 'standard_price': 1.5 }) bom_form_crumble = Form(self.env['mrp.bom']) bom_form_crumble.product_tmpl_id = crumble.product_tmpl_id bom_form_crumble.product_qty = 11 bom_form_crumble.product_uom_id = uom_kg bom_crumble = bom_form_crumble.save() with Form(bom_crumble) as bom: with bom.bom_line_ids.new() as line: line.product_id = butter line.product_uom_id = uom_kg line.product_qty = 5 with bom.bom_line_ids.new() as line: line.product_id = biscuit line.product_uom_id = uom_kg line.product_qty = 6 workcenter = self.env['mrp.workcenter'].create({ 'costs_hour': 10, 'name': 'Deserts Table' }) routing_form = Form(self.env['mrp.routing']) routing_form.name = "Crumble process" routing_crumble = routing_form.save() with Form(routing_crumble) as routing: with routing.operation_ids.new() as operation: operation.workcenter_id = workcenter operation.name = 'Prepare biscuits' operation.time_cycle_manual = 5 with routing.operation_ids.new() as operation: operation.workcenter_id = workcenter operation.name = 'Prepare butter' operation.time_cycle_manual = 3 with routing.operation_ids.new() as operation: operation.workcenter_id = workcenter operation.name = 'Mix manually' operation.time_cycle_manual = 5 bom_crumble.routing_id = routing_crumble.id # TEST BOM STRUCTURE VALUE WITH BOM QUANTITY report_values = self.env[ 'report.mrp.report_bom_structure']._get_report_data( bom_id=bom_crumble.id, searchQty=11, searchVariant=False) # 5 min 'Prepare biscuits' + 3 min 'Prepare butter' + 5 min 'Mix manually' = 13 minutes self.assertEqual( report_values['lines']['operations_time'], 13.0, 'Operation time should be the same for 1 unit or for the batch') # Operation cost is the sum of operation line. operation_cost = float_round(5 / 60 * 10, precision_digits=2) * 2 + float_round( 3 / 60 * 10, precision_digits=2) self.assertEqual( float_compare(report_values['lines']['operations_cost'], operation_cost, precision_digits=2), 0, '13 minute for 10$/hours -> 2.16') for component_line in report_values['lines']['components']: # standard price * bom line quantity * current quantity / bom finished product quantity if component_line['prod_id'] == butter.id: # 5 kg of butter at 7.01$ for 11kg of crumble -> 35.05$ self.assertEqual( float_compare(component_line['total'], (7.01 * 5), precision_digits=2), 0) if component_line['prod_id'] == biscuit.id: # 6 kg of biscuits at 1.50$ for 11kg of crumble -> 9$ self.assertEqual( float_compare(component_line['total'], (1.5 * 6), precision_digits=2), 0) # total price = 35.05 + 9 + operation_cost(0.83 + 0.83 + 0.5 = 2.16) = 46,21 self.assertEqual( float_compare(report_values['lines']['total'], 46.21, precision_digits=2), 0, 'Product Bom Price is not correct') self.assertEqual( float_compare(report_values['lines']['total'] / 11.0, 4.20, precision_digits=2), 0, 'Product Unit Bom Price is not correct') # TEST BOM STRUCTURE VALUE BY UNIT report_values = self.env[ 'report.mrp.report_bom_structure']._get_report_data( bom_id=bom_crumble.id, searchQty=1, searchVariant=False) # 5 min 'Prepare biscuits' + 3 min 'Prepare butter' + 5 min 'Mix manually' = 13 minutes self.assertEqual( report_values['lines']['operations_time'], 13.0, 'Operation time should be the same for 1 unit or for the batch') # Operation cost is the sum of operation line. operation_cost = float_round(5 / 60 * 10, precision_digits=2) * 2 + float_round( 3 / 60 * 10, precision_digits=2) self.assertEqual( float_compare(report_values['lines']['operations_cost'], operation_cost, precision_digits=2), 0, '13 minute for 10$/hours -> 2.16') for component_line in report_values['lines']['components']: # standard price * bom line quantity * current quantity / bom finished product quantity if component_line['prod_id'] == butter.id: # 5 kg of butter at 7.01$ for 11kg of crumble -> / 11 for price per unit (3.19) self.assertEqual( float_compare(component_line['total'], (7.01 * 5) * (1 / 11), precision_digits=2), 0) if component_line['prod_id'] == biscuit.id: # 6 kg of biscuits at 1.50$ for 11kg of crumble -> / 11 for price per unit (0.82) self.assertEqual( float_compare(component_line['total'], (1.5 * 6) * (1 / 11), precision_digits=2), 0) # total price = 3.19 + 0.82 + operation_cost(0.83 + 0.83 + 0.5 = 2.16) = 6,17 self.assertEqual( float_compare(report_values['lines']['total'], 6.17, precision_digits=2), 0, 'Product Unit Bom Price is not correct') # TEST OPERATION COST WHEN PRODUCED QTY > BOM QUANTITY report_values_12 = self.env[ 'report.mrp.report_bom_structure']._get_report_data( bom_id=bom_crumble.id, searchQty=12, searchVariant=False) report_values_22 = self.env[ 'report.mrp.report_bom_structure']._get_report_data( bom_id=bom_crumble.id, searchQty=22, searchVariant=False) operation_cost = float_round(10 / 60 * 10, precision_digits=2) * 2 + float_round( 6 / 60 * 10, precision_digits=2) # Both needs 2 operation cycle self.assertEqual(report_values_12['lines']['operations_cost'], report_values_22['lines']['operations_cost']) self.assertEqual(report_values_22['lines']['operations_cost'], operation_cost) report_values_23 = self.env[ 'report.mrp.report_bom_structure']._get_report_data( bom_id=bom_crumble.id, searchQty=23, searchVariant=False) operation_cost = float_round(15 / 60 * 10, precision_digits=2) * 2 + float_round( 9 / 60 * 10, precision_digits=2) self.assertEqual(report_values_23['lines']['operations_cost'], operation_cost) # Create a more complex BoM with a sub product cheese_cake = self.env['product.product'].create({ 'name': 'Cheese Cake 300g', 'type': 'product', }) cream = self.env['product.product'].create({ 'name': 'cream', 'type': 'product', 'uom_id': uom_litre.id, 'uom_po_id': uom_litre.id, 'standard_price': 5.17, }) bom_form_cheese_cake = Form(self.env['mrp.bom']) bom_form_cheese_cake.product_tmpl_id = cheese_cake.product_tmpl_id bom_form_cheese_cake.product_qty = 60 bom_form_cheese_cake.product_uom_id = self.uom_unit bom_cheese_cake = bom_form_cheese_cake.save() with Form(bom_cheese_cake) as bom: with bom.bom_line_ids.new() as line: line.product_id = cream line.product_uom_id = uom_litre line.product_qty = 3 with bom.bom_line_ids.new() as line: line.product_id = crumble line.product_uom_id = uom_kg line.product_qty = 5.4 workcenter_2 = self.env['mrp.workcenter'].create({ 'name': 'cake mounting', 'costs_hour': 20, 'time_start': 10, 'time_stop': 15 }) routing_form = Form(self.env['mrp.routing']) routing_form.name = "Cheese cake process" routing_cheese = routing_form.save() with Form(routing_cheese) as routing: with routing.operation_ids.new() as operation: operation.workcenter_id = workcenter operation.name = 'Mix cheese and crumble' operation.time_cycle_manual = 10 with routing.operation_ids.new() as operation: operation.workcenter_id = workcenter_2 operation.name = 'Cake mounting' operation.time_cycle_manual = 5 bom_cheese_cake.routing_id = routing_cheese.id # TEST CHEESE BOM STRUCTURE VALUE WITH BOM QUANTITY report_values = self.env[ 'report.mrp.report_bom_structure']._get_report_data( bom_id=bom_cheese_cake.id, searchQty=60, searchVariant=False) self.assertEqual( report_values['lines']['operations_time'], 40.0, 'Operation time should be the same for 1 unit or for the batch') # Operation cost is the sum of operation line. operation_cost = float_round( 10 / 60 * 10, precision_digits=2) + float_round(30 / 60 * 20, precision_digits=2) self.assertEqual( float_compare(report_values['lines']['operations_cost'], operation_cost, precision_digits=2), 0) for component_line in report_values['lines']['components']: # standard price * bom line quantity * current quantity / bom finished product quantity if component_line['prod_id'] == cream.id: # 3 liter of cream at 5.17$ for 60 unit of cheese cake -> 15.51$ self.assertEqual( float_compare(component_line['total'], (3 * 5.17), precision_digits=2), 0) if component_line['prod_id'] == crumble.id: # 5.4 kg of crumble at the cost of a batch. crumble_cost = self.env[ 'report.mrp.report_bom_structure']._get_report_data( bom_id=bom_crumble.id, searchQty=5.4, searchVariant=False)['lines']['total'] self.assertEqual( float_compare(component_line['total'], crumble_cost, precision_digits=2), 0) # total price = 15.51 + crumble_cost + operation_cost(10 + 1.67 = 11.67) = 27.18 + crumble_cost self.assertEqual( float_compare(report_values['lines']['total'], 27.18 + crumble_cost, precision_digits=2), 0, 'Product Bom Price is not correct')
def write(self, vals): """ When editing a done stock.move.line, we impact the valuation. Users may increase or decrease the `qty_done` field. There are three cost method available: standard, average and fifo. We implement the logic in a similar way for standard and average: increase or decrease the original value with the standard or average price of today. In fifo, we have a different logic wheter the move is incoming or outgoing. If the move is incoming, we update the value and remaining_value/qty with the unit price of the move. If the move is outgoing and the user increases qty_done, we call _run_fifo and it'll consume layer(s) in the stack the same way a new outgoing move would have done. If the move is outoing and the user decreases qty_done, we either increase the last receipt candidate if one is found or we decrease the value with the last fifo price. """ if 'qty_done' in vals: moves_to_update = {} for move_line in self.filtered(lambda ml: ml.state == 'done' and ( ml.move_id._is_in() or ml.move_id._is_out())): rounding = move_line.product_uom_id.rounding qty_difference = float_round(vals['qty_done'] - move_line.qty_done, precision_rounding=rounding) if not float_is_zero(qty_difference, precision_rounding=rounding): moves_to_update[move_line.move_id] = qty_difference for move_id, qty_difference in moves_to_update.items(): move_vals = {} if move_id.product_id.cost_method in ['standard', 'average']: correction_value = qty_difference * move_id.product_id.standard_price if move_id._is_in(): move_vals['value'] = move_id.value + correction_value elif move_id._is_out(): move_vals['value'] = move_id.value - correction_value else: if move_id._is_in(): correction_value = qty_difference * move_id.price_unit new_remaining_value = move_id.remaining_value + correction_value move_vals['value'] = move_id.value + correction_value move_vals[ 'remaining_qty'] = move_id.remaining_qty + qty_difference move_vals[ 'remaining_value'] = move_id.remaining_value + correction_value elif move_id._is_out() and qty_difference > 0: correction_value = self.env['stock.move']._run_fifo( move_id, quantity=qty_difference) # no need to adapt `remaining_qty` and `remaining_value` as `_run_fifo` took care of it move_vals['value'] = move_id.value - correction_value elif move_id._is_out() and qty_difference < 0: candidates_receipt = self.env['stock.move'].search( move_id._get_in_domain(), order='date, id desc', limit=1) if candidates_receipt: candidates_receipt.write({ 'remaining_qty': candidates_receipt.remaining_qty + -qty_difference, 'remaining_value': candidates_receipt.remaining_value + (-qty_difference * candidates_receipt.price_unit), }) correction_value = qty_difference * candidates_receipt.price_unit else: correction_value = qty_difference * move_id.product_id.standard_price move_vals['value'] = move_id.value - correction_value move_id.write(move_vals) if move_id.product_id.valuation == 'real_time': move_id.with_context( force_valuation_amount=correction_value, forced_quantity=qty_difference)._account_entry_move() if qty_difference > 0: move_id.product_price_update_before_done( forced_qty=qty_difference) return super(StockMoveLine, self).write(vals)
def _run_valuation(self, quantity=None): self.ensure_one() value_to_return = 0 if self._is_in(): valued_move_lines = self.move_line_ids.filtered( lambda ml: not ml.location_id._should_be_valued() and ml. location_dest_id._should_be_valued() and not ml.owner_id) valued_quantity = 0 for valued_move_line in valued_move_lines: valued_quantity += valued_move_line.product_uom_id._compute_quantity( valued_move_line.qty_done, self.product_id.uom_id) # Note: we always compute the fifo `remaining_value` and `remaining_qty` fields no # matter which cost method is set, to ease the switching of cost method. vals = {} price_unit = self._get_price_unit() value = price_unit * (quantity or valued_quantity) value_to_return = value if quantity is None or not self.value else self.value vals = { 'price_unit': price_unit, 'value': value_to_return, 'remaining_value': value if quantity is None else self.remaining_value + value, } vals[ 'remaining_qty'] = valued_quantity if quantity is None else self.remaining_qty + quantity if self.product_id.cost_method == 'standard': value = self.product_id.standard_price * (quantity or valued_quantity) value_to_return = value if quantity is None or not self.value else self.value vals.update({ 'price_unit': self.product_id.standard_price, 'value': value_to_return, }) self.write(vals) elif self._is_out(): valued_move_lines = self.move_line_ids.filtered( lambda ml: ml.location_id._should_be_valued() and not ml. location_dest_id._should_be_valued() and not ml.owner_id) valued_quantity = 0 for valued_move_line in valued_move_lines: valued_quantity += valued_move_line.product_uom_id._compute_quantity( valued_move_line.qty_done, self.product_id.uom_id) self.env['stock.move']._run_fifo(self, quantity=quantity) if self.product_id.cost_method in ['standard', 'average']: curr_rounding = self.company_id.currency_id.rounding value = -float_round( self.product_id.standard_price * (valued_quantity if quantity is None else quantity), precision_rounding=curr_rounding) value_to_return = value if quantity is None else self.value + value self.write({ 'value': value_to_return, 'price_unit': value / valued_quantity, }) elif self._is_dropshipped() or self._is_dropshipped_returned(): curr_rounding = self.company_id.currency_id.rounding if self.product_id.cost_method in ['fifo']: price_unit = self._get_price_unit() # see test_dropship_fifo_perpetual_anglosaxon_ordered self.product_id.standard_price = price_unit else: price_unit = self.product_id.standard_price value = float_round(self.product_qty * price_unit, precision_rounding=curr_rounding) value_to_return = value if self._is_dropshipped() else -value # In move have a positive value, out move have a negative value, let's arbitrary say # dropship are positive. self.write({ 'value': value_to_return, 'price_unit': price_unit if self._is_dropshipped() else -price_unit, }) return value_to_return
def compute_landed_cost(self): AdjustementLines = self.env['stock.valuation.adjustment.lines'] AdjustementLines.search([('cost_id', 'in', self.ids)]).unlink() digits = self.env['decimal.precision'].precision_get('Product Price') towrite_dict = {} for cost in self.filtered(lambda cost: cost.picking_ids): total_qty = 0.0 total_cost = 0.0 total_weight = 0.0 total_volume = 0.0 total_line = 0.0 all_val_line_values = cost.get_valuation_lines() for val_line_values in all_val_line_values: for cost_line in cost.cost_lines: val_line_values.update({'cost_id': cost.id, 'cost_line_id': cost_line.id}) self.env['stock.valuation.adjustment.lines'].create(val_line_values) total_qty += val_line_values.get('quantity', 0.0) total_weight += val_line_values.get('weight', 0.0) total_volume += val_line_values.get('volume', 0.0) former_cost = val_line_values.get('former_cost', 0.0) # round this because former_cost on the valuation lines is also rounded total_cost += tools.float_round(former_cost, precision_digits=digits) if digits else former_cost total_line += 1 for line in cost.cost_lines: value_split = 0.0 for valuation in cost.valuation_adjustment_lines: value = 0.0 if valuation.cost_line_id and valuation.cost_line_id.id == line.id: if line.split_method == 'by_quantity' and total_qty: per_unit = (line.price_unit / total_qty) value = valuation.quantity * per_unit elif line.split_method == 'by_weight' and total_weight: per_unit = (line.price_unit / total_weight) value = valuation.weight * per_unit elif line.split_method == 'by_volume' and total_volume: per_unit = (line.price_unit / total_volume) value = valuation.volume * per_unit elif line.split_method == 'equal': value = (line.price_unit / total_line) elif line.split_method == 'by_current_cost_price' and total_cost: per_unit = (line.price_unit / total_cost) value = valuation.former_cost * per_unit else: value = (line.price_unit / total_line) if digits: value = tools.float_round(value, precision_digits=digits, rounding_method='UP') fnc = min if line.price_unit > 0 else max value = fnc(value, line.price_unit - value_split) value_split += value if valuation.id not in towrite_dict: towrite_dict[valuation.id] = value else: towrite_dict[valuation.id] += value for key, value in towrite_dict.items(): AdjustementLines.browse(key).write({'additional_landed_cost': value}) return True
def change_prod_qty(self): precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') for wizard in self: production = wizard.mo_id produced = sum(production.move_finished_ids.filtered(lambda m: m.product_id == production.product_id).mapped('quantity_done')) if wizard.product_qty < produced: format_qty = '%.{precision}f'.format(precision=precision) raise UserError(_("You have already processed %s. Please input a quantity higher than %s ") % (format_qty % produced, format_qty % produced)) old_production_qty = production.product_qty production.write({'product_qty': wizard.product_qty}) done_moves = production.move_finished_ids.filtered(lambda x: x.state == 'done' and x.product_id == production.product_id) qty_produced = production.product_id.uom_id._compute_quantity(sum(done_moves.mapped('product_qty')), production.product_uom_id) factor = production.product_uom_id._compute_quantity(production.product_qty - qty_produced, production.bom_id.product_uom_id) / production.bom_id.product_qty boms, lines = production.bom_id.explode(production.product_id, factor, picking_type=production.bom_id.picking_type_id) documents = {} for line, line_data in lines: if line.child_bom_id and line.child_bom_id.type == 'phantom' or\ line.product_id.type not in ['product', 'consu']: continue move = production.move_raw_ids.filtered(lambda x: x.bom_line_id.id == line.id and x.state not in ('done', 'cancel')) if move: move = move[0] old_qty = move.product_uom_qty else: old_qty = 0 iterate_key = production._get_document_iterate_key(move) if iterate_key: document = self.env['stock.picking']._log_activity_get_documents({move: (line_data['qty'], old_qty)}, iterate_key, 'UP') for key, value in document.items(): if documents.get(key): documents[key] += [value] else: documents[key] = [value] production._update_raw_move(line, line_data) production._log_manufacture_exception(documents) operation_bom_qty = {} for bom, bom_data in boms: for operation in bom.routing_id.operation_ids: operation_bom_qty[operation.id] = bom_data['qty'] finished_moves_modification = self._update_finished_moves(production, production.product_qty - qty_produced, old_production_qty) production._log_downside_manufactured_quantity(finished_moves_modification) moves = production.move_raw_ids.filtered(lambda x: x.state not in ('done', 'cancel')) moves._action_assign() for wo in production.workorder_ids: operation = wo.operation_id if operation_bom_qty.get(operation.id): cycle_number = float_round(operation_bom_qty[operation.id] / operation.workcenter_id.capacity, precision_digits=0, rounding_method='UP') wo.duration_expected = (operation.workcenter_id.time_start + operation.workcenter_id.time_stop + cycle_number * operation.time_cycle * 100.0 / operation.workcenter_id.time_efficiency) quantity = wo.qty_production - wo.qty_produced if production.product_id.tracking == 'serial': quantity = 1.0 if not float_is_zero(quantity, precision_digits=precision) else 0.0 else: quantity = quantity if (quantity > 0) else 0 if float_is_zero(quantity, precision_digits=precision): wo.finished_lot_id = False wo._workorder_line_ids().unlink() wo.qty_producing = quantity if wo.qty_produced < wo.qty_production and wo.state == 'done': wo.state = 'progress' if wo.qty_produced == wo.qty_production and wo.state == 'progress': wo.state = 'done' # assign moves; last operation receive all unassigned moves # TODO: following could be put in a function as it is similar as code in _workorders_create # TODO: only needed when creating new moves moves_raw = production.move_raw_ids.filtered(lambda move: move.operation_id == operation and move.state not in ('done', 'cancel')) if wo == production.workorder_ids[-1]: moves_raw |= production.move_raw_ids.filtered(lambda move: not move.operation_id) moves_finished = production.move_finished_ids.filtered(lambda move: move.operation_id == operation) #TODO: code does nothing, unless maybe by_products? moves_raw.mapped('move_line_ids').write({'workorder_id': wo.id}) (moves_finished + moves_raw).write({'workorder_id': wo.id}) if wo.state not in ('done', 'cancel'): line_values = wo._update_workorder_lines() wo._workorder_line_ids().create(line_values['to_create']) if line_values['to_delete']: line_values['to_delete'].unlink() for line, vals in line_values['to_update'].items(): line.write(vals) return {}
def _update_workorder_lines(self): """ Update workorder lines, according to the new qty currently produced. It returns a dict with line to create, update or delete. It do not directly write or unlink the line because this function is used in onchange and request that write on db (e.g. workorder creation). """ line_values = {'to_create': [], 'to_delete': [], 'to_update': {}} # moves are actual records move_finished_ids = self.move_finished_ids._origin.filtered( lambda move: move.product_id != self.product_id and move.state not in ('done', 'cancel')) move_raw_ids = self.move_raw_ids._origin.filtered( lambda move: move.state not in ('done', 'cancel')) for move in move_raw_ids | move_finished_ids: move_workorder_lines = self._workorder_line_ids().filtered( lambda w: w.move_id == move) # Compute the new quantity for the current component rounding = move.product_uom.rounding new_qty = self._prepare_component_quantity(move, self.qty_producing) # In case the production uom is different than the workorder uom # it means the product is serial and production uom is not the reference new_qty = self.product_uom_id._compute_quantity( new_qty, self.production_id.product_uom_id, round=False) qty_todo = float_round( new_qty - sum(move_workorder_lines.mapped('qty_to_consume')), precision_rounding=rounding) # Remove or lower quantity on exisiting workorder lines if float_compare(qty_todo, 0.0, precision_rounding=rounding) < 0: qty_todo = abs(qty_todo) # Try to decrease or remove lines that are not reserved and # partialy reserved first. A different decrease strategy could # be define in _unreserve_order method. for workorder_line in move_workorder_lines.sorted( key=lambda wl: wl._unreserve_order()): if float_compare(qty_todo, 0, precision_rounding=rounding) <= 0: break # If the quantity to consume on the line is lower than the # quantity to remove, the line could be remove. if float_compare(workorder_line.qty_to_consume, qty_todo, precision_rounding=rounding) <= 0: qty_todo = float_round(qty_todo - workorder_line.qty_to_consume, precision_rounding=rounding) if line_values['to_delete']: line_values['to_delete'] |= workorder_line else: line_values['to_delete'] = workorder_line # decrease the quantity on the line else: new_val = workorder_line.qty_to_consume - qty_todo # avoid to write a negative reserved quantity new_reserved = max( 0, workorder_line.qty_reserved - qty_todo) line_values['to_update'][workorder_line] = { 'qty_to_consume': new_val, 'qty_done': new_val, 'qty_reserved': new_reserved, } qty_todo = 0 else: # Search among wo lines which one could be updated qty_reserved_wl = defaultdict(float) # Try to update the line with the greater reservation first in # order to promote bigger batch. for workorder_line in move_workorder_lines.sorted( key=lambda wl: wl.qty_reserved, reverse=True): rounding = workorder_line.product_uom_id.rounding if float_compare(qty_todo, 0, precision_rounding=rounding) <= 0: break move_lines = workorder_line._get_move_lines() qty_reserved_wl[ workorder_line.lot_id] += workorder_line.qty_reserved # The reserved quantity according to exisiting move line # already produced (with qty_done set) and other production # lines with the same lot that are currently on production. qty_reserved_remaining = sum( move_lines.mapped('product_uom_qty')) - sum( move_lines.mapped('qty_done')) - qty_reserved_wl[ workorder_line.lot_id] if float_compare(qty_reserved_remaining, 0, precision_rounding=rounding) > 0: qty_to_add = min(qty_reserved_remaining, qty_todo) line_values['to_update'][workorder_line] = { 'qty_done': workorder_line.qty_to_consume + qty_to_add, 'qty_to_consume': workorder_line.qty_to_consume + qty_to_add, 'qty_reserved': workorder_line.qty_reserved + qty_to_add, } qty_todo -= qty_to_add qty_reserved_wl[workorder_line.lot_id] += qty_to_add # If a line exists without reservation and without lot. It # means that previous operations could not find any reserved # quantity and created a line without lot prefilled. In this # case, the system will not find an existing move line with # available reservation anymore and will increase this line # instead of creating a new line without lot and reserved # quantities. if not workorder_line.qty_reserved and not workorder_line.lot_id and workorder_line.product_tracking != 'serial': line_values['to_update'][workorder_line] = { 'qty_done': workorder_line.qty_to_consume + qty_todo, 'qty_to_consume': workorder_line.qty_to_consume + qty_todo, } qty_todo = 0 # if there are still qty_todo, create new wo lines if float_compare(qty_todo, 0.0, precision_rounding=rounding) > 0: for values in self._generate_lines_values(move, qty_todo): line_values['to_create'].append(values) return line_values
def _plan_prepare_values(self): currency = self.env.company.currency_id uom_hour = self.env.ref('uom.product_uom_hour') hour_rounding = uom_hour.rounding billable_types = [ 'non_billable', 'non_billable_project', 'billable_time', 'billable_fixed' ] values = { 'projects': self, 'currency': currency, 'timesheet_domain': [('project_id', 'in', self.ids)], 'profitability_domain': [('project_id', 'in', self.ids)], 'stat_buttons': self._plan_get_stat_button(), } # # Hours, Rates and Profitability # dashboard_values = { 'hours': dict.fromkeys(billable_types + ['total'], 0.0), 'rates': dict.fromkeys(billable_types + ['total'], 0.0), 'profit': { 'invoiced': 0.0, 'to_invoice': 0.0, 'cost': 0.0, 'total': 0.0, } } # hours from non-invoiced timesheets that are linked to canceled so canceled_hours_domain = [('project_id', 'in', self.ids), ('timesheet_invoice_type', '!=', False), ('so_line.state', '=', 'cancel')] total_canceled_hours = sum(self.env['account.analytic.line'].search( canceled_hours_domain).mapped('unit_amount')) dashboard_values['hours']['canceled'] = float_round( total_canceled_hours, precision_rounding=hour_rounding) dashboard_values['hours']['total'] += float_round( total_canceled_hours, precision_rounding=hour_rounding) # hours (from timesheet) and rates (by billable type) dashboard_domain = [('project_id', 'in', self.ids), ('timesheet_invoice_type', '!=', False), '|', ('so_line', '=', False), ('so_line.state', '!=', 'cancel') ] # force billable type dashboard_data = self.env['account.analytic.line'].read_group( dashboard_domain, ['unit_amount', 'timesheet_invoice_type'], ['timesheet_invoice_type']) dashboard_total_hours = sum( [data['unit_amount'] for data in dashboard_data]) + total_canceled_hours for data in dashboard_data: billable_type = data['timesheet_invoice_type'] dashboard_values['hours'][billable_type] = float_round( data.get('unit_amount'), precision_rounding=hour_rounding) dashboard_values['hours']['total'] += float_round( data.get('unit_amount'), precision_rounding=hour_rounding) # rates rate = round( data.get('unit_amount') / dashboard_total_hours * 100, 2) if dashboard_total_hours else 0.0 dashboard_values['rates'][billable_type] = rate dashboard_values['rates']['total'] += rate # rates from non-invoiced timesheets that are linked to canceled so dashboard_values['rates']['canceled'] = float_round( 100 * total_canceled_hours / (dashboard_total_hours or 1), precision_rounding=hour_rounding) # profitability, using profitability SQL report profit = dict.fromkeys([ 'invoiced', 'to_invoice', 'cost', 'expense_cost', 'expense_amount_untaxed_invoiced', 'total' ], 0.0) profitability_raw_data = self.env[ 'project.profitability.report'].read_group( [('project_id', 'in', self.ids)], [ 'project_id', 'amount_untaxed_to_invoice', 'amount_untaxed_invoiced', 'timesheet_cost', 'expense_cost', 'expense_amount_untaxed_invoiced' ], ['project_id']) for data in profitability_raw_data: profit['invoiced'] += data.get('amount_untaxed_invoiced', 0.0) profit['to_invoice'] += data.get('amount_untaxed_to_invoice', 0.0) profit['cost'] += data.get('timesheet_cost', 0.0) profit['expense_cost'] += data.get('expense_cost', 0.0) profit['expense_amount_untaxed_invoiced'] += data.get( 'expense_amount_untaxed_invoiced', 0.0) profit['total'] = sum([profit[item] for item in profit.keys()]) dashboard_values['profit'] = profit values['dashboard'] = dashboard_values # # Time Repartition (per employee per billable types) # user_ids = self.env['project.task'].sudo().read_group( [('project_id', 'in', self.ids), ('user_id', '!=', False)], ['user_id'], ['user_id']) user_ids = [user_id['user_id'][0] for user_id in user_ids] employee_ids = self.env['res.users'].sudo().search_read( [('id', 'in', user_ids)], ['employee_ids']) # flatten the list of list employee_ids = list( itertools.chain.from_iterable( [employee_id['employee_ids'] for employee_id in employee_ids])) aal_employee_ids = self.env['account.analytic.line'].read_group( [('project_id', 'in', self.ids), ('employee_id', '!=', False)], ['employee_id'], ['employee_id']) employee_ids.extend( list(map(lambda x: x['employee_id'][0], aal_employee_ids))) employees = self.env['hr.employee'].sudo().browse(employee_ids) repartition_domain = [('project_id', 'in', self.ids), ('employee_id', '!=', False), ('timesheet_invoice_type', '!=', False) ] # force billable type # repartition data, without timesheet on cancelled so repartition_data = self.env['account.analytic.line'].read_group( repartition_domain + ['|', ('so_line', '=', False), ('so_line.state', '!=', 'cancel')], ['employee_id', 'timesheet_invoice_type', 'unit_amount'], ['employee_id', 'timesheet_invoice_type'], lazy=False) # read timesheet on cancelled so cancelled_so_timesheet = self.env['account.analytic.line'].read_group( repartition_domain + [('so_line.state', '=', 'cancel')], ['employee_id', 'unit_amount'], ['employee_id'], lazy=False) repartition_data += [{ **canceled, 'timesheet_invoice_type': 'canceled' } for canceled in cancelled_so_timesheet] # set repartition per type per employee repartition_employee = {} for employee in employees: repartition_employee[employee.id] = dict( employee_id=employee.id, employee_name=employee.name, non_billable_project=0.0, non_billable=0.0, billable_time=0.0, billable_fixed=0.0, canceled=0.0, total=0.0, ) for data in repartition_data: employee_id = data['employee_id'][0] repartition_employee.setdefault( employee_id, dict( employee_id=data['employee_id'][0], employee_name=data['employee_id'][1], non_billable_project=0.0, non_billable=0.0, billable_time=0.0, billable_fixed=0.0, canceled=0.0, total=0.0, ))[data['timesheet_invoice_type']] = float_round( data.get('unit_amount', 0.0), precision_rounding=hour_rounding) repartition_employee[employee_id][ '__domain_' + data['timesheet_invoice_type']] = data['__domain'] # compute total for employee_id, vals in repartition_employee.items(): repartition_employee[employee_id]['total'] = sum( [vals[inv_type] for inv_type in [*billable_types, 'canceled']]) hours_per_employee = [ repartition_employee[employee_id]['total'] for employee_id in repartition_employee ] values['repartition_employee_max'] = (max(hours_per_employee) if hours_per_employee else 1) or 1 values['repartition_employee'] = repartition_employee # # Table grouped by SO / SOL / Employees # timesheet_forecast_table_rows = self._table_get_line_values() if timesheet_forecast_table_rows: values['timesheet_forecast_table'] = timesheet_forecast_table_rows return values