check code + db

This commit is contained in:
2026-05-13 18:39:54 +02:00
parent a71ce107f0
commit 26c955ef53
9 changed files with 774 additions and 107 deletions

View File

@@ -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:

View File

@@ -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 :

View File

@@ -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.

View File

@@ -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;

View File

@@ -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'

View File

@@ -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')

View File

@@ -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')

View File

@@ -2,13 +2,14 @@
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
this repository contains the full copyright notices and license terms. -->
<data>
<xpath expr="/form/notebook/page[@id='general']/field[@name='unit']" position="after">
<newline/>
<xpath expr="/form/notebook/page[@id='general']/label[@name='quantity']" position="before">
<label name="quantity_theorical"/>
<field name="quantity_theorical"/>
<label name="finished"/>
<field name="finished"/>
<newline/>
</xpath>
<xpath expr="/form/notebook/page[@id='general']/field[@name='unit']" position="after">
<label name="price_type"/>
<field name="price_type"/>
<label name="enable_linked_currency"/>

View File

@@ -2,13 +2,14 @@
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
this repository contains the full copyright notices and license terms. -->
<data>
<xpath expr="/form/notebook/page[@id='general']/field[@name='unit']" position="after">
<newline/>
<xpath expr="/form/notebook/page[@id='general']/label[@name='quantity']" position="before">
<label name="quantity_theorical"/>
<field name="quantity_theorical"/>
<label name="finished"/>
<field name="finished"/>
<newline/>
</xpath>
<xpath expr="/form/notebook/page[@id='general']/field[@name='unit']" position="after">
<label name="price_type"/>
<field name="price_type"/>
<label name="enable_linked_currency"/>