diff --git a/modules/purchase_trade/docs/business/lots-and-quantities.en.md b/modules/purchase_trade/docs/business/lots-and-quantities.en.md index 2230d0f..9785d12 100644 --- a/modules/purchase_trade/docs/business/lots-and-quantities.en.md +++ b/modules/purchase_trade/docs/business/lots-and-quantities.en.md @@ -223,6 +223,14 @@ Invariants to preserve after every update: - `sum(physical lots) + virtual lot current quantity = quantity_theorical`; - `sum(lot.qt.lot_quantity where lot_p = virtual lot) = virtual lot current quantity`. +- For a purchase virtual lot, the second invariant sums all `lot.qt` lines + where `lot_p = virtual lot`, whether they are matched to a `lot_s` or not. +- For a sale virtual lot, the second invariant sums all `lot.qt` lines where + `lot_s = virtual lot`, whether they are linked to a `lot_p` or not. +- The technical guard is centralized in + `lot.lot.assert_lines_quantity_consistency()` and must be called at the end + of flows that modify lots, matching, transport, weighing, or the contractual + quantity. Purchase-specific point: diff --git a/modules/purchase_trade/docs/business/lots-and-quantities.md b/modules/purchase_trade/docs/business/lots-and-quantities.md index 6270099..e72dd12 100644 --- a/modules/purchase_trade/docs/business/lots-and-quantities.md +++ b/modules/purchase_trade/docs/business/lots-and-quantities.md @@ -228,6 +228,15 @@ Invariants à préserver après chaque modification : - `sum(physical lots) + virtual lot current quantity = quantity_theorical` ; - `sum(lot.qt.lot_quantity where lot_p = virtual lot) = virtual lot current quantity`. +- Pour un lot virtuel achat, le second invariant somme toutes les lignes + `lot.qt` où `lot_p = virtual lot`, qu'elles soient matchées à un `lot_s` ou + non. +- Pour un lot virtuel vente, le second invariant somme toutes les lignes + `lot.qt` où `lot_s = virtual lot`, qu'elles soient liées à un `lot_p` ou non. +- Le garde-fou technique est centralisé dans + `lot.lot.assert_lines_quantity_consistency()` et doit être appelé à la fin + des flux qui modifient des lots, du matching, du transport, du weighing ou la + quantité contractuelle. Point spécifique achat : diff --git a/modules/purchase_trade/docs/business/sql/README.md b/modules/purchase_trade/docs/business/sql/README.md new file mode 100644 index 0000000..25b5631 --- /dev/null +++ b/modules/purchase_trade/docs/business/sql/README.md @@ -0,0 +1,30 @@ +# SQL diagnostics for purchase_trade business rules + +These scripts are read-only diagnostics for a PostgreSQL test database. + +## quantity_consistency_checks.sql + +Checks the two core lot quantity invariants documented in +`lots-and-quantities.md` and `lots-and-quantities.en.md`. + +Run it on a restored test database: + +```sql +\i modules/purchase_trade/docs/business/sql/quantity_consistency_checks.sql +``` + +The script returns rows only when it finds a potential issue. + +Main columns: + +- `check_name`: invariant or diagnostic that failed. +- `line_id`: `purchase.line` or `sale.line` id depending on the check. +- `virtual_lot_id`: virtual lot involved in the inconsistency. +- `observed_value`: value found in the database. +- `expected_value`: value required by the business rule. +- `diff`: observed minus expected. +- `detail`: human-readable explanation. + +Cross-category UoM rows are reported as manual-review diagnostics because the +Python code may pass explicit conversion factors that cannot be inferred safely +from SQL alone. diff --git a/modules/purchase_trade/docs/business/sql/quantity_consistency_checks.sql b/modules/purchase_trade/docs/business/sql/quantity_consistency_checks.sql new file mode 100644 index 0000000..54c6cd4 --- /dev/null +++ b/modules/purchase_trade/docs/business/sql/quantity_consistency_checks.sql @@ -0,0 +1,391 @@ +/* +Purchase Trade quantity consistency diagnostics. + +Read-only script. It returns rows only when a consistency issue is detected. + +Rules checked: +1. For each trade line with one virtual lot: + sum(physical lots current quantity) + virtual lot current quantity + = line quantity_theorical. + +2. For each virtual purchase lot: + sum(lot_qt.lot_quantity where lot_p = virtual lot) + = virtual lot current quantity. + Matched and unmatched lot_qt rows are both included. + +3. For each virtual sale lot: + sum(lot_qt.lot_quantity where lot_s = virtual lot) + = virtual lot current quantity. + Rows linked to a purchase lot and rows without purchase lot are both + included. + +Notes: +- The script uses the lot current state, i.e. lot_lot.lot_state joined to + lot_qt_hist.quantity_type. +- Quantities are converted with product_uom.factor when source and target + units are in the same category. +- Cross-category conversions are reported separately because the Python code + sometimes passes an explicit factor/rate and SQL cannot infer that business + context safely. +*/ + +WITH +params AS ( + SELECT 0.00001::numeric AS epsilon +), +uom AS ( + SELECT id, name, symbol, category, factor::numeric AS factor + FROM product_uom +), +lot_current AS ( + SELECT + lot.id AS lot_id, + lot.line AS purchase_line, + lot.sale_line AS sale_line, + lot.lot_type, + lot.lot_state, + lot.lot_unit_line, + hist.quantity_type, + COALESCE(hist.quantity, 0)::numeric AS current_quantity + FROM lot_lot lot + LEFT JOIN lot_qt_hist hist + ON hist.lot = lot.id + AND hist.quantity_type = lot.lot_state +), +purchase_line_base AS ( + SELECT + pl.id AS line_id, + pl.quantity_theorical::numeric AS theoretical_quantity, + pl.quantity::numeric AS line_quantity, + pl.unit AS line_unit, + COUNT(vlot.id) AS virtual_count, + MIN(vlot.id) AS virtual_lot_id + FROM purchase_line pl + LEFT JOIN lot_lot vlot + ON vlot.line = pl.id + AND vlot.lot_type = 'virtual' + WHERE pl.quantity_theorical IS NOT NULL + GROUP BY pl.id, pl.quantity_theorical, pl.quantity, pl.unit +), +sale_line_base AS ( + SELECT + sl.id AS line_id, + sl.quantity_theorical::numeric AS theoretical_quantity, + sl.quantity::numeric AS line_quantity, + sl.unit AS line_unit, + COUNT(vlot.id) AS virtual_count, + MIN(vlot.id) AS virtual_lot_id + FROM sale_line sl + LEFT JOIN lot_lot vlot + ON vlot.sale_line = sl.id + AND vlot.lot_type = 'virtual' + WHERE sl.quantity_theorical IS NOT NULL + GROUP BY sl.id, sl.quantity_theorical, sl.quantity, sl.unit +), +purchase_line_quantities AS ( + SELECT + base.line_id, + base.theoretical_quantity, + base.line_quantity, + base.line_unit, + base.virtual_count, + base.virtual_lot_id, + COALESCE(SUM( + CASE + WHEN phys_uom.category = line_uom.category + THEN phys.current_quantity * phys_uom.factor / line_uom.factor + ELSE NULL + END), 0)::numeric AS physical_quantity, + MAX( + CASE + WHEN vlot_uom.category = line_uom.category + THEN vlot.current_quantity * vlot_uom.factor / line_uom.factor + ELSE NULL + END)::numeric AS virtual_quantity, + COUNT(*) FILTER ( + WHERE phys.lot_id IS NOT NULL + AND phys_uom.category IS DISTINCT FROM line_uom.category + ) AS physical_cross_category_count, + CASE + WHEN vlot.lot_id IS NOT NULL + AND vlot_uom.category IS DISTINCT FROM line_uom.category + THEN 1 ELSE 0 + END AS virtual_cross_category_count + FROM purchase_line_base base + LEFT JOIN uom line_uom ON line_uom.id = base.line_unit + LEFT JOIN lot_current vlot ON vlot.lot_id = base.virtual_lot_id + LEFT JOIN uom vlot_uom ON vlot_uom.id = vlot.lot_unit_line + LEFT JOIN lot_current phys + ON phys.purchase_line = base.line_id + AND phys.lot_type = 'physic' + LEFT JOIN uom phys_uom ON phys_uom.id = phys.lot_unit_line + GROUP BY + base.line_id, base.theoretical_quantity, base.line_quantity, + base.line_unit, base.virtual_count, base.virtual_lot_id, + vlot.lot_id, vlot.current_quantity, vlot_uom.category, + vlot_uom.factor, line_uom.category, line_uom.factor +), +sale_line_quantities AS ( + SELECT + base.line_id, + base.theoretical_quantity, + base.line_quantity, + base.line_unit, + base.virtual_count, + base.virtual_lot_id, + COALESCE(SUM( + CASE + WHEN phys_uom.category = line_uom.category + THEN phys.current_quantity * phys_uom.factor / line_uom.factor + ELSE NULL + END), 0)::numeric AS physical_quantity, + MAX( + CASE + WHEN vlot_uom.category = line_uom.category + THEN vlot.current_quantity * vlot_uom.factor / line_uom.factor + ELSE NULL + END)::numeric AS virtual_quantity, + COUNT(*) FILTER ( + WHERE phys.lot_id IS NOT NULL + AND phys_uom.category IS DISTINCT FROM line_uom.category + ) AS physical_cross_category_count, + CASE + WHEN vlot.lot_id IS NOT NULL + AND vlot_uom.category IS DISTINCT FROM line_uom.category + THEN 1 ELSE 0 + END AS virtual_cross_category_count + FROM sale_line_base base + LEFT JOIN uom line_uom ON line_uom.id = base.line_unit + LEFT JOIN lot_current vlot ON vlot.lot_id = base.virtual_lot_id + LEFT JOIN uom vlot_uom ON vlot_uom.id = vlot.lot_unit_line + LEFT JOIN lot_current phys + ON phys.sale_line = base.line_id + AND phys.lot_type = 'physic' + LEFT JOIN uom phys_uom ON phys_uom.id = phys.lot_unit_line + GROUP BY + base.line_id, base.theoretical_quantity, base.line_quantity, + base.line_unit, base.virtual_count, base.virtual_lot_id, + vlot.lot_id, vlot.current_quantity, vlot_uom.category, + vlot_uom.factor, line_uom.category, line_uom.factor +), +purchase_lot_qt_balance AS ( + SELECT + vlot.id AS virtual_lot_id, + vlot.line AS line_id, + COALESCE(SUM( + CASE + WHEN lqt_uom.category = line_uom.category + THEN COALESCE(lqt.lot_quantity, 0)::numeric + * lqt_uom.factor / line_uom.factor + ELSE NULL + END), 0)::numeric AS lot_qt_quantity, + MAX( + CASE + WHEN vlot_uom.category = line_uom.category + THEN current.current_quantity * vlot_uom.factor / line_uom.factor + ELSE NULL + END)::numeric AS virtual_quantity, + COUNT(*) FILTER ( + WHERE lqt.id IS NOT NULL + AND lqt_uom.category IS DISTINCT FROM line_uom.category + ) AS lot_qt_cross_category_count + FROM lot_lot vlot + JOIN purchase_line pl ON pl.id = vlot.line + JOIN uom line_uom ON line_uom.id = pl.unit + LEFT JOIN lot_current current ON current.lot_id = vlot.id + LEFT JOIN uom vlot_uom ON vlot_uom.id = current.lot_unit_line + LEFT JOIN lot_qt lqt ON lqt.lot_p = vlot.id + LEFT JOIN uom lqt_uom ON lqt_uom.id = lqt.lot_unit + WHERE vlot.lot_type = 'virtual' + GROUP BY vlot.id, vlot.line +), +sale_lot_qt_balance AS ( + SELECT + vlot.id AS virtual_lot_id, + vlot.sale_line AS line_id, + COALESCE(SUM( + CASE + WHEN lqt_uom.category = line_uom.category + THEN COALESCE(lqt.lot_quantity, 0)::numeric + * lqt_uom.factor / line_uom.factor + ELSE NULL + END), 0)::numeric AS lot_qt_quantity, + MAX( + CASE + WHEN vlot_uom.category = line_uom.category + THEN current.current_quantity * vlot_uom.factor / line_uom.factor + ELSE NULL + END)::numeric AS virtual_quantity, + COUNT(*) FILTER ( + WHERE lqt.id IS NOT NULL + AND lqt_uom.category IS DISTINCT FROM line_uom.category + ) AS lot_qt_cross_category_count + FROM lot_lot vlot + JOIN sale_line sl ON sl.id = vlot.sale_line + JOIN uom line_uom ON line_uom.id = sl.unit + LEFT JOIN lot_current current ON current.lot_id = vlot.id + LEFT JOIN uom vlot_uom ON vlot_uom.id = current.lot_unit_line + LEFT JOIN lot_qt lqt ON lqt.lot_s = vlot.id + LEFT JOIN uom lqt_uom ON lqt_uom.id = lqt.lot_unit + WHERE vlot.lot_type = 'virtual' + GROUP BY vlot.id, vlot.sale_line +) +SELECT * +FROM ( + SELECT + 'purchase_line_virtual_count' AS check_name, + line_id, + virtual_lot_id, + virtual_count::numeric AS observed_value, + 1::numeric AS expected_value, + (virtual_count - 1)::numeric AS diff, + 'purchase.line must have exactly one virtual lot once initialized' + AS detail + FROM purchase_line_base + WHERE virtual_count <> 1 + + UNION ALL + + SELECT + 'sale_line_virtual_count' AS check_name, + line_id, + virtual_lot_id, + virtual_count::numeric AS observed_value, + 1::numeric AS expected_value, + (virtual_count - 1)::numeric AS diff, + 'sale.line must have exactly one virtual lot once initialized' + AS detail + FROM sale_line_base + WHERE virtual_count <> 1 + + UNION ALL + + SELECT + 'purchase_line_physical_plus_virtual' AS check_name, + q.line_id, + q.virtual_lot_id, + ROUND(q.physical_quantity + COALESCE(q.virtual_quantity, 0), 5) + AS observed_value, + ROUND(q.theoretical_quantity, 5) AS expected_value, + ROUND( + q.physical_quantity + COALESCE(q.virtual_quantity, 0) + - q.theoretical_quantity, 5) AS diff, + 'physical lots + virtual lot must equal purchase quantity_theorical' + AS detail + FROM purchase_line_quantities q, params p + WHERE q.virtual_count = 1 + AND q.physical_cross_category_count = 0 + AND q.virtual_cross_category_count = 0 + AND ABS( + q.physical_quantity + COALESCE(q.virtual_quantity, 0) + - q.theoretical_quantity) > p.epsilon + + UNION ALL + + SELECT + 'sale_line_physical_plus_virtual' AS check_name, + q.line_id, + q.virtual_lot_id, + ROUND(q.physical_quantity + COALESCE(q.virtual_quantity, 0), 5) + AS observed_value, + ROUND(q.theoretical_quantity, 5) AS expected_value, + ROUND( + q.physical_quantity + COALESCE(q.virtual_quantity, 0) + - q.theoretical_quantity, 5) AS diff, + 'physical lots + virtual lot must equal sale quantity_theorical' + AS detail + FROM sale_line_quantities q, params p + WHERE q.virtual_count = 1 + AND q.physical_cross_category_count = 0 + AND q.virtual_cross_category_count = 0 + AND ABS( + q.physical_quantity + COALESCE(q.virtual_quantity, 0) + - q.theoretical_quantity) > p.epsilon + + UNION ALL + + SELECT + 'purchase_lot_qt_equals_virtual' AS check_name, + b.line_id, + b.virtual_lot_id, + ROUND(b.lot_qt_quantity, 5) AS observed_value, + ROUND(COALESCE(b.virtual_quantity, 0), 5) AS expected_value, + ROUND(b.lot_qt_quantity - COALESCE(b.virtual_quantity, 0), 5) AS diff, + 'sum lot_qt where lot_p = virtual lot must equal virtual lot quantity' + AS detail + FROM purchase_lot_qt_balance b, params p + WHERE b.lot_qt_cross_category_count = 0 + AND ABS(b.lot_qt_quantity - COALESCE(b.virtual_quantity, 0)) > p.epsilon + + UNION ALL + + SELECT + 'sale_lot_qt_equals_virtual' AS check_name, + b.line_id, + b.virtual_lot_id, + ROUND(b.lot_qt_quantity, 5) AS observed_value, + ROUND(COALESCE(b.virtual_quantity, 0), 5) AS expected_value, + ROUND(b.lot_qt_quantity - COALESCE(b.virtual_quantity, 0), 5) AS diff, + 'sum lot_qt where lot_s = virtual lot must equal virtual lot quantity' + AS detail + FROM sale_lot_qt_balance b, params p + WHERE b.lot_qt_cross_category_count = 0 + AND ABS(b.lot_qt_quantity - COALESCE(b.virtual_quantity, 0)) > p.epsilon + + UNION ALL + + SELECT + 'purchase_cross_category_manual_review' AS check_name, + q.line_id, + q.virtual_lot_id, + (q.physical_cross_category_count + q.virtual_cross_category_count)::numeric, + 0::numeric, + (q.physical_cross_category_count + q.virtual_cross_category_count)::numeric, + 'line has lot quantities in another UoM category; review with Python conversion context' + AS detail + FROM purchase_line_quantities q + WHERE q.physical_cross_category_count + q.virtual_cross_category_count > 0 + + UNION ALL + + SELECT + 'sale_cross_category_manual_review' AS check_name, + q.line_id, + q.virtual_lot_id, + (q.physical_cross_category_count + q.virtual_cross_category_count)::numeric, + 0::numeric, + (q.physical_cross_category_count + q.virtual_cross_category_count)::numeric, + 'line has lot quantities in another UoM category; review with Python conversion context' + AS detail + FROM sale_line_quantities q + WHERE q.physical_cross_category_count + q.virtual_cross_category_count > 0 + + UNION ALL + + SELECT + 'lot_qt_cross_category_manual_review' AS check_name, + b.line_id, + b.virtual_lot_id, + b.lot_qt_cross_category_count::numeric, + 0::numeric, + b.lot_qt_cross_category_count::numeric, + 'lot_qt contains quantities in another UoM category; review with Python conversion context' + AS detail + FROM purchase_lot_qt_balance b + WHERE b.lot_qt_cross_category_count > 0 + + UNION ALL + + SELECT + 'sale_lot_qt_cross_category_manual_review' AS check_name, + b.line_id, + b.virtual_lot_id, + b.lot_qt_cross_category_count::numeric, + 0::numeric, + b.lot_qt_cross_category_count::numeric, + 'lot_qt contains quantities in another UoM category; review with Python conversion context' + AS detail + FROM sale_lot_qt_balance b + WHERE b.lot_qt_cross_category_count > 0 +) issues +ORDER BY check_name, line_id, virtual_lot_id; diff --git a/modules/purchase_trade/lot.py b/modules/purchase_trade/lot.py index 83d1af8..009f64b 100755 --- a/modules/purchase_trade/lot.py +++ b/modules/purchase_trade/lot.py @@ -46,8 +46,166 @@ class LotMove(ModelSQL,ModelView): move = fields.Many2One('stock.move', "Move", ondelete='CASCADE') sequence = fields.Integer("Sequence") -class Lot(metaclass=PoolMeta): - __name__ = 'lot.lot' +class Lot(metaclass=PoolMeta): + __name__ = 'lot.lot' + + _quantity_consistency_epsilon = Decimal("0.00001") + + @classmethod + def _round_consistency_quantity(cls, quantity): + return Decimal(str(quantity or 0)).quantize( + cls._quantity_consistency_epsilon, + rounding=ROUND_HALF_UP) + + @staticmethod + def _same_record(left, right): + return ( + left == right + or getattr(left, 'id', left) == getattr(right, 'id', right)) + + @classmethod + def _convert_consistency_quantity(cls, quantity, from_unit, to_unit): + if not from_unit or not to_unit or cls._same_record(from_unit, to_unit): + return Decimal(str(quantity or 0)) + Uom = Pool().get('product.uom') + factor = None + rate = None + if from_unit.category.id != to_unit.category.id: + factor = 1 + rate = 1 + return Decimal(Uom.compute_qty( + from_unit, float(quantity or 0), to_unit, True, factor, rate)) + + @classmethod + def _get_line_lots_for_consistency(cls, line): + lots = list(getattr(line, 'lots', None) or []) + virtual_lots = [ + lot for lot in lots + if getattr(lot, 'lot_type', None) == 'virtual'] + physical_lots = [ + lot for lot in lots + if getattr(lot, 'lot_type', None) == 'physic'] + return virtual_lots, physical_lots + + @classmethod + def _get_lot_current_quantity_for_consistency(cls, lot, unit): + quantity = lot.get_current_quantity_converted(unit=unit) + return Decimal(str(quantity or 0)) + + @classmethod + def _get_lot_qt_quantity_for_consistency(cls, lqt, unit): + quantity = Decimal(str(getattr(lqt, 'lot_quantity', 0) or 0)) + return cls._convert_consistency_quantity( + quantity, getattr(lqt, 'lot_unit', None), unit) + + @classmethod + def _format_consistency_error(cls, line, message, values): + line_name = ( + getattr(line, 'rec_name', None) + or getattr(line, 'id', None) + or line) + detail = ", ".join( + "%s=%s" % (key, cls._round_consistency_quantity(value)) + for key, value in values) + return "Quantity consistency error on line %s: %s (%s)" % ( + line_name, message, detail) + + @classmethod + def _assert_line_quantity_consistency(cls, line): + if not line: + return + product = getattr(line, 'product', None) + if getattr(product, 'type', None) == 'service': + return + theorical = getattr(line, 'quantity_theorical', None) + if theorical is None: + return + + unit = getattr(line, 'unit', None) + virtual_lots, physical_lots = cls._get_line_lots_for_consistency(line) + if not virtual_lots: + return + if len(virtual_lots) > 1: + raise UserError( + cls._format_consistency_error( + line, + "more than one virtual lot exists", + [('virtual_lots', len(virtual_lots))])) + + vlot = virtual_lots[0] + physical_quantity = sum( + cls._get_lot_current_quantity_for_consistency(lot, unit) + for lot in physical_lots) + virtual_quantity = cls._get_lot_current_quantity_for_consistency( + vlot, unit) + theorical_quantity = Decimal(str(theorical or 0)) + expected_diff = ( + cls._round_consistency_quantity(physical_quantity) + + cls._round_consistency_quantity(virtual_quantity) + - cls._round_consistency_quantity(theorical_quantity)) + if expected_diff: + raise UserError( + cls._format_consistency_error( + line, + "physical lots + virtual lot must equal theoretical " + "quantity", + [ + ('physical', physical_quantity), + ('virtual', virtual_quantity), + ('theoretical', theorical_quantity), + ('diff', expected_diff), + ])) + + cls._assert_virtual_lot_open_quantity_consistency(vlot, line) + + @classmethod + def _assert_virtual_lot_open_quantity_consistency(cls, vlot, line=None): + LotQt = Pool().get('lot.qt') + line = line or getattr(vlot, 'line', None) or getattr(vlot, 'sale_line', None) + if not line: + return + unit = getattr(line, 'unit', None) + if getattr(vlot, 'line', None): + lotqts = LotQt.search([('lot_p', '=', vlot.id)]) + elif getattr(vlot, 'sale_line', None): + lotqts = LotQt.search([('lot_s', '=', vlot.id)]) + else: + return + + open_quantity = sum( + cls._get_lot_qt_quantity_for_consistency(lqt, unit) + for lqt in lotqts) + virtual_quantity = cls._get_lot_current_quantity_for_consistency( + vlot, unit) + diff = ( + cls._round_consistency_quantity(open_quantity) + - cls._round_consistency_quantity(virtual_quantity)) + if diff: + raise UserError( + cls._format_consistency_error( + line, + "lot.qt forecast quantity must equal virtual lot " + "current quantity", + [ + ('lot_qt', open_quantity), + ('virtual', virtual_quantity), + ('diff', diff), + ])) + + @classmethod + def assert_lines_quantity_consistency(cls, lines): + for line in lines: + cls._assert_line_quantity_consistency(line) + + @classmethod + def assert_lots_quantity_consistency(cls, lots): + lines = [] + for lot in lots: + if getattr(lot, 'line', None): + lines.append(lot.line) + if getattr(lot, 'sale_line', None): + lines.append(lot.sale_line) + cls.assert_lines_quantity_consistency(lines) line = fields.Many2One('purchase.line',"Purchase",ondelete='CASCADE') move = fields.Function(fields.Many2One('stock.move',"Move"),'get_current_move') @@ -682,21 +840,24 @@ class Lot(metaclass=PoolMeta): LotHist.save([lh]) @classmethod - def validate(cls, lots): - super(Lot, cls).validate(lots) - LotQt = Pool().get('lot.qt') - StockMove = Pool().get('stock.move') - for lot in lots: - if lot.lot_type == 'virtual': - if lot.line: - lqt = LotQt.search([('lot_p','=',lot.id)]) - if len(lqt)==0 and not lot.line.created_by_code: - lot.createVirtualPart(lot.lot_quantity,None,None) - - if lot.sale_line: - lqt = LotQt.search([('lot_s','=',lot.id)]) - if len(lqt)==0 and not lot.sale_line.created_by_code: - lot.createVirtualPart(lot.lot_quantity,None,lot.id,'only sale') + def validate(cls, lots): + super(Lot, cls).validate(lots) + LotQt = Pool().get('lot.qt') + StockMove = Pool().get('stock.move') + virtual_lots_to_check = [] + for lot in lots: + if lot.lot_type == 'virtual': + if lot.line: + lqt = LotQt.search([('lot_p','=',lot.id)]) + if len(lqt)==0 and not lot.line.created_by_code: + lot.createVirtualPart(lot.lot_quantity,None,None) + virtual_lots_to_check.append(lot) + + if lot.sale_line: + lqt = LotQt.search([('lot_s','=',lot.id)]) + if len(lqt)==0 and not lot.sale_line.created_by_code: + lot.createVirtualPart(lot.lot_quantity,None,lot.id,'only sale') + virtual_lots_to_check.append(lot) #Recalculate total line quantity if lot.lot_type == 'physic': @@ -722,9 +883,9 @@ class Lot(metaclass=PoolMeta): if not lot.IsDelivered() and not lot.get_current_customer_move(): from_ = lot.sale_line.sale.from_location to_ = lot.sale_line.sale.to_location - if from_ and to_ and not (from_.type=='supplier' and to_.type=='customer'): - Move = Pool().get('stock.move') - nm = Move() + if from_ and to_ and not (from_.type=='supplier' and to_.type=='customer'): + Move = Pool().get('stock.move') + nm = Move() nm.from_location = from_ nm.to_location = to_ nm.product = lot.lot_product @@ -734,22 +895,23 @@ class Lot(metaclass=PoolMeta): nm.lot = lot nm.shipment = None nm.currency = lot.sale_line.currency - nm.unit_price = lot.get_lot_sale_price() - Move.save([nm]) - - #check that lot with shipment are on this shipment fees - if lot.lot_shipment_in: - if lot.lot_shipment_in.fees: + nm.unit_price = lot.get_lot_sale_price() + Move.save([nm]) + #check that lot with shipment are on this shipment fees + if lot.lot_shipment_in: + if lot.lot_shipment_in.fees: for f in lot.lot_shipment_in.fees: FeeLots = Pool().get('fee.lots') fl = FeeLots.search(['fee','=',f.id]) if not fl: fl = FeeLots() fl.fee = f.id - fl.lot = lot.id - FeeLots.save([fl]) - - def createVirtualPart(self,qt=0,sh=None,lot_s=None,mode='both'): + fl.lot = lot.id + FeeLots.save([fl]) + + cls.assert_lots_quantity_consistency(virtual_lots_to_check) + + def createVirtualPart(self,qt=0,sh=None,lot_s=None,mode='both'): lqt = LotQt() if self.lot_type == 'physic': lqt.lot_p = self.getVlot_p() @@ -897,14 +1059,19 @@ class Lot(metaclass=PoolMeta): super(Lot, cls).delete(lots) - for line_id in lines_to_update: - cls._recompute_virtual_lot(Pool().get('purchase.line')(line_id)) - cls._recalc_line_quantity(line_id,True) - + for line_id in lines_to_update: + cls._recompute_virtual_lot(Pool().get('purchase.line')(line_id)) + cls._recalc_line_quantity(line_id,True) + for line_id in sale_lines_to_update: cls._recompute_virtual_lot(Pool().get('sale.line')(line_id)) cls._recalc_line_quantity(line_id,False) + cls.assert_lines_quantity_consistency( + [Pool().get('purchase.line')(line_id) for line_id in lines_to_update]) + cls.assert_lines_quantity_consistency( + [Pool().get('sale.line')(line_id) for line_id in sale_lines_to_update]) + Fee = Pool().get('fee.fee') for fee in Fee.browse(list(fees_to_sync)): fee.sync_quantity_from_lots() @@ -1101,10 +1268,11 @@ class LotQt( qt_p = left_qt @classmethod - def add_physical_lots(cls,lqt,vlots): - Purchase = Pool().get('purchase.purchase') - LotQt = Pool().get('lot.qt') - purchase = lqt.lot_p.line.purchase + def add_physical_lots(cls,lqt,vlots): + Purchase = Pool().get('purchase.purchase') + LotQt = Pool().get('lot.qt') + Lot = Pool().get('lot.lot') + purchase = lqt.lot_p.line.purchase lots = [] tot_qt = 0 for l in vlots: @@ -1117,12 +1285,18 @@ class LotQt( #update LotQt lqt.lot_quantity -= round(tot_qt,5) - if lqt.lot_quantity < 0: - lqt.lot_quantity = 0 - LotQt.save([lqt]) - - if lots: - return lots[0].id + if lqt.lot_quantity < 0: + lqt.lot_quantity = 0 + LotQt.save([lqt]) + affected_lines = [] + if lqt.lot_p and lqt.lot_p.line: + affected_lines.append(lqt.lot_p.line) + if lqt.lot_s and lqt.lot_s.sale_line: + affected_lines.append(lqt.lot_s.sale_line) + Lot.assert_lines_quantity_consistency(affected_lines) + + if lots: + return lots[0].id def add_physical_lot(self,l): Lot = Pool().get('lot.lot') @@ -2259,13 +2433,14 @@ class LotShipping(Wizard): # return { # } - def transition_shipping(self): - Lot = Pool().get('lot.lot') - LotQt = Pool().get('lot.qt') - Move = Pool().get('stock.move') - if not self.ship.shipment: - pass - for r in self.records: + def transition_shipping(self): + Lot = Pool().get('lot.lot') + LotQt = Pool().get('lot.qt') + Move = Pool().get('stock.move') + affected_lines = [] + if not self.ship.shipment: + pass + for r in self.records: if r.r_lot_shipment_in: raise UserError("Please unlink before linking to a new shipment !") else: @@ -2291,8 +2466,8 @@ class LotShipping(Wizard): if r.id < 10000000 : l = Lot(r.id) logger.info("IN_SHIPPING1:%s",l) - if self.ship.shipment == 'in': - l.lot_shipment_in = self.ship.shipment_in + if self.ship.shipment == 'in': + l.lot_shipment_in = self.ship.shipment_in elif self.ship.shipment == 'out': l.lot_shipment_out = self.ship.shipment_out elif self.ship.shipment == 'int': @@ -2313,18 +2488,27 @@ class LotShipping(Wizard): l.updateVirtualPart(-l.get_current_quantity_converted(),shipment_origin,l.getVlot_s()) l.lot_av = 'reserved' Lot.save([l]) - l.set_current_quantity(l.lot_quantity,l.lot_gross_quantity,2) - Lot.save([l]) - else: - lqt = LotQt(r.id - 10000000) + l.set_current_quantity(l.lot_quantity,l.lot_gross_quantity,2) + Lot.save([l]) + if l.line: + affected_lines.append(l.line) + if l.sale_line: + affected_lines.append(l.sale_line) + else: + lqt = LotQt(r.id - 10000000) #Increase forecasted virtual part shipped if not lqt.lot_p.updateVirtualPart(shipped_quantity,shipment_origin,lqt.lot_s): logger.info("LotShipping2:%s",shipped_quantity) lqt.lot_p.createVirtualPart(shipped_quantity,shipment_origin,lqt.lot_s) - #Decrease forecasted virtual part non shipped - lqt.lot_p.updateVirtualPart(-shipped_quantity,None,lqt.lot_s) - - return 'end' + #Decrease forecasted virtual part non shipped + lqt.lot_p.updateVirtualPart(-shipped_quantity,None,lqt.lot_s) + if lqt.lot_p and lqt.lot_p.line: + affected_lines.append(lqt.lot_p.line) + if lqt.lot_s and lqt.lot_s.sale_line: + affected_lines.append(lqt.lot_s.sale_line) + + Lot.assert_lines_quantity_consistency(affected_lines) + return 'end' def end(self): return 'reload' @@ -2377,18 +2561,31 @@ class LotMatching(Wizard): def transition_start(self): return 'match' - def transition_matching(self): - Warning = Pool().get('res.user.warning') - LotQt = Pool().get('lot.qt') + def transition_matching(self): + Warning = Pool().get('res.user.warning') + LotQt = Pool().get('lot.qt') if self.match.tot_p != self.match.tot_s: warning_name = Warning.format("Quantity's issue", []) if Warning.check(warning_name): raise QtWarning(warning_name, "Quantities not compatibles") - Lot = Pool().get('lot.lot') - LotQt.match_lots(self.match.lot_p,self.match.lot_s) - - return 'end' + Lot = Pool().get('lot.lot') + LotQt.match_lots(self.match.lot_p,self.match.lot_s) + affected_lines = [] + for matched_lot in list(self.match.lot_p or []) + list(self.match.lot_s or []): + if matched_lot.lot_id: + if matched_lot.lot_id.line: + affected_lines.append(matched_lot.lot_id.line) + if matched_lot.lot_id.sale_line: + affected_lines.append(matched_lot.lot_id.sale_line) + if matched_lot.lot_r_id: + if matched_lot.lot_r_id.lot_p and matched_lot.lot_r_id.lot_p.line: + affected_lines.append(matched_lot.lot_r_id.lot_p.line) + if matched_lot.lot_r_id.lot_s and matched_lot.lot_r_id.lot_s.sale_line: + affected_lines.append(matched_lot.lot_r_id.lot_s.sale_line) + Lot.assert_lines_quantity_consistency(affected_lines) + + return 'end' def end(self): return 'reload' @@ -2553,12 +2750,13 @@ class LotUnmatch(Wizard): start = StateTransition() - def transition_start(self): - Lot = Pool().get('lot.lot') - LotQt = Pool().get('lot.qt') - for r in self.records: - if r.id > 10000000 : - lqt = LotQt(r.id - 10000000) + def transition_start(self): + Lot = Pool().get('lot.lot') + LotQt = Pool().get('lot.qt') + affected_lines = [] + for r in self.records: + if r.id > 10000000 : + lqt = LotQt(r.id - 10000000) if lqt.lot_p and lqt.lot_s: lot = lqt.lot_p qt = lqt.lot_quantity @@ -2568,10 +2766,14 @@ class LotUnmatch(Wizard): if not lot.updateVirtualPart(qt,lqt.lot_shipment_origin,lqt.lot_s,'only sale'): lot.createVirtualPart(qt,lqt.lot_shipment_origin,lqt.lot_s,'only sale') #Decrease forecasted virtual part matched - logger.info("UNMATCH_QT:%s",qt) - lot.updateVirtualPart(-qt,lqt.lot_shipment_origin,lqt.lot_s) - else: - lot = Lot(r.id) + logger.info("UNMATCH_QT:%s",qt) + lot.updateVirtualPart(-qt,lqt.lot_shipment_origin,lqt.lot_s) + if lqt.lot_p and lqt.lot_p.line: + affected_lines.append(lqt.lot_p.line) + if lqt.lot_s and lqt.lot_s.sale_line: + affected_lines.append(lqt.lot_s.sale_line) + else: + lot = Lot(r.id) qt = lot.get_current_quantity_converted() # #Increase forecasted virtual part matched # if not lot.updateVirtualPart(qt,lot.lot_shipment_origin,lot.getVlot_s()): @@ -2580,11 +2782,16 @@ class LotUnmatch(Wizard): # if not lot.updateVirtualPart(-qt,lot.lot_shipment_origin,None): # lot.createVirtualPart(-qt,lot.lot_shipment_origin,None) ls = lot.getVlot_s() - ls.lot_quantity += qt - Lot.save([ls]) - lot.sale_line = None - Lot.save([lot]) - return 'end' + ls.lot_quantity += qt + Lot.save([ls]) + lot.sale_line = None + Lot.save([lot]) + if lot.line: + affected_lines.append(lot.line) + if ls.sale_line: + affected_lines.append(ls.sale_line) + Lot.assert_lines_quantity_consistency(affected_lines) + return 'end' def end(self): return 'reload' @@ -2595,11 +2802,12 @@ class LotUnship(Wizard): start = StateTransition() - def transition_start(self): - Lot = Pool().get('lot.lot') - LotQt = Pool().get('lot.qt') - Move = Pool().get('stock.move') - for r in self.records: + def transition_start(self): + Lot = Pool().get('lot.lot') + LotQt = Pool().get('lot.qt') + Move = Pool().get('stock.move') + affected_lines = [] + for r in self.records: lqt = LotQt(r.id - 10000000) lot = Lot(r.id) if lqt and r.r_lot_type == 'virtual': @@ -2620,10 +2828,14 @@ class LotUnship(Wizard): #Decrease forecasted virtual part shipped lot.updateVirtualPart(-qt,lqt.lot_shipment_origin,lqt.lot_s,mode) #Increase forecasted virtual part non shipped - if not lot.updateVirtualPart(qt,None,lqt.lot_s,mode): - lot.createVirtualPart(qt,None,lqt.lot_s,mode) - if lot and r.r_lot_type == 'physic': - lot = Lot(r.r_lot_p) + if not lot.updateVirtualPart(qt,None,lqt.lot_s,mode): + lot.createVirtualPart(qt,None,lqt.lot_s,mode) + if lqt.lot_p and lqt.lot_p.line: + affected_lines.append(lqt.lot_p.line) + if lqt.lot_s and lqt.lot_s.sale_line: + affected_lines.append(lqt.lot_s.sale_line) + if lot and r.r_lot_type == 'physic': + lot = Lot(r.r_lot_p) if lot.lot_type == 'physic': str_sh = str(lot.lot_shipment_origin) if 'stock.shipment.in' in str_sh: @@ -2640,9 +2852,14 @@ class LotUnship(Wizard): # lm = LotMove.search([('lot','=',lot.id),('move','=',lot.move)]) # if lm: # LotMove.delete(lm) - lot.lot_status = 'forecast' - Lot.save([lot]) - return 'end' + lot.lot_status = 'forecast' + Lot.save([lot]) + if lot.line: + affected_lines.append(lot.line) + if lot.sale_line: + affected_lines.append(lot.sale_line) + Lot.assert_lines_quantity_consistency(affected_lines) + return 'end' def end(self): return 'reload' @@ -3380,12 +3597,13 @@ class LotWeighing(Wizard): 'lot_p': lot_p, } - def transition_weighing(self): - Warning = Pool().get('res.user.warning') - FeeLots = Pool().get('fee.lots') - Lot = Pool().get('lot.lot') - LotHist = Pool().get('lot.qt.hist') - for l in self.w.lot_p: + def transition_weighing(self): + Warning = Pool().get('res.user.warning') + FeeLots = Pool().get('fee.lots') + Lot = Pool().get('lot.lot') + LotHist = Pool().get('lot.qt.hist') + affected_lines = [] + for l in self.w.lot_p: quantity = l.lot.get_current_quantity_converted() lhs = LotHist.search([('lot',"=",l.lot.id),('quantity_type','=',self.w.lot_state.id)]) if lhs: @@ -3406,13 +3624,18 @@ class LotWeighing(Wizard): if diff != 0 : #need to update virtual part l.lot.updateVirtualPart(-diff,l.lot.lot_shipment_origin,l.lot.getVlot_s()) + if l.lot.line: + affected_lines.append(l.lot.line) + if l.lot.sale_line: + affected_lines.append(l.lot.sale_line) #adjuts fee ordered with new quantity fees = FeeLots.search(['lot','=',l.lot.id]) for f in fees: f.fee.sync_quantity_from_lots() f.fee.adjust_purchase_values() - - return 'end' + + Lot.assert_lines_quantity_consistency(affected_lines) + return 'end' def end(self): return 'reload' diff --git a/modules/purchase_trade/purchase.py b/modules/purchase_trade/purchase.py index 2bc51ff..b95288c 100755 --- a/modules/purchase_trade/purchase.py +++ b/modules/purchase_trade/purchase.py @@ -1661,6 +1661,8 @@ class Line(metaclass=PoolMeta): fee.sync_quantity_from_lots() fee.adjust_purchase_values() + Pool().get('lot.lot').assert_lines_quantity_consistency(lines) + @classmethod def _sync_open_lot_quantity(cls, line, vlot, target_quantity): Lot = Pool().get('lot.lot') diff --git a/modules/purchase_trade/sale.py b/modules/purchase_trade/sale.py index db439e9..ad76568 100755 --- a/modules/purchase_trade/sale.py +++ b/modules/purchase_trade/sale.py @@ -1776,6 +1776,8 @@ class SaleLine(metaclass=PoolMeta): fee.sync_quantity_from_lots() fee.adjust_purchase_values() + Pool().get('lot.lot').assert_lines_quantity_consistency(lines) + @classmethod def _sync_open_lot_quantity(cls, line, vlot, target_quantity): Lot = Pool().get('lot.lot') diff --git a/modules/purchase_trade/view/purchase_line_form.xml b/modules/purchase_trade/view/purchase_line_form.xml index 1793c93..85fa7fe 100755 --- a/modules/purchase_trade/view/purchase_line_form.xml +++ b/modules/purchase_trade/view/purchase_line_form.xml @@ -2,13 +2,14 @@ - - + +