def _get_extra_move_lines_vals(self): res = super(PosSession, self)._get_extra_move_lines_vals() if not self.config_id.cash_rounding: return res rounding_difference = {'amount': 0.0, 'amount_converted': 0.0} rounding_vals = [] for order in self.order_ids: if not order.is_invoiced: rounding_difference['amount'] += self.currency_id.round( order.amount_paid - order.amount_total) if not self.is_in_company_currency: difference = sum(self.move_id.line_ids.mapped('debit')) - sum( self.move_id.line_ids.mapped('credit')) rounding_difference[ 'amount_converted'] = self.company_id.currency_id.round( difference) else: rounding_difference['amount_converted'] = rounding_difference[ 'amount'] if (not float_is_zero(rounding_difference['amount'], precision_rounding=self.currency_id.rounding) or not float_is_zero( rounding_difference['amount_converted'], precision_rounding=self.company_id.currency_id.rounding)): rounding_vals += [ self._get_rounding_difference_vals( rounding_difference['amount'], rounding_difference['amount_converted']) ] return res + rounding_vals
def _compute_average_price(self, qty_invoiced, qty_to_invoice, stock_moves): """Go over the valuation layers of `stock_moves` to value `qty_to_invoice` while taking care of ignoring `qty_invoiced`. If `qty_to_invoice` is greater than what's possible to value with the valuation layers, use the product's standard price. :param qty_invoiced: quantity already invoiced :param qty_to_invoice: quantity to invoice :param stock_moves: recordset of `stock.move` :returns: the anglo saxon price unit :rtype: float """ self.ensure_one() if not qty_to_invoice: return 0.0 candidates = stock_moves\ .sudo()\ .mapped('stock_valuation_layer_ids')\ .sorted() qty_to_take_on_candidates = qty_to_invoice tmp_value = 0 # to accumulate the value taken on the candidates for candidate in candidates: candidate_quantity = abs(candidate.quantity) if float_is_zero(candidate_quantity, precision_rounding=candidate.uom_id.rounding): continue # correction entries if not float_is_zero(qty_invoiced, precision_rounding=candidate.uom_id.rounding): qty_ignored = min(qty_invoiced, candidate_quantity) qty_invoiced -= qty_ignored candidate_quantity -= qty_ignored if float_is_zero(candidate_quantity, precision_rounding=candidate.uom_id.rounding): continue qty_taken_on_candidate = min(qty_to_take_on_candidates, candidate_quantity) qty_to_take_on_candidates -= qty_taken_on_candidate tmp_value += qty_taken_on_candidate * \ ((candidate.value + sum(candidate.stock_valuation_layer_ids.mapped('value'))) / candidate.quantity) if float_is_zero(qty_to_take_on_candidates, precision_rounding=candidate.uom_id.rounding): break # If there's still quantity to invoice but we're out of candidates, we chose the standard # price to estimate the anglo saxon price unit. if not float_is_zero(qty_to_take_on_candidates, precision_rounding=self.uom_id.rounding): negative_stock_value = self.standard_price * qty_to_take_on_candidates tmp_value += negative_stock_value return tmp_value / qty_to_invoice
def _sale_get_invoice_price(self, order): """ Based on the current move line, compute the price to reinvoice the analytic line that is going to be created (so the price of the sale line). """ self.ensure_one() unit_amount = self.quantity amount = (self.credit or 0.0) - (self.debit or 0.0) if self.product_id.expense_policy == 'sales_price': return self.product_id.with_context( partner=order.partner_id.id, date_order=order.date_order, pricelist=order.pricelist_id.id, uom=self.product_uom_id.id ).price uom_precision_digits = self.env['decimal.precision'].precision_get('Product Unit of Measure') if float_is_zero(unit_amount, precision_digits=uom_precision_digits): return 0.0 # Prevent unnecessary currency conversion that could be impacted by exchange rate # fluctuations if self.company_id.currency_id and amount and self.company_id.currency_id == order.currency_id: return abs(amount / unit_amount) price_unit = abs(amount / unit_amount) currency_id = self.company_id.currency_id if currency_id and currency_id != order.currency_id: price_unit = currency_id._convert(price_unit, order.currency_id, order.company_id, order.date_order or fields.Date.today()) return price_unit
def _create_out_svl(self, forced_quantity=None): """Create a `stock.valuation.layer` from `self`. :param forced_quantity: under some circunstances, the quantity to value is different than the initial demand of the move (Default value = None) """ svl_vals_list = [] for move in self: move = move.with_context(force_company=move.company_id.id) valued_move_lines = move._get_out_move_lines() 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, move.product_id.uom_id) if float_is_zero( forced_quantity or valued_quantity, precision_rounding=move.product_id.uom_id.rounding): continue svl_vals = move.product_id._prepare_out_svl_vals( forced_quantity or valued_quantity, move.company_id) svl_vals.update(move._prepare_common_svl_vals()) if forced_quantity: svl_vals[ 'description'] = 'Correction of %s (modification of past move)' % move.picking_id.name or move.name svl_vals_list.append(svl_vals) return self.env['stock.valuation.layer'].sudo().create(svl_vals_list)
def product_price_update_before_done(self, forced_qty=None): tmpl_dict = defaultdict(lambda: 0.0) # adapt standard price on incomming moves if the product cost_method is 'average' std_price_update = {} for move in self.filtered(lambda move: move._is_in( ) and move.with_context(force_company=move.company_id.id).product_id. cost_method == 'average'): product_tot_qty_available = move.product_id.sudo().with_context( force_company=move.company_id.id).quantity_svl + tmpl_dict[ move.product_id.id] rounding = move.product_id.uom_id.rounding valued_move_lines = move._get_in_move_lines() qty_done = 0 for valued_move_line in valued_move_lines: qty_done += valued_move_line.product_uom_id._compute_quantity( valued_move_line.qty_done, move.product_id.uom_id) qty = forced_qty or qty_done if float_is_zero(product_tot_qty_available, precision_rounding=rounding): new_std_price = move._get_price_unit() elif float_is_zero(product_tot_qty_available + move.product_qty, precision_rounding=rounding) or \ float_is_zero(product_tot_qty_available + qty, precision_rounding=rounding): new_std_price = move._get_price_unit() else: # Get the standard price amount_unit = std_price_update.get( (move.company_id.id, move.product_id.id)) or move.product_id.with_context( force_company=move.company_id.id).standard_price new_std_price = ((amount_unit * product_tot_qty_available) + (move._get_price_unit() * qty)) / ( product_tot_qty_available + qty) tmpl_dict[move.product_id.id] += qty_done # Write the standard price, as SUPERUSER_ID because a warehouse manager may not have the right to write on products move.product_id.with_context( force_company=move.company_id.id).sudo().write( {'standard_price': new_std_price}) std_price_update[move.company_id.id, move.product_id.id] = new_std_price
def _check_sum(self): """ Check if each cost line its valuation lines sum to the correct amount and if the overall total amount is correct also """ prec_digits = self.env.company.currency_id.decimal_places for landed_cost in self: total_amount = sum( landed_cost.valuation_adjustment_lines.mapped( 'additional_landed_cost')) if not tools.float_is_zero(total_amount - landed_cost.amount_total, precision_digits=prec_digits): return False val_to_cost_lines = defaultdict(lambda: 0.0) for val_line in landed_cost.valuation_adjustment_lines: val_to_cost_lines[ val_line.cost_line_id] += val_line.additional_landed_cost if any(not tools.float_is_zero(cost_line.price_unit - val_amount, precision_digits=prec_digits) for cost_line, val_amount in val_to_cost_lines.items()): return False return True
def create(self, vals_list): move_lines = super(StockMoveLine, self).create(vals_list) for move_line in move_lines: if move_line.state != 'done': continue move = move_line.move_id rounding = move.product_id.uom_id.rounding diff = move_line.qty_done if float_is_zero(diff, precision_rounding=rounding): continue self._create_correction_svl(move, diff) return move_lines
def write(self, vals): if 'qty_done' in vals: for move_line in self: if move_line.state != 'done': continue move = move_line.move_id rounding = move.product_id.uom_id.rounding diff = vals['qty_done'] - move_line.qty_done if float_is_zero(diff, precision_rounding=rounding): continue self._create_correction_svl(move, diff) return super(StockMoveLine, self).write(vals)
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_is_zero(0.0, precision_rounding=0.0) with self.assertRaises(AssertionError): float_is_zero(0.0, precision_rounding=-0.1) with self.assertRaises(AssertionError): float_compare(0.01, 0.02, precision_digits=3, precision_rounding=0.01) with self.assertRaises(AssertionError): float_compare(1.0, 1.0, precision_rounding=0.0) with self.assertRaises(AssertionError): float_compare(1.0, 1.0, precision_rounding=-0.1) with self.assertRaises(AssertionError): float_round(0.01, precision_digits=3, precision_rounding=0.01) with self.assertRaises(AssertionError): float_round(1.25, precision_rounding=0.0) with self.assertRaises(AssertionError): float_round(1.25, precision_rounding=-0.1)
def _search_difference_qty(self, operator, value): if operator == '=': result = True elif operator == '!=': result = False else: raise NotImplementedError() lines = self.search([('inventory_id', '=', self.env.context.get('default_inventory_id'))]) line_ids = lines.filtered( lambda line: float_is_zero(line.difference_qty, line.product_id. uom_id.rounding) == result).ids return [('id', 'in', line_ids)]
def _costs_generate(self): """ Calculates total costs at the end of the production. """ self.ensure_one() AccountAnalyticLine = self.env['account.analytic.line'].sudo() for wc_line in self.workorder_ids.filtered( 'workcenter_id.costs_hour_account_id'): vals = self._prepare_wc_analytic_line(wc_line) precision_rounding = wc_line.workcenter_id.costs_hour_account_id.currency_id.rounding if not float_is_zero(vals.get('amount', 0.0), precision_rounding=precision_rounding): # we use SUPERUSER_ID as we do not guarantee an mrp user # has access to account analytic lines but still should be # able to produce orders AccountAnalyticLine.create(vals)
def _compute_kit_quantities(self, product_id, kit_qty, kit_bom, filters): """ Computes the quantity delivered or received when a kit is sold or purchased. A ratio 'qty_processed/qty_needed' is computed for each component, and the lowest one is kept to define the kit's quantity delivered or received. :param product_id: The kit itself a.k.a. the finished product :param kit_qty: The quantity from the order line :param kit_bom: The kit's BoM :param filters: Dict of lambda expression to define the moves to consider and the ones to ignore :return: The quantity delivered or received """ qty_ratios = [] boms, bom_sub_lines = kit_bom.explode(product_id, kit_qty) for bom_line, bom_line_data in bom_sub_lines: bom_line_moves = self.filtered(lambda m: m.bom_line_id == bom_line) if bom_line_moves: if float_is_zero( bom_line_data['qty'], precision_rounding=bom_line.product_uom_id.rounding): # As BoMs allow components with 0 qty, a.k.a. optionnal components, we simply skip those # to avoid a division by zero. continue # We compute the quantities needed of each components to make one kit. # Then, we collect every relevant moves related to a specific component # to know how many are considered delivered. uom_qty_per_kit = bom_line_data['qty'] / bom_line_data[ 'original_qty'] qty_per_kit = bom_line.product_uom_id._compute_quantity( uom_qty_per_kit, bom_line.product_id.uom_id) if not qty_per_kit: continue incoming_moves = bom_line_moves.filtered( filters['incoming_moves']) outgoing_moves = bom_line_moves.filtered( filters['outgoing_moves']) qty_processed = sum( incoming_moves.mapped('product_qty')) - sum( outgoing_moves.mapped('product_qty')) # We compute a ratio to know how many kits we can produce with this quantity of that specific component qty_ratios.append(qty_processed / qty_per_kit) else: return 0.0 if qty_ratios: # Now that we have every ratio by components, we keep the lowest one to know how many kits we can produce # with the quantities delivered of each component. We use the floor division here because a 'partial kit' # doesn't make sense. return min(qty_ratios) // 1 else: return 0.0
def _onchange_qty_done(self): """ When the user is encoding a produce line for a tracked product, we apply some logic to help him. This onchange will warn him if he set `qty_done` to a non-supported value. """ res = {} if self.product_id.tracking == 'serial' and not float_is_zero( self.qty_done, self.product_uom_id.rounding): if float_compare( self.qty_done, 1.0, precision_rounding=self.product_uom_id.rounding) != 0: message = _( 'You can only process 1.0 %s of products with unique serial number.' ) % self.product_id.uom_id.name res['warning'] = {'title': _('Warning'), 'message': message} return res
def _generate_moves(self): vals_list = [] for line in self: virtual_location = line._get_virtual_location() rounding = line.product_id.uom_id.rounding if float_is_zero(line.difference_qty, precision_rounding=rounding): continue if line.difference_qty > 0: # found more than expected vals = line._get_move_values(line.difference_qty, virtual_location.id, line.location_id.id, False) else: vals = line._get_move_values(abs(line.difference_qty), line.location_id.id, virtual_location.id, True) vals_list.append(vals) return self.env['stock.move'].create(vals_list)
def run(self, procurements): """ Method used in a procurement case. The purpose is to supply the product passed as argument in the location also given as an argument. In order to be able to find a suitable location that provide the product it will search among stock.rule. """ actions_to_run = defaultdict(list) errors = [] for procurement in procurements: procurement.values.setdefault('company_id', self.env.company) procurement.values.setdefault('priority', '1') procurement.values.setdefault('date_planned', fields.Datetime.now()) if ( procurement.product_id.type not in ('consu', 'product') or float_is_zero(procurement.product_qty, precision_rounding=procurement.product_uom.rounding) ): continue rule = self._get_rule(procurement.product_id, procurement.location_id, procurement.values) if not rule: errors.append(_('No rule has been found to replenish "%s" in "%s".\nVerify the routes configuration on the product.') % (procurement.product_id.display_name, procurement.location_id.display_name)) else: action = 'pull' if rule.action == 'pull_push' else rule.action actions_to_run[action].append((procurement, rule)) if errors: raise UserError('\n'.join(errors)) for action, procurements in actions_to_run.items(): if hasattr(self.env['stock.rule'], '_run_%s' % action): try: getattr(self.env['stock.rule'], '_run_%s' % action)(procurements) except UserError as e: errors.append(e.name) else: _logger.error("The method _run_%s doesn't exist on the procurement rules" % action) if errors: raise UserError('\n'.join(errors)) return True
def _check_rule_propositions(self, statement_line, candidates): ''' Check restrictions that can't be handled for each move.line separately. /!\ Only used by models having a type equals to 'invoice_matching'. :param statement_line: An account.bank.statement.line record. :param candidates: Fetched account.move.lines from query (dict). :return: True if the reconciliation propositions are accepted. False otherwise. ''' if not self.match_total_amount: return True if not candidates: return False # Match total residual amount. line_residual = statement_line.currency_id and statement_line.amount_currency or statement_line.amount line_currency = statement_line.currency_id or statement_line.journal_id.currency_id or statement_line.company_id.currency_id total_residual = 0.0 for aml in candidates: if aml['account_internal_type'] == 'liquidity': partial_residual = aml['aml_currency_id'] and aml['aml_amount_currency'] or aml['aml_balance'] else: partial_residual = aml['aml_currency_id'] and aml['aml_amount_residual_currency'] or aml['aml_amount_residual'] partial_currency = aml['aml_currency_id'] and self.env['res.currency'].browse(aml['aml_currency_id']) or self.company_id.currency_id if partial_currency != line_currency: partial_residual = partial_currency._convert(partial_residual, line_currency, self.company_id, statement_line.date) total_residual += partial_residual # Statement line amount is equal to the total residual. if float_is_zero(total_residual - line_residual, precision_rounding=line_currency.rounding): return True line_residual_to_compare = line_residual if line_residual > 0.0 else -line_residual total_residual_to_compare = total_residual if line_residual > 0.0 else -total_residual if line_residual_to_compare > total_residual_to_compare: amount_percentage = (total_residual_to_compare / line_residual_to_compare) * 100 elif total_residual: amount_percentage = (line_residual_to_compare / total_residual_to_compare) * 100 if total_residual_to_compare else 0.0 else: return False return amount_percentage >= self.match_total_amount_param
def _svl_empty_stock(self, description, product_category=None, product_template=None): impacted_product_ids = [] impacted_products = self.env['product.product'] products_orig_quantity_svl = {} # get the impacted products domain = [('type', '=', 'product')] if product_category is not None: domain += [('categ_id', '=', product_category.id)] elif product_template is not None: domain += [('product_tmpl_id', '=', product_template.id)] else: raise ValueError() products = self.env['product.product'].search_read( domain, ['quantity_svl']) for product in products: impacted_product_ids.append(product['id']) products_orig_quantity_svl[product['id']] = product['quantity_svl'] impacted_products |= self.env['product.product'].browse( impacted_product_ids) # empty out the stock for the impacted products empty_stock_svl_list = [] for product in impacted_products: # FIXME sle: why not use products_orig_quantity_svl here? if float_is_zero(product.quantity_svl, precision_rounding=product.uom_id.rounding): # FIXME: create an empty layer to track the change? continue svsl_vals = product._prepare_out_svl_vals(product.quantity_svl, self.env.company) svsl_vals['description'] = description svsl_vals['company_id'] = self.env.company.id empty_stock_svl_list.append(svsl_vals) return empty_stock_svl_list, products_orig_quantity_svl, impacted_products
def summary(self): res = super(EventRegistration, self).summary() if self.event_ticket_id.product_id.image_128: res['image'] = '/web/image/product.product/%s/image_128' % self.event_ticket_id.product_id.id information = res.setdefault('information', {}) information.append((_('Name'), self.name)) information.append((_('Ticket'), self.event_ticket_id.name or _('None'))) order = self.sale_order_id.sudo() order_line = self.sale_order_line_id.sudo() if not order or float_is_zero( order_line.price_total, precision_digits=order.currency_id.rounding): payment_status = _('Free') elif not order.invoice_ids or any( invoice.invoice_payment_state != 'paid' for invoice in order.invoice_ids): payment_status = _('To pay') res['alert'] = _('The registration must be paid') else: payment_status = _('Paid') information.append((_('Payment'), payment_status)) return res
def check(self): """Check the order: if the order is not paid: continue payment, if the order is paid print ticket. """ self.ensure_one() order = self.env['pos.order'].browse(self.env.context.get('active_id', False)) currency = order.currency_id init_data = self.read()[0] if not float_is_zero(init_data['amount'], precision_rounding=currency.rounding): order.add_payment({ 'pos_order_id': order.id, 'amount': order._get_rounded_amount(init_data['amount']), 'name': init_data['payment_name'], 'payment_method_id': init_data['payment_method_id'][0], }) if order._is_pos_order_paid(): order.action_pos_order_paid() return {'type': 'ir.actions.act_window_close'} return self.launch_payment()
def _run_fifo_vacuum(self, company=None): """Compensate layer valued at an estimated price with the price of future receipts if any. If the estimated price is equals to the real price, no layer is created but the original layer is marked as compensated. :param company: recordset of `res.company` to limit the execution of the vacuum """ self.ensure_one() if company is None: company = self.env.company svls_to_vacuum = self.env['stock.valuation.layer'].sudo().search( [ ('product_id', '=', self.id), ('remaining_qty', '<', 0), ('stock_move_id', '!=', False), ('company_id', '=', company.id), ], order='create_date, id') for svl_to_vacuum in svls_to_vacuum: domain = [('company_id', '=', svl_to_vacuum.company_id.id), ('product_id', '=', self.id), ('remaining_qty', '>', 0), '|', ('create_date', '>', svl_to_vacuum.create_date), '&', ('create_date', '=', svl_to_vacuum.create_date), ('id', '>', svl_to_vacuum.id)] candidates = self.env['stock.valuation.layer'].sudo().search( domain) if not candidates: break qty_to_take_on_candidates = abs(svl_to_vacuum.remaining_qty) qty_taken_on_candidates = 0 tmp_value = 0 for candidate in candidates: qty_taken_on_candidate = min(candidate.remaining_qty, qty_to_take_on_candidates) qty_taken_on_candidates += qty_taken_on_candidate candidate_unit_cost = candidate.remaining_value / candidate.remaining_qty value_taken_on_candidate = qty_taken_on_candidate * candidate_unit_cost value_taken_on_candidate = candidate.currency_id.round( value_taken_on_candidate) new_remaining_value = candidate.remaining_value - value_taken_on_candidate candidate_vals = { 'remaining_qty': candidate.remaining_qty - qty_taken_on_candidate, 'remaining_value': new_remaining_value } candidate.write(candidate_vals) qty_to_take_on_candidates -= qty_taken_on_candidate tmp_value += value_taken_on_candidate if float_is_zero(qty_to_take_on_candidates, precision_rounding=self.uom_id.rounding): break # Get the estimated value we will correct. remaining_value_before_vacuum = svl_to_vacuum.unit_cost * qty_taken_on_candidates new_remaining_qty = svl_to_vacuum.remaining_qty + qty_taken_on_candidates corrected_value = remaining_value_before_vacuum - tmp_value svl_to_vacuum.write({ 'remaining_qty': new_remaining_qty, }) # Don't create a layer or an accounting entry if the corrected value is zero. if svl_to_vacuum.currency_id.is_zero(corrected_value): continue corrected_value = svl_to_vacuum.currency_id.round(corrected_value) move = svl_to_vacuum.stock_move_id vals = { 'product_id': self.id, 'value': corrected_value, 'unit_cost': 0, 'quantity': 0, 'remaining_qty': 0, 'stock_move_id': move.id, 'company_id': move.company_id.id, 'description': 'Revaluation of %s (negative inventory)' % move.picking_id.name or move.name, 'stock_valuation_layer_id': svl_to_vacuum.id, } vacuum_svl = self.env['stock.valuation.layer'].sudo().create(vals) # If some negative stock were fixed, we need to recompute the standard price. product = self.with_context(force_company=company.id) if product.cost_method == 'average' and not float_is_zero( product.quantity_svl, precision_rounding=self.uom_id.rounding): product.sudo().write({ 'standard_price': product.value_svl / product.quantity_svl }) # Create the account move. if self.valuation != 'real_time': continue vacuum_svl.stock_move_id._account_entry_move( vacuum_svl.quantity, vacuum_svl.description, vacuum_svl.id, vacuum_svl.value)
def test_invoice(self): """ Test create and invoice from the SO, and check qty invoice/to invoice, and the related amounts """ # lines are in draft for line in self.sale_order.order_line: self.assertTrue( float_is_zero(line.untaxed_amount_to_invoice, precision_digits=2), "The amount to invoice should be zero, as the line is in draf state" ) self.assertTrue( float_is_zero(line.untaxed_amount_invoiced, precision_digits=2), "The invoiced amount should be zero, as the line is in draft state" ) # Confirm the SO self.sale_order.action_confirm() # Check ordered quantity, quantity to invoice and invoiced quantity of SO lines for line in self.sale_order.order_line: if line.product_id.invoice_policy == 'delivery': self.assertEquals( line.qty_to_invoice, 0.0, 'Quantity to invoice should be same as ordered quantity') self.assertEquals( line.qty_invoiced, 0.0, 'Invoiced quantity should be zero as no any invoice created for SO' ) self.assertEquals( line.untaxed_amount_to_invoice, 0.0, "The amount to invoice should be zero, as the line based on delivered quantity" ) self.assertEquals( line.untaxed_amount_invoiced, 0.0, "The invoiced amount should be zero, as the line based on delivered quantity" ) else: self.assertEquals( line.qty_to_invoice, line.product_uom_qty, 'Quantity to invoice should be same as ordered quantity') self.assertEquals( line.qty_invoiced, 0.0, 'Invoiced quantity should be zero as no any invoice created for SO' ) self.assertEquals( line.untaxed_amount_to_invoice, line.product_uom_qty * line.price_unit, "The amount to invoice should the total of the line, as the line is confirmed" ) self.assertEquals( line.untaxed_amount_invoiced, 0.0, "The invoiced amount should be zero, as the line is confirmed" ) # Let's do an invoice with invoiceable lines payment = self.env['sale.advance.payment.inv'].with_context( self.context).create({'advance_payment_method': 'delivered'}) payment.create_invoices() invoice = self.sale_order.invoice_ids[0] # Update quantity of an invoice lines move_form = Form(invoice) with move_form.invoice_line_ids.edit(0) as line_form: line_form.quantity = 3.0 with move_form.invoice_line_ids.edit(1) as line_form: line_form.quantity = 2.0 invoice = move_form.save() # amount to invoice / invoiced should not have changed (amounts take only confirmed invoice into account) for line in self.sale_order.order_line: if line.product_id.invoice_policy == 'delivery': self.assertEquals(line.qty_to_invoice, 0.0, "Quantity to invoice should be zero") self.assertEquals( line.qty_invoiced, 0.0, "Invoiced quantity should be zero as delivered lines are not delivered yet" ) self.assertEquals( line.untaxed_amount_to_invoice, 0.0, "The amount to invoice should be zero, as the line based on delivered quantity (no confirmed invoice)" ) self.assertEquals( line.untaxed_amount_invoiced, 0.0, "The invoiced amount should be zero, as no invoice are validated for now" ) else: if line == self.sol_prod_order: self.assertEquals( self.sol_prod_order.qty_to_invoice, 2.0, "Changing the quantity on draft invoice update the qty to invoice on SO lines" ) self.assertEquals( self.sol_prod_order.qty_invoiced, 3.0, "Changing the quantity on draft invoice update the invoiced qty on SO lines" ) else: self.assertEquals( self.sol_serv_order.qty_to_invoice, 1.0, "Changing the quantity on draft invoice update the qty to invoice on SO lines" ) self.assertEquals( self.sol_serv_order.qty_invoiced, 2.0, "Changing the quantity on draft invoice update the invoiced qty on SO lines" ) self.assertEquals( line.untaxed_amount_to_invoice, line.product_uom_qty * line.price_unit, "The amount to invoice should the total of the line, as the line is confirmed (no confirmed invoice)" ) self.assertEquals( line.untaxed_amount_invoiced, 0.0, "The invoiced amount should be zero, as no invoice are validated for now" ) invoice.post() # Check quantity to invoice on SO lines for line in self.sale_order.order_line: if line.product_id.invoice_policy == 'delivery': self.assertEquals( line.qty_to_invoice, 0.0, "Quantity to invoice should be same as ordered quantity") self.assertEquals( line.qty_invoiced, 0.0, "Invoiced quantity should be zero as no any invoice created for SO" ) self.assertEquals( line.untaxed_amount_to_invoice, 0.0, "The amount to invoice should be zero, as the line based on delivered quantity" ) self.assertEquals( line.untaxed_amount_invoiced, 0.0, "The invoiced amount should be zero, as the line based on delivered quantity" ) else: if line == self.sol_prod_order: self.assertEquals( line.qty_to_invoice, 2.0, "The ordered sale line are totally invoiced (qty to invoice is zero)" ) self.assertEquals( line.qty_invoiced, 3.0, "The ordered (prod) sale line are totally invoiced (qty invoiced come from the invoice lines)" ) else: self.assertEquals( line.qty_to_invoice, 1.0, "The ordered sale line are totally invoiced (qty to invoice is zero)" ) self.assertEquals( line.qty_invoiced, 2.0, "The ordered (serv) sale line are totally invoiced (qty invoiced = the invoice lines)" ) self.assertEquals( line.untaxed_amount_to_invoice, line.price_unit * line.qty_to_invoice, "Amount to invoice is now set as qty to invoice * unit price since no price change on invoice, for ordered products" ) self.assertEquals( line.untaxed_amount_invoiced, line.price_unit * line.qty_invoiced, "Amount invoiced is now set as qty invoiced * unit price since no price change on invoice, for ordered products" )
def test_invoice_with_discount(self): """ Test invoice with a discount and check discount applied on both SO lines and an invoice lines """ # Update discount and delivered quantity on SO lines self.sol_prod_order.write({'discount': 20.0}) self.sol_serv_deliver.write({'discount': 20.0, 'qty_delivered': 4.0}) self.sol_serv_order.write({'discount': -10.0}) self.sol_prod_deliver.write({'qty_delivered': 2.0}) for line in self.sale_order.order_line.filtered(lambda l: l.discount): product_price = line.price_unit * line.product_uom_qty self.assertEquals(line.discount, (product_price - line.price_subtotal) / product_price * 100, 'Discount should be applied on order line') # lines are in draft for line in self.sale_order.order_line: self.assertTrue( float_is_zero(line.untaxed_amount_to_invoice, precision_digits=2), "The amount to invoice should be zero, as the line is in draf state" ) self.assertTrue( float_is_zero(line.untaxed_amount_invoiced, precision_digits=2), "The invoiced amount should be zero, as the line is in draft state" ) self.sale_order.action_confirm() for line in self.sale_order.order_line: self.assertTrue( float_is_zero(line.untaxed_amount_invoiced, precision_digits=2), "The invoiced amount should be zero, as the line is in draft state" ) self.assertEquals(self.sol_serv_order.untaxed_amount_to_invoice, 297, "The untaxed amount to invoice is wrong") self.assertEquals( self.sol_serv_deliver.untaxed_amount_to_invoice, self.sol_serv_deliver.qty_delivered * self.sol_serv_deliver.price_reduce, "The untaxed amount to invoice should be qty deli * price reduce, so 4 * (180 - 36)" ) self.assertEquals( self.sol_prod_deliver.untaxed_amount_to_invoice, 140, "The untaxed amount to invoice should be qty deli * price reduce, so 4 * (180 - 36)" ) # Let's do an invoice with invoiceable lines payment = self.env['sale.advance.payment.inv'].with_context( self.context).create({'advance_payment_method': 'delivered'}) payment.create_invoices() invoice = self.sale_order.invoice_ids[0] invoice.post() # Check discount appeared on both SO lines and invoice lines for line, inv_line in zip(self.sale_order.order_line, invoice.invoice_line_ids): self.assertEquals( line.discount, inv_line.discount, 'Discount on lines of order and invoice should be same')
def try_zero(amount, expected): self.assertEqual(float_is_zero(amount, precision_digits=3), expected, "Rounding error: %s should be zero!" % amount)
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' if wo.next_work_order_id.state == 'pending': wo.next_work_order_id.state = 'ready' # 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 _apply_rules(self, st_lines, excluded_ids=None, partner_map=None): ''' Apply criteria to get candidates for all reconciliation models. :param st_lines: Account.bank.statement.lines recordset. :param excluded_ids: Account.move.lines to exclude. :param partner_map: Dict mapping each line with new partner eventually. :return: A dict mapping each statement line id with: * aml_ids: A list of account.move.line ids. * model: An account.reconcile.model record (optional). * status: 'reconciled' if the lines has been already reconciled, 'write_off' if the write-off must be applied on the statement line. ''' available_models = self.filtered(lambda m: m.rule_type != 'writeoff_button') results = dict((r.id, {'aml_ids': []}) for r in st_lines) if not available_models: return results ordered_models = available_models.sorted(key=lambda m: (m.sequence, m.id)) grouped_candidates = {} # Type == 'invoice_matching'. # Map each (st_line.id, model_id) with matching amls. invoices_models = ordered_models.filtered(lambda m: m.rule_type == 'invoice_matching') self.env['account.move'].flush(['state']) self.env['account.move.line'].flush(['balance', 'reconciled']) self.env['account.bank.statement.line'].flush(['company_id']) if invoices_models: query, params = invoices_models._get_invoice_matching_query(st_lines, excluded_ids=excluded_ids, partner_map=partner_map) self._cr.execute(query, params) query_res = self._cr.dictfetchall() for res in query_res: grouped_candidates.setdefault(res['id'], {}) grouped_candidates[res['id']].setdefault(res['model_id'], []) grouped_candidates[res['id']][res['model_id']].append(res) # Type == 'writeoff_suggestion'. # Map each (st_line.id, model_id) with a flag indicating the st_line matches the criteria. write_off_models = ordered_models.filtered(lambda m: m.rule_type == 'writeoff_suggestion') if write_off_models: query, params = write_off_models._get_writeoff_suggestion_query(st_lines, excluded_ids=excluded_ids, partner_map=partner_map) self._cr.execute(query, params) query_res = self._cr.dictfetchall() for res in query_res: grouped_candidates.setdefault(res['id'], {}) grouped_candidates[res['id']].setdefault(res['model_id'], True) # Keep track of already processed amls. amls_ids_to_exclude = set() # Keep track of already reconciled amls. reconciled_amls_ids = set() # Iterate all and create results. for line in st_lines: line_currency = line.currency_id or line.journal_id.currency_id or line.company_id.currency_id line_residual = line.currency_id and line.amount_currency or line.amount # Search for applicable rule. # /!\ BREAK are very important here to avoid applying multiple rules on the same line. for model in ordered_models: # No result found. if not grouped_candidates.get(line.id) or not grouped_candidates[line.id].get(model.id): continue if model.rule_type == 'invoice_matching': candidates = grouped_candidates[line.id][model.id] # If some invoices match on the communication, suggest them. # Otherwise, suggest all invoices having the same partner. # N.B: The only way to match a line without a partner is through the communication. first_batch_candidates = [] first_batch_candidates_proposed = [] second_batch_candidates = [] second_batch_candidates_proposed = [] third_batch_candidates = [] third_batch_candidates_proposed = [] for c in candidates: # Don't take into account already reconciled lines. if c['aml_id'] in reconciled_amls_ids: continue # Dispatch candidates between lines matching invoices with the communication or only the partner. elif c['payment_reference_flag']: if c['aml_id'] in amls_ids_to_exclude: first_batch_candidates_proposed.append(c) else: first_batch_candidates.append(c) elif c['communication_flag']: if c['aml_id'] in amls_ids_to_exclude: second_batch_candidates_proposed.append(c) else: second_batch_candidates.append(c) elif not first_batch_candidates: if c['aml_id'] in amls_ids_to_exclude: third_batch_candidates_proposed.append(c) else: third_batch_candidates.append(c) available_candidates = (first_batch_candidates + first_batch_candidates_proposed or second_batch_candidates + second_batch_candidates_proposed or third_batch_candidates + third_batch_candidates_proposed) # Special case: the amount are the same, submit the line directly. for c in available_candidates: residual_amount = c['aml_currency_id'] and c['aml_amount_residual_currency'] or c['aml_amount_residual'] if float_is_zero(residual_amount - line_residual, precision_rounding=line_currency.rounding): available_candidates = [c] break # Needed to handle check on total residual amounts. if first_batch_candidates or first_batch_candidates_proposed or model._check_rule_propositions(line, available_candidates): results[line.id]['model'] = model # Add candidates to the result. for candidate in available_candidates: results[line.id]['aml_ids'].append(candidate['aml_id']) amls_ids_to_exclude.add(candidate['aml_id']) # Create write-off lines. move_lines = self.env['account.move.line'].browse(results[line.id]['aml_ids']) partner = partner_map and partner_map.get(line.id) and self.env['res.partner'].browse(partner_map[line.id]) reconciliation_results = model._prepare_reconciliation(line, move_lines, partner=partner) # A write-off must be applied. if reconciliation_results['new_aml_dicts']: results[line.id]['status'] = 'write_off' # Process auto-reconciliation. if (first_batch_candidates or second_batch_candidates) and model.auto_reconcile: # An open balance is needed but no partner has been found. if reconciliation_results['open_balance_dict'] is False: break new_aml_dicts = reconciliation_results['new_aml_dicts'] if reconciliation_results['open_balance_dict']: new_aml_dicts.append(reconciliation_results['open_balance_dict']) if not line.partner_id and partner: line.partner_id = partner counterpart_moves = line.process_reconciliation( counterpart_aml_dicts=reconciliation_results['counterpart_aml_dicts'], payment_aml_rec=reconciliation_results['payment_aml_rec'], new_aml_dicts=new_aml_dicts, ) results[line.id]['status'] = 'reconciled' results[line.id]['reconciled_lines'] = counterpart_moves.mapped('line_ids') # The reconciled move lines are no longer candidates for another rule. reconciled_amls_ids.update(move_lines.ids) # Break models loop. break elif model.rule_type == 'writeoff_suggestion' and grouped_candidates[line.id][model.id]: results[line.id]['model'] = model results[line.id]['status'] = 'write_off' # Create write-off lines. partner = partner_map and partner_map.get(line.id) and self.env['res.partner'].browse(partner_map[line.id]) reconciliation_results = model._prepare_reconciliation(line, partner=partner) # An open balance is needed but no partner has been found. if reconciliation_results['open_balance_dict'] is False: break # Process auto-reconciliation. if model.auto_reconcile: new_aml_dicts = reconciliation_results['new_aml_dicts'] if reconciliation_results['open_balance_dict']: new_aml_dicts.append(reconciliation_results['open_balance_dict']) if not line.partner_id and partner: line.partner_id = partner counterpart_moves = line.process_reconciliation( counterpart_aml_dicts=reconciliation_results['counterpart_aml_dicts'], payment_aml_rec=reconciliation_results['payment_aml_rec'], new_aml_dicts=new_aml_dicts, ) results[line.id]['status'] = 'reconciled' results[line.id]['reconciled_lines'] = counterpart_moves.mapped('line_ids') # Break models loop. break return results
def _get_write_off_move_lines_dict(self, st_line, move_lines=None): ''' Get move.lines dict (to be passed to the create()) corresponding to the reconciliation model's write-off lines. :param st_line: An account.bank.statement.line record. :param move_lines: An account.move.line recordset. :return: A list of dict representing move.lines to be created corresponding to the write-off lines. ''' self.ensure_one() if self.rule_type == 'invoice_matching' and (not self.match_total_amount or (self.match_total_amount_param == 100)): return [] line_residual = st_line.currency_id and st_line.amount_currency or st_line.amount line_currency = st_line.currency_id or st_line.journal_id.currency_id or st_line.company_id.currency_id total_residual = move_lines and sum(aml.currency_id and aml.amount_residual_currency or aml.amount_residual for aml in move_lines) or 0.0 balance = total_residual - line_residual if not self.account_id or float_is_zero(balance, precision_rounding=line_currency.rounding): return [] if self.amount_type == 'percentage': line_balance = balance * (self.amount / 100.0) elif self.amount_type == "regex": match = re.search(self.amount_from_label_regex, st_line.name) if match: line_balance = copysign(float(re.sub(r'\D' + self.decimal_separator, '', match.group(1)).replace(self.decimal_separator, '.')) * (1 if balance > 0.0 else -1), balance) else: line_balance = 0 else: line_balance = self.amount * (1 if balance > 0.0 else -1) new_aml_dicts = [] # First write-off line. writeoff_line = { 'name': self.label or st_line.name, 'account_id': self.account_id.id, 'analytic_account_id': self.analytic_account_id.id, 'analytic_tag_ids': [(6, 0, self.analytic_tag_ids.ids)], 'debit': line_balance > 0 and line_balance or 0, 'credit': line_balance < 0 and -line_balance or 0, 'reconcile_model_id': self.id, } new_aml_dicts.append(writeoff_line) if self.tax_ids: writeoff_line['tax_ids'] = [(6, None, self.tax_ids.ids)] tax = self.tax_ids # Multiple taxes with force_tax_included results in wrong computation, so we # only allow to set the force_tax_included field if we have one tax selected if self.force_tax_included: tax = tax[0].with_context(force_price_include=True) new_aml_dicts += self._get_taxes_move_lines_dict(tax, writeoff_line) # Second write-off line. if self.has_second_line and self.second_account_id: remaining_balance = balance - sum(aml['debit'] - aml['credit'] for aml in new_aml_dicts) if self.second_amount_type == 'percentage': line_balance = remaining_balance * (self.second_amount / 100.0) elif self.second_amount_type == "regex": match = re.search(self.second_amount_from_label_regex, st_line.name) if match: line_balance = copysign(float(re.sub(r'\D' + self.decimal_separator, '', match.group(1)).replace(self.decimal_separator, '.')), remaining_balance) else: line_balance = 0 else: line_balance = self.second_amount * (1 if remaining_balance > 0.0 else -1) second_writeoff_line = { 'name': self.second_label or st_line.name, 'account_id': self.second_account_id.id, 'analytic_account_id': self.second_analytic_account_id.id, 'analytic_tag_ids': [(6, 0, self.second_analytic_tag_ids.ids)], 'debit': line_balance > 0 and line_balance or 0, 'credit': line_balance < 0 and -line_balance or 0, 'reconcile_model_id': self.id, } new_aml_dicts.append(second_writeoff_line) if self.second_tax_ids: second_writeoff_line['tax_ids'] = [(6, None, self.second_tax_ids.ids)] tax = self.second_tax_ids # Multiple taxes with force_tax_included results in wrong computation, so we # only allow to set the force_tax_included field if we have one tax selected if self.force_second_tax_included: tax = tax[0].with_context(force_price_include=True) new_aml_dicts += self._get_taxes_move_lines_dict(tax, second_writeoff_line) return new_aml_dicts
def test_timesheet_delivery(self): """ Test timesheet invoicing with 'invoice on delivery' timetracked products 1. Create SO and confirm it 2. log timesheet 3. create invoice 4. log other timesheet 5. create a second invoice 6. add new SO line (delivered service) """ # create SO and confirm it self.env['res.currency.rate'].search([]).unlink() sale_order = self.env['sale.order'].create({ 'partner_id': self.partner_customer_usd.id, 'partner_invoice_id': self.partner_customer_usd.id, 'partner_shipping_id': self.partner_customer_usd.id, 'pricelist_id': self.pricelist_usd.id, }) so_line_deliver_global_project = self.env['sale.order.line'].create({ 'name': self.product_delivery_timesheet2.name, 'product_id': self.product_delivery_timesheet2.id, 'product_uom_qty': 50, 'product_uom': self.product_delivery_timesheet2.uom_id.id, 'price_unit': self.product_delivery_timesheet2.list_price, 'order_id': sale_order.id, }) so_line_deliver_task_project = self.env['sale.order.line'].create({ 'name': self.product_delivery_timesheet3.name, 'product_id': self.product_delivery_timesheet3.id, 'product_uom_qty': 20, 'product_uom': self.product_delivery_timesheet3.uom_id.id, 'price_unit': self.product_delivery_timesheet3.list_price, 'order_id': sale_order.id, }) so_line_deliver_global_project.product_id_change() so_line_deliver_task_project.product_id_change() # confirm SO sale_order.action_confirm() task_serv1 = self.env['project.task'].search([ ('sale_line_id', '=', so_line_deliver_global_project.id) ]) task_serv2 = self.env['project.task'].search([ ('sale_line_id', '=', so_line_deliver_task_project.id) ]) project_serv2 = self.env['project.project'].search([ ('sale_line_id', '=', so_line_deliver_task_project.id) ]) self.assertEqual( task_serv1.project_id, self.project_global, "Sale Timesheet: task should be created in global project") self.assertTrue( task_serv1, "Sale Timesheet: on SO confirmation, a task should have been created in global project" ) self.assertTrue( task_serv2, "Sale Timesheet: on SO confirmation, a task should have been created in a new project" ) self.assertEqual( sale_order.invoice_status, 'no', 'Sale Timesheet: "invoice on delivery" should not need to be invoiced on so confirmation' ) self.assertEqual(sale_order.analytic_account_id, task_serv2.project_id.analytic_account_id, "SO should have create a project") self.assertEqual( sale_order.tasks_count, 2, "Two tasks (1 per SO line) should have been created on SO confirmation" ) self.assertEqual( len(sale_order.project_ids), 2, "One project should have been created by the SO, when confirmed + the one from SO line 1 'task in global project'" ) self.assertEqual( sale_order.analytic_account_id, project_serv2.analytic_account_id, "The created project should be linked to the analytic account of the SO" ) # let's log some timesheets timesheet1 = self.env['account.analytic.line'].create({ 'name': 'Test Line', 'project_id': task_serv1.project_id.id, # global project 'task_id': task_serv1.id, 'unit_amount': 10.5, 'employee_id': self.employee_manager.id, }) self.assertEqual( so_line_deliver_global_project.invoice_status, 'to invoice', 'Sale Timesheet: "invoice on delivery" timesheets should set the so line in "to invoice" status when logged' ) self.assertEqual( so_line_deliver_task_project.invoice_status, 'no', 'Sale Timesheet: so line invoice status should not change when no timesheet linked to the line' ) self.assertEqual( sale_order.invoice_status, 'to invoice', 'Sale Timesheet: "invoice on delivery" timesheets should set the so in "to invoice" status when logged' ) self.assertEqual( timesheet1.timesheet_invoice_type, 'billable_time', "Timesheets linked to SO line with delivered product shoulbe be billable time" ) self.assertFalse( timesheet1.timesheet_invoice_id, "The timesheet1 should not be linked to the invoice yet") # invoice SO invoice1 = sale_order._create_invoices() self.assertTrue( float_is_zero(invoice1.amount_total - so_line_deliver_global_project.price_unit * 10.5, precision_digits=2), 'Sale: invoice generation on timesheets product is wrong') self.assertEqual( timesheet1.timesheet_invoice_id, invoice1, "The timesheet1 should not be linked to the invoice 1, as we are in delivered quantity (even if invoice is in draft" ) with self.assertRaises( UserError ): # We can not modify timesheet linked to invoice (even draft ones) timesheet1.write({'unit_amount': 42}) # log some timesheets again timesheet2 = self.env['account.analytic.line'].create({ 'name': 'Test Line', 'project_id': task_serv1.project_id.id, # global project 'task_id': task_serv1.id, 'unit_amount': 39.5, 'employee_id': self.employee_user.id, }) self.assertEqual( so_line_deliver_global_project.invoice_status, 'to invoice', 'Sale Timesheet: "invoice on delivery" timesheets should set the so line in "to invoice" status when logged' ) self.assertEqual( so_line_deliver_task_project.invoice_status, 'no', 'Sale Timesheet: so line invoice status should not change when no timesheet linked to the line' ) self.assertEqual( sale_order.invoice_status, 'to invoice', 'Sale Timesheet: "invoice on delivery" timesheets should not modify the invoice_status of the so' ) self.assertEqual( timesheet2.timesheet_invoice_type, 'billable_time', "Timesheets linked to SO line with delivered product shoulbe be billable time" ) self.assertFalse( timesheet2.timesheet_invoice_id, "The timesheet2 should not be linked to the invoice yet") # create a second invoice invoice2 = sale_order._create_invoices()[0] self.assertEqual( len(sale_order.invoice_ids), 2, "A second invoice should have been created from the SO") self.assertEqual( so_line_deliver_global_project.invoice_status, 'invoiced', 'Sale Timesheet: "invoice on delivery" timesheets should set the so line in "to invoice" status when logged' ) self.assertEqual( sale_order.invoice_status, 'no', 'Sale Timesheet: "invoice on delivery" timesheets should be invoiced completely by now' ) self.assertEqual( timesheet2.timesheet_invoice_id, invoice2, "The timesheet2 should not be linked to the invoice 2") with self.assertRaises( UserError ): # We can not modify timesheet linked to invoice (even draft ones) timesheet2.write({'unit_amount': 42}) # add a line on SO so_line_deliver_only_project = self.env['sale.order.line'].create({ 'name': self.product_delivery_timesheet4.name, 'product_id': self.product_delivery_timesheet4.id, 'product_uom_qty': 5, 'product_uom': self.product_delivery_timesheet4.uom_id.id, 'price_unit': self.product_delivery_timesheet4.list_price, 'order_id': sale_order.id, }) self.assertEqual( len(sale_order.project_ids), 2, "No new project should have been created by the SO, when selling 'project only' product, since it reuse the one from 'new task in new project'." ) # let's log some timesheets on the project timesheet3 = self.env['account.analytic.line'].create({ 'name': 'Test Line', 'project_id': project_serv2.id, 'unit_amount': 7, 'employee_id': self.employee_user.id, }) self.assertTrue( float_is_zero(so_line_deliver_only_project.qty_delivered, precision_digits=2), "Timesheeting on project should not incremented the delivered quantity on the SO line" ) self.assertEqual( sale_order.invoice_status, 'no', 'Sale Timesheet: "invoice on delivery" timesheets should be invoiced completely by now' ) self.assertEqual( timesheet3.timesheet_invoice_type, 'non_billable_project', "Timesheets without task shoulbe be 'no project found'") self.assertFalse( timesheet3.timesheet_invoice_id, "The timesheet3 should not be linked to the invoice yet") # let's log some timesheets on the task (new task/new project) timesheet4 = self.env['account.analytic.line'].create({ 'name': 'Test Line 4', 'project_id': task_serv2.project_id.id, 'task_id': task_serv2.id, 'unit_amount': 7, 'employee_id': self.employee_user.id, }) self.assertFalse( timesheet4.timesheet_invoice_id, "The timesheet4 should not be linked to the invoice yet") # modify a non invoiced timesheet timesheet4.write({'unit_amount': 42}) self.assertFalse( timesheet4.timesheet_invoice_id, "The timesheet4 should not still be linked to the invoice") # validate the second invoice invoice2.post() self.assertEqual( timesheet1.timesheet_invoice_id, invoice1, "The timesheet1 should not be linked to the invoice 1, even after validation" ) self.assertEqual( timesheet2.timesheet_invoice_id, invoice2, "The timesheet2 should not be linked to the invoice 1, even after validation" ) self.assertFalse( timesheet3.timesheet_invoice_id, "The timesheet3 should not be linked to the invoice, since we are in ordered quantity" ) self.assertFalse( timesheet4.timesheet_invoice_id, "The timesheet4 should not be linked to the invoice, since we are in ordered quantity" )
def test_timesheet_manual(self): """ Test timesheet invoicing with 'invoice on delivery' timetracked products """ # create SO and confirm it sale_order = self.env['sale.order'].create({ 'partner_id': self.partner_customer_usd.id, 'partner_invoice_id': self.partner_customer_usd.id, 'partner_shipping_id': self.partner_customer_usd.id, 'pricelist_id': self.pricelist_usd.id, }) so_line_manual_global_project = self.env['sale.order.line'].create({ 'name': self.product_delivery_manual2.name, 'product_id': self.product_delivery_manual2.id, 'product_uom_qty': 50, 'product_uom': self.product_delivery_manual2.uom_id.id, 'price_unit': self.product_delivery_manual2.list_price, 'order_id': sale_order.id, }) so_line_manual_only_project = self.env['sale.order.line'].create({ 'name': self.product_delivery_manual4.name, 'product_id': self.product_delivery_manual4.id, 'product_uom_qty': 20, 'product_uom': self.product_delivery_manual4.uom_id.id, 'price_unit': self.product_delivery_manual4.list_price, 'order_id': sale_order.id, }) # confirm SO sale_order.action_confirm() self.assertTrue(sale_order.project_ids, "Sales Order should have create a project") self.assertEqual( sale_order.invoice_status, 'no', 'Sale Timesheet: manually product should not need to be invoiced on so confirmation' ) project_serv2 = so_line_manual_only_project.project_id self.assertTrue( project_serv2, "A second project is created when selling 'project only' after SO confirmation." ) self.assertEqual( sale_order.analytic_account_id, project_serv2.analytic_account_id, "The created project should be linked to the analytic account of the SO" ) # let's log some timesheets (on task and project) timesheet1 = self.env['account.analytic.line'].create({ 'name': 'Test Line', 'project_id': self.project_global.id, # global project 'task_id': so_line_manual_global_project.task_id.id, 'unit_amount': 6, 'employee_id': self.employee_manager.id, }) timesheet2 = self.env['account.analytic.line'].create({ 'name': 'Test Line', 'project_id': self.project_global.id, # global project 'unit_amount': 3, 'employee_id': self.employee_manager.id, }) self.assertEqual( len(sale_order.project_ids), 2, "One project should have been created by the SO, when confirmed + the one coming from SO line 1 'task in global project'." ) self.assertEqual( so_line_manual_global_project.task_id.sale_line_id, so_line_manual_global_project, "Task from a milestone product should be linked to its SO line too" ) self.assertEqual( timesheet1.timesheet_invoice_type, 'billable_fixed', "Milestone timesheet goes in billable fixed category") self.assertTrue( float_is_zero(so_line_manual_global_project.qty_delivered, precision_digits=2), "Milestone Timesheeting should not incremented the delivered quantity on the SO line" ) self.assertEqual( so_line_manual_global_project.qty_to_invoice, 0.0, "Manual service should not be affected by timesheet on their created task." ) self.assertEqual( so_line_manual_only_project.qty_to_invoice, 0.0, "Manual service should not be affected by timesheet on their created project." ) self.assertEqual( sale_order.invoice_status, 'no', 'Sale Timesheet: "invoice on delivery" should not need to be invoiced on so confirmation' ) self.assertEqual( timesheet1.timesheet_invoice_type, 'billable_fixed', "Timesheets linked to SO line with ordered product shoulbe be billable fixed since it is a milestone" ) self.assertEqual( timesheet2.timesheet_invoice_type, 'non_billable_project', "Timesheets without task shoulbe be 'no project found'") self.assertFalse(timesheet1.timesheet_invoice_id, "The timesheet1 should not be linked to the invoice") self.assertFalse(timesheet2.timesheet_invoice_id, "The timesheet2 should not be linked to the invoice") # invoice SO sale_order.order_line.write({'qty_delivered': 5}) invoice1 = sale_order._create_invoices() for invoice_line in invoice1.invoice_line_ids: self.assertEqual( invoice_line.quantity, 5, "The invoiced quantity should be 5, as manually set on SO lines" ) self.assertFalse( timesheet1.timesheet_invoice_id, "The timesheet1 should not be linked to the invoice, since timesheets are used for time tracking in milestone" ) self.assertFalse( timesheet2.timesheet_invoice_id, "The timesheet2 should not be linked to the invoice, since timesheets are used for time tracking in milestone" ) # validate the invoice invoice1.post() self.assertFalse( timesheet1.timesheet_invoice_id, "The timesheet1 should not be linked to the invoice, even after invoice validation" ) self.assertFalse( timesheet2.timesheet_invoice_id, "The timesheet2 should not be linked to the invoice, even after invoice validation" )
def _change_standard_price(self, new_price, counterpart_account_id=False): """Helper to create the stock valuation layers and the account moves after an update of standard price. :param new_price: new standard price """ # Handle stock valuation layers. svl_vals_list = [] company_id = self.env.company for product in self: if product.cost_method not in ('standard', 'average'): continue quantity_svl = product.sudo().quantity_svl if float_is_zero(quantity_svl, precision_rounding=product.uom_id.rounding): continue diff = new_price - product.standard_price value = company_id.currency_id.round(quantity_svl * diff) if company_id.currency_id.is_zero(value): continue svl_vals = { 'company_id': company_id.id, 'product_id': product.id, 'description': _('Product value manually modified (from %s to %s)') % (product.standard_price, new_price), 'value': value, 'quantity': 0, } svl_vals_list.append(svl_vals) stock_valuation_layers = self.env['stock.valuation.layer'].sudo( ).create(svl_vals_list) # Handle account moves. product_accounts = { product.id: product.product_tmpl_id.get_product_accounts() for product in self } am_vals_list = [] for stock_valuation_layer in stock_valuation_layers: product = stock_valuation_layer.product_id value = stock_valuation_layer.value if product.valuation != 'real_time': continue # Sanity check. if counterpart_account_id is False: raise UserError(_('You must set a counterpart account.')) if not product_accounts[product.id].get('stock_valuation'): raise UserError( _('You don\'t have any stock valuation account defined on your product category. You must define one before processing this operation.' )) if value < 0: debit_account_id = counterpart_account_id credit_account_id = product_accounts[ product.id]['stock_valuation'].id else: debit_account_id = product_accounts[ product.id]['stock_valuation'].id credit_account_id = counterpart_account_id move_vals = { 'journal_id': product_accounts[product.id]['stock_journal'].id, 'company_id': company_id.id, 'ref': product.default_code, 'stock_valuation_layer_ids': [(6, None, [stock_valuation_layer.id])], 'line_ids': [(0, 0, { 'name': _('%s changed cost from %s to %s - %s') % (self.env.user.name, product.standard_price, new_price, product.display_name), 'account_id': debit_account_id, 'debit': abs(value), 'credit': 0, 'product_id': product.id, }), (0, 0, { 'name': _('%s changed cost from %s to %s - %s') % (self.env.user.name, product.standard_price, new_price, product.display_name), 'account_id': credit_account_id, 'debit': 0, 'credit': abs(value), 'product_id': product.id, })], } am_vals_list.append(move_vals) account_moves = self.env['account.move'].create(am_vals_list) if account_moves: account_moves.post() # Actually update the standard price. self.with_context(force_company=company_id.id).sudo().write( {'standard_price': new_price})
def _run_fifo(self, quantity, company): self.ensure_one() # Find back incoming stock valuation layers (called candidates here) to value `quantity`. qty_to_take_on_candidates = quantity candidates = self.env['stock.valuation.layer'].sudo().with_context( active_test=False).search([ ('product_id', '=', self.id), ('remaining_qty', '>', 0), ('company_id', '=', company.id), ]) new_standard_price = 0 tmp_value = 0 # to accumulate the value taken on the candidates for candidate in candidates: qty_taken_on_candidate = min(qty_to_take_on_candidates, candidate.remaining_qty) candidate_unit_cost = candidate.remaining_value / candidate.remaining_qty new_standard_price = candidate_unit_cost value_taken_on_candidate = qty_taken_on_candidate * candidate_unit_cost value_taken_on_candidate = candidate.currency_id.round( value_taken_on_candidate) new_remaining_value = candidate.remaining_value - value_taken_on_candidate candidate_vals = { 'remaining_qty': candidate.remaining_qty - qty_taken_on_candidate, 'remaining_value': new_remaining_value, } candidate.write(candidate_vals) qty_to_take_on_candidates -= qty_taken_on_candidate tmp_value += value_taken_on_candidate if float_is_zero(qty_to_take_on_candidates, precision_rounding=self.uom_id.rounding): break # Update the standard price with the price of the last used candidate, if any. if new_standard_price and self.cost_method == 'fifo': self.sudo().with_context( force_company=company.id).standard_price = new_standard_price # If there's still quantity to value but we're out of candidates, we fall in the # negative stock use case. We chose to value the out move at the price of the # last out and a correction entry will be made once `_fifo_vacuum` is called. vals = {} if float_is_zero(qty_to_take_on_candidates, precision_rounding=self.uom_id.rounding): vals = { 'value': -tmp_value, 'unit_cost': tmp_value / quantity, } else: assert qty_to_take_on_candidates > 0 last_fifo_price = new_standard_price or self.standard_price negative_stock_value = last_fifo_price * -qty_to_take_on_candidates tmp_value += abs(negative_stock_value) vals = { 'remaining_qty': -qty_to_take_on_candidates, 'value': -tmp_value, 'unit_cost': last_fifo_price, } return vals