diff --git a/modules/purchase_trade/invoice.py b/modules/purchase_trade/invoice.py index ad7907b..05154fc 100644 --- a/modules/purchase_trade/invoice.py +++ b/modules/purchase_trade/invoice.py @@ -131,6 +131,45 @@ class Invoice(metaclass=PoolMeta): return lots[0].lot_unit_line return getattr(line, 'unit', None) + @staticmethod + def _get_report_lbs_unit(): + Uom = Pool().get('product.uom') + for domain in ( + [('symbol', '=', 'LBS')], + [('rec_name', '=', 'LBS')], + [('name', '=', 'LBS')], + [('symbol', '=', 'LB')], + [('rec_name', '=', 'LB')], + [('name', '=', 'LB')]): + units = Uom.search(domain, limit=1) + if units: + return units[0] + return None + + @classmethod + def _convert_report_quantity_to_lbs(cls, quantity, unit): + value = Decimal(str(quantity or 0)) + if value == 0: + return Decimal('0.00') + if not unit: + return (value * Decimal('2204.62')).quantize(Decimal('0.01')) + label = ( + getattr(unit, 'symbol', None) + or getattr(unit, 'rec_name', None) + or getattr(unit, 'name', None) + or '' + ).strip().upper() + if label in {'LBS', 'LB', 'POUND', 'POUNDS'}: + return value.quantize(Decimal('0.01')) + lbs_unit = cls._get_report_lbs_unit() + if lbs_unit: + converted = Pool().get('product.uom').compute_qty( + unit, float(value), lbs_unit) or 0 + return Decimal(str(converted)).quantize(Decimal('0.01')) + if label in {'KG', 'KGS', 'KILOGRAM', 'KILOGRAMS'}: + return (value * Decimal('2.20462')).quantize(Decimal('0.01')) + return (value * Decimal('2204.62')).quantize(Decimal('0.01')) + @staticmethod def _clean_report_description(value): text = (value or '').strip() @@ -517,7 +556,7 @@ class Invoice(metaclass=PoolMeta): quantity, keep_trailing_decimal=True) unit = self._get_report_invoice_line_unit(line) unit_name = unit.rec_name.upper() if unit and unit.rec_name else '' - lbs = round(Decimal(quantity) * Decimal('2204.62'), 2) + lbs = self._convert_report_quantity_to_lbs(quantity, unit) parts = [quantity_text, unit_name] if lbs != '': parts.append( @@ -746,7 +785,9 @@ class Invoice(metaclass=PoolMeta): net = self.report_net if net == '': return '' - return round(Decimal(net) * Decimal('2204.62'),2) + invoice_line = self._get_report_invoice_line() + unit = self._get_report_invoice_line_unit(invoice_line) if invoice_line else None + return self._convert_report_quantity_to_lbs(net, unit) @property def report_weight_unit_upper(self): @@ -996,7 +1037,8 @@ class InvoiceLine(metaclass=PoolMeta): net = self.report_net if net == '': return '' - return round(Decimal(net) * Decimal('2204.62'),2) + unit = Invoice._get_report_invoice_line_unit(self) + return Invoice._convert_report_quantity_to_lbs(net, unit) class ReportTemplateMixin: diff --git a/modules/purchase_trade/sale.py b/modules/purchase_trade/sale.py index 3efc9c6..085c898 100755 --- a/modules/purchase_trade/sale.py +++ b/modules/purchase_trade/sale.py @@ -473,32 +473,108 @@ class Sale(metaclass=PoolMeta): if lots and getattr(lots[0], 'lot_unit_line', None): return lots[0].lot_unit_line return getattr(line, 'unit', None) + + def _get_report_total_unit(self): + virtual_units = [] + for line in self._get_report_lines(): + for lot in self._get_report_line_lots(line): + if ( + getattr(lot, 'lot_type', None) == 'virtual' + and getattr(lot, 'lot_unit_line', None)): + virtual_units.append(lot.lot_unit_line) + if len(virtual_units) == 1: + return virtual_units[0] + line = self._get_report_first_line() + if line: + return self._get_report_line_unit(line) + return None + + @staticmethod + def _get_report_unit_wording(unit): + label = '' + for attr in ('symbol', 'rec_name', 'name'): + value = getattr(unit, attr, None) + if value: + label = str(value).strip().upper() + break + mapping = { + 'MT': ('METRIC TON', 'METRIC TONS'), + 'METRIC TON': ('METRIC TON', 'METRIC TONS'), + 'METRIC TONS': ('METRIC TON', 'METRIC TONS'), + 'KG': ('KILOGRAM', 'KILOGRAMS'), + 'KGS': ('KILOGRAM', 'KILOGRAMS'), + 'KILOGRAM': ('KILOGRAM', 'KILOGRAMS'), + 'KILOGRAMS': ('KILOGRAM', 'KILOGRAMS'), + 'LB': ('POUND', 'POUNDS'), + 'LBS': ('POUND', 'POUNDS'), + 'POUND': ('POUND', 'POUNDS'), + 'POUNDS': ('POUND', 'POUNDS'), + 'BALE': ('BALE', 'BALES'), + 'BALES': ('BALE', 'BALES'), + } + if label in mapping: + return mapping[label] + if label.endswith('S') and len(label) > 1: + return label[:-1], label + return label, label + + @classmethod + def _report_quantity_to_words(cls, quantity, unit): + singular, plural = cls._get_report_unit_wording(unit) + return quantity_to_words( + quantity, + unit_singular=singular, + unit_plural=plural, + ) + + @staticmethod + def _convert_report_quantity(quantity, from_unit, to_unit): + value = Decimal(str(quantity or 0)) + if not from_unit or not to_unit: + return value + if getattr(from_unit, 'id', None) == getattr(to_unit, 'id', None): + return value + from_name = getattr(from_unit, 'rec_name', None) + to_name = getattr(to_unit, 'rec_name', None) + if from_name and to_name and from_name == to_name: + return value + Uom = Pool().get('product.uom') + converted = Uom.compute_qty(from_unit, float(value), to_unit) or 0 + return Decimal(str(converted)) + + def _get_report_total_weight(self, index): + lines = self._get_report_lines() + if not lines: + return None + total_unit = self._get_report_total_unit() + total = Decimal(0) + for line in lines: + quantity = self._get_report_line_weights(line)[index] + total += self._convert_report_quantity( + quantity, + self._get_report_line_unit(line), + total_unit, + ) + return total @property def report_gross(self): - lines = self._get_report_lines() - if lines: - total = Decimal(0) - for line in lines: - total += self._get_report_line_weights(line)[1] + total = self._get_report_total_weight(1) + if total is not None: return total return '' @property def report_net(self): - lines = self._get_report_lines() - if lines: - total = Decimal(0) - for line in lines: - total += self._get_report_line_weights(line)[0] + total = self._get_report_total_weight(0) + if total is not None: return total return '' @property def report_total_quantity(self): - lines = self._get_report_lines() - if lines: - total = sum(self._get_report_line_weights(line)[0] for line in lines) + total = self._get_report_total_weight(0) + if total is not None: return self._format_report_number(total, keep_trailing_decimal=True) return '0.0' @@ -515,10 +591,10 @@ class Sale(metaclass=PoolMeta): @property def report_qt(self): - lines = self._get_report_lines() - if lines: - total = sum(self._get_report_line_quantity(line) for line in lines) - return quantity_to_words(total) + total = self._get_report_total_weight(0) + if total is not None: + return self._report_quantity_to_words( + total, self._get_report_total_unit()) return '' @property @@ -536,7 +612,7 @@ class Sale(metaclass=PoolMeta): line_unit.rec_name.upper() if line_unit and line_unit.rec_name else '' ) - words = quantity_to_words(current_quantity) + words = self._report_quantity_to_words(current_quantity, line_unit) period = line.del_period.description if getattr(line, 'del_period', None) else '' detail = ' '.join( part for part in [ @@ -623,8 +699,12 @@ class Sale(metaclass=PoolMeta): current_quantity = self._get_report_line_quantity(line) quantity = self._format_report_number( current_quantity, keep_trailing_decimal=True) - unit = line.unit.rec_name.upper() if line.unit and line.unit.rec_name else '' - words = quantity_to_words(current_quantity) + line_unit = self._get_report_line_unit(line) + unit = ( + line_unit.rec_name.upper() + if line_unit and line_unit.rec_name else '' + ) + words = self._report_quantity_to_words(current_quantity, line_unit) period = line.del_period.description if getattr(line, 'del_period', None) else '' quantity_line = ' '.join( part for part in [ diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py index 17ab19b..da080b8 100644 --- a/modules/purchase_trade/tests/test_module.py +++ b/modules/purchase_trade/tests/test_module.py @@ -789,6 +789,106 @@ class PurchaseTradeTestCase(ModuleTestCase): 'USC 70.2500 PER POUND (SEVENTY USC AND TWENTY FIVE CENTS) ON ICE Cotton #2 MAY 2026', ]) + def test_sale_report_converts_mixed_units_for_total_and_words(self): + 'sale report totals prefer the virtual lot unit as common unit' + Sale = Pool().get('sale.sale') + + mt = Mock(id=1, rec_name='MT') + kg = Mock(id=2, rec_name='KILOGRAM') + + line_mt = Mock() + line_mt.type = 'line' + line_mt.quantity = Decimal('1000') + line_mt.unit = mt + line_mt.del_period = Mock(description='MARCH 2026') + line_mt.lots = [] + + virtual = Mock(lot_type='virtual', lot_unit_line=kg) + virtual.get_hist_quantity.return_value = ( + Decimal('1000000'), + Decimal('1000000'), + ) + line_kg = Mock() + line_kg.type = 'line' + line_kg.quantity = Decimal('1000') + line_kg.unit = mt + line_kg.del_period = Mock(description='MAY 2026') + line_kg.lots = [virtual] + + sale = Sale() + sale.lines = [line_mt, line_kg] + + uom_model = Mock() + uom_model.compute_qty.side_effect = ( + lambda from_unit, qty, to_unit: ( + qty * 1000 + if getattr(from_unit, 'rec_name', None) == 'MT' + and getattr(to_unit, 'rec_name', None) == 'KILOGRAM' + else ( + qty / 1000 + if getattr(from_unit, 'rec_name', None) == 'KILOGRAM' + and getattr(to_unit, 'rec_name', None) == 'MT' + else qty + ) + )) + + with patch('trytond.modules.purchase_trade.sale.Pool') as PoolMock: + PoolMock.return_value.get.return_value = uom_model + + self.assertEqual(sale.report_total_quantity, '2000000.0') + self.assertEqual(sale.report_quantity_unit_upper, 'KILOGRAM') + self.assertEqual(sale.report_qt, 'TWO MILLION KILOGRAMS') + self.assertEqual( + sale.report_quantity_lines.splitlines(), + [ + '1000.0 MT (ONE THOUSAND METRIC TONS) - MARCH 2026', + '1000000.0 KILOGRAM (ONE MILLION KILOGRAMS) - MAY 2026', + ]) + + def test_sale_report_total_unit_falls_back_when_multiple_virtual_lots(self): + 'sale report common unit uses virtual only when there is a single one' + Sale = Pool().get('sale.sale') + + mt = Mock(id=1, rec_name='MT') + kg = Mock(id=2, rec_name='KILOGRAM') + + line_mt = Mock(type='line', quantity=Decimal('1000'), unit=mt) + line_mt.del_period = Mock(description='MARCH 2026') + line_mt.lots = [] + + virtual_a = Mock(lot_type='virtual', lot_unit_line=kg) + virtual_a.get_hist_quantity.return_value = ( + Decimal('1000000'), + Decimal('1000000'), + ) + virtual_b = Mock(lot_type='virtual', lot_unit_line=kg) + virtual_b.get_hist_quantity.return_value = ( + Decimal('1000000'), + Decimal('1000000'), + ) + line_kg = Mock(type='line', quantity=Decimal('1000'), unit=mt) + line_kg.del_period = Mock(description='MAY 2026') + line_kg.lots = [virtual_a, virtual_b] + + sale = Sale() + sale.lines = [line_mt, line_kg] + + uom_model = Mock() + uom_model.compute_qty.side_effect = ( + lambda from_unit, qty, to_unit: ( + qty / 1000 + if getattr(from_unit, 'rec_name', None) == 'KILOGRAM' + and getattr(to_unit, 'rec_name', None) == 'MT' + else qty + )) + + with patch('trytond.modules.purchase_trade.sale.Pool') as PoolMock: + PoolMock.return_value.get.return_value = uom_model + + self.assertEqual(sale.report_quantity_unit_upper, 'MT') + self.assertEqual(sale.report_total_quantity, '2000.0') + self.assertEqual(sale.report_qt, 'TWO THOUSAND METRIC TONS') + def test_report_product_fields_expose_name_and_description(self): 'sale and invoice templates use stable product name/description helpers' Sale = Pool().get('sale.sale') @@ -1015,7 +1115,7 @@ class PurchaseTradeTestCase(ModuleTestCase): 'invoice net and gross weights come from the current lot hist entry' Invoice = Pool().get('account.invoice') - unit = Mock(rec_name='LBS') + unit = Mock(rec_name='LBS', symbol='LBS') lot = Mock(lot_unit_line=unit) lot.get_hist_quantity.return_value = ( Decimal('950'), @@ -1030,7 +1130,36 @@ class PurchaseTradeTestCase(ModuleTestCase): self.assertEqual(invoice.report_weight_unit_upper, 'LBS') self.assertEqual( invoice.report_quantity_lines, - '950.0 LBS (2094389.00 LBS)') + '950.0 LBS (950.00 LBS)') + + def test_invoice_report_lbs_converts_kilogram_to_lbs(self): + 'invoice lbs helper converts kilogram quantities with the proper uom ratio' + Invoice = Pool().get('account.invoice') + + kg = Mock(id=1, rec_name='KILOGRAM', symbol='KG') + lbs = Mock(id=2, rec_name='LBS', symbol='LBS') + lot = Mock(lot_unit_line=kg) + lot.get_hist_quantity.return_value = ( + Decimal('999995'), + Decimal('999995'), + ) + line = Mock(type='line', quantity=Decimal('999995'), lot=lot, unit=kg) + invoice = Invoice() + invoice.lines = [line] + + uom_model = Mock() + uom_model.search.return_value = [lbs] + uom_model.compute_qty.side_effect = ( + lambda from_unit, qty, to_unit: qty * 2.20462 + ) + + with patch('trytond.modules.purchase_trade.invoice.Pool') as PoolMock: + PoolMock.return_value.get.return_value = uom_model + + self.assertEqual(invoice.report_lbs, Decimal('2204608.98')) + self.assertEqual( + invoice.report_quantity_lines, + '999995.0 KILOGRAM (2204608.98 LBS)') def test_invoice_report_weights_keep_line_sign_with_lot_hist_values(self): 'invoice lot hist values keep the invoice line sign for final notes'