Compare commits

...

34 Commits

Author SHA1 Message Date
493dd54606 Get amount 2026-07-21 23:35:39 +02:00
ab80f8d909 Linkage 2026-07-21 21:40:35 +02:00
2fe13c1e76 Mark as finished 2026-07-21 17:47:10 +02:00
a70c2c4853 Faircot cron 2026-07-20 09:30:31 +02:00
d6fa273d11 Faircot cron 2026-07-20 09:16:28 +02:00
816d1fcaec Faircot cron 2026-07-20 08:43:43 +02:00
8973f8a6d0 Faircot process 2026-07-19 22:52:21 +02:00
572f6ef902 Faircot process 2026-07-19 22:42:57 +02:00
bd72f8cef3 Faircot cron 2026-07-19 21:59:22 +02:00
a94f36e26f Faircot report 2026-07-19 21:16:19 +02:00
669320c976 Faircot cron 2026-07-19 21:02:50 +02:00
facd627f5e Faircot cron 2026-07-19 19:30:28 +02:00
b2bd777ec8 Faircot report 2026-07-19 18:34:03 +02:00
71751a96c0 Faircot report 2026-07-19 18:17:06 +02:00
c655427be5 Bug col_width 2026-07-19 17:32:54 +02:00
0272fc70bc Bug Pnl packing 2026-07-17 07:28:35 +02:00
39afd9dc32 Cupping Criteria 2026-07-16 22:14:27 +02:00
0e30f9ac30 Cupping bug 2026-07-16 21:45:46 +02:00
8be68db6db Coffee form 2026-07-16 21:22:34 +02:00
aba25d62dc Hedging setup 2026-07-16 20:37:17 +02:00
4c85f80412 Lot virtual packing update 2026-07-16 18:53:55 +02:00
a0bd6f0e28 Bug Pnl 2026-07-16 15:09:54 +02:00
cda8b4067b Bug Add physical lots 2026-07-16 12:22:51 +02:00
728e2c7d60 Pnl 2026-07-16 10:11:11 +02:00
4652a1a94a Price linked currency 2026-07-16 09:05:08 +02:00
b53d7a216d Coffee visible 2026-07-15 23:26:07 +02:00
0e004027df Coffee hide 2026-07-15 22:18:42 +02:00
df8439f3f5 Bug tax_rate 2026-07-15 21:19:47 +02:00
cb35de91ba Coffee lines 2026-07-15 17:03:07 +02:00
59171ec07b Linkage 2026-07-15 08:51:00 +02:00
9b0dd0e7c1 Linkage 2026-07-15 07:58:02 +02:00
cf0c0923a6 Linkage 2026-07-14 18:15:26 +02:00
bdf13f4fac Merge branch 'main' of https://gitea.open-squared.tech/admin/tradon 2026-07-14 16:51:18 +02:00
e184da898f Linkage 2026-07-14 16:51:10 +02:00
49 changed files with 4862 additions and 1162 deletions

View File

@@ -117,6 +117,7 @@
<name ns="">widget</name>
<choice>
<value>binary</value>
<value>badge</value>
<value>boolean</value>
<value>callto</value>
<value>char</value>
@@ -145,6 +146,14 @@
</attribute>
</optional>
</define>
<define name="attlist.field" combine="interleave">
<optional>
<attribute>
<name ns="">badge_colors</name>
<text/>
</attribute>
</optional>
</define>
<define name="attlist.field" combine="interleave">
<optional>
<attribute a:defaultValue="0">

View File

@@ -1,11 +1,12 @@
from trytond.pool import Pool
from . import automation,rules,freight_booking,cron #, document
def register():
Pool.register(
automation.AutomationDocument,
rules.AutomationRuleSet,
freight_booking.FreightBookingInfo,
cron.Cron,
cron.AutomationCron,
module='automation', type_='model')
from trytond.pool import Pool
from . import automation,rules,freight_booking,booking_report,cron #, document
def register():
Pool.register(
automation.AutomationDocument,
rules.AutomationRuleSet,
freight_booking.FreightBookingInfo,
booking_report.FreightBookingShipmentReport,
cron.Cron,
cron.AutomationCron,
module='automation', type_='model')

View File

@@ -10,6 +10,7 @@ import io
import logging
import json
import re
import traceback
logger = logging.getLogger(__name__)
@@ -62,25 +63,57 @@ class AutomationDocument(ModelSQL, ModelView, Workflow):
@ModelView.button
def run_ocr(cls, docs):
for doc in docs:
logger.info(
"RUN_OCR_START doc=%s type=%s state=%s incoming=%s",
getattr(doc, 'id', None), getattr(doc, 'type', None),
getattr(doc, 'state', None),
getattr(getattr(doc, 'document', None), 'id', None))
try:
if doc.type == 'weight_report':
# Décoder le fichier depuis le champ Binary
file_data = doc.document.data or b""
logger.info(f"File size: {len(file_data)} bytes")
logger.info(f"First 20 bytes: {file_data[:20]}")
logger.info(f"Last 20 bytes: {file_data[-20:]}")
if not doc.document:
raise ValueError("No incoming document linked")
file_data = doc.document.data or b""
file_name = doc.document.name or "document"
url = "http://automation-service:8006/ocr"
logger.info(
"RUN_OCR_FILE doc=%s incoming=%s name=%s size=%s "
"first_20=%r last_20=%r",
getattr(doc, 'id', None), doc.document.id,
file_name, len(file_data), file_data[:20],
file_data[-20:])
if not file_data:
raise ValueError("Incoming document has no binary data")
# Envoyer le fichier au service OCR
logger.info(
"RUN_OCR_HTTP_REQUEST doc=%s url=%s file_name=%s "
"size=%s",
getattr(doc, 'id', None), url, file_name,
len(file_data))
response = requests.post(
"http://automation-service:8006/ocr",
files={"file": (file_name, io.BytesIO(file_data))}
url,
files={"file": (file_name, io.BytesIO(file_data))},
timeout=120,
)
logger.info(
"RUN_OCR_HTTP_RESPONSE doc=%s status=%s reason=%s "
"elapsed=%s content_type=%s body_start=%r",
getattr(doc, 'id', None), response.status_code,
response.reason, response.elapsed,
response.headers.get('content-type'),
response.text[:1000])
response.raise_for_status()
data = response.json()
logger.info("RUN_OCR_RESPONSE:%s",data)
doc.ocr_text = data.get("ocr_text", "")
ocr_text = data.get("ocr_text", "") or ""
logger.info(
"RUN_OCR_JSON doc=%s keys=%s ocr_text_len=%s "
"ocr_text_start=%r",
getattr(doc, 'id', None), list(data.keys()),
len(ocr_text), ocr_text[:500])
doc.ocr_text = ocr_text
doc.state = "ocr_done"
doc.notes = (doc.notes or "") + "OCR done\n"
else:
@@ -112,9 +145,17 @@ class AutomationDocument(ModelSQL, ModelView, Workflow):
doc.notes = (doc.notes or "") + " SO updated"
except Exception as e:
logger.error(
"RUN_OCR_FAILED doc=%s type=%s error=%s traceback=%s",
getattr(doc, 'id', None), getattr(doc, 'type', None),
e, traceback.format_exc())
doc.state = "error"
doc.notes = (doc.notes or "") + f"OCR error: {e}\n"
doc.save()
logger.info(
"RUN_OCR_END doc=%s state=%s ocr_text_len=%s",
getattr(doc, 'id', None), getattr(doc, 'state', None),
len(getattr(doc, 'ocr_text', None) or ""))
# -------------------------------------------------------
# STRUCTURE (doctr)
# -------------------------------------------------------

View File

@@ -0,0 +1,128 @@
import json
from sql import Literal, Null
from sql.functions import CurrentTimestamp
from trytond.model import ModelSQL, ModelView, fields
from trytond.pool import Pool
class FreightBookingShipmentReport(ModelSQL, ModelView):
"Freight Booking Shipment Report"
__name__ = 'freight.booking.shipment.report'
automation_document = fields.Many2One(
'automation.document', "Automation Document")
bl_number = fields.Function(fields.Char("BL Number"), 'get_bl_number')
shipment = fields.Function(fields.Many2One(
'stock.shipment.in', "Shipment"), 'get_shipment')
status = fields.Function(fields.Selection([
('found', "Found"),
('error', "Error"),
], "Status"), 'get_status')
@classmethod
def __setup__(cls):
super().__setup__()
cls._order = [
('id', 'DESC'),
]
@classmethod
def table_query(cls):
AutomationDocument = Pool().get('automation.document')
document = AutomationDocument.__table__()
return document.select(
Literal(None).as_('create_uid'),
CurrentTimestamp().as_('create_date'),
Literal(None).as_('write_uid'),
Literal(None).as_('write_date'),
document.id.as_('id'),
document.id.as_('automation_document'),
where=(
(document.type == 'weight_report')
& (document.metadata_json != Null)))
@classmethod
def _extract_bl_number(cls, metadata_json):
if not metadata_json:
return None
try:
metadata = json.loads(str(metadata_json))
except (TypeError, ValueError):
return None
def find_value(value):
if isinstance(value, dict):
for key in (
'bl_no', 'bl_number', 'bl', 'BL_Number',
'BL number', 'B/L No'):
bl_number = value.get(key)
if bl_number:
return str(bl_number).strip()
for item in value.values():
bl_number = find_value(item)
if bl_number:
return bl_number
elif isinstance(value, list):
for item in value:
bl_number = find_value(item)
if bl_number:
return bl_number
return None
return find_value(metadata)
@classmethod
def _get_report_data(cls, reports):
ShipmentIn = Pool().get('stock.shipment.in')
bl_numbers = {}
for report in reports:
document = report.automation_document
bl_number = cls._extract_bl_number(
document.metadata_json if document else None)
bl_numbers[report.id] = bl_number
shipments_by_bl = {}
for bl_number in {b for b in bl_numbers.values() if b}:
shipments = ShipmentIn.search([
('bl_number', '=', bl_number),
], limit=1)
if not shipments:
shipments = ShipmentIn.search([
('bl_number', 'ilike', bl_number),
], limit=1)
shipments_by_bl[bl_number] = shipments[0] if shipments else None
return bl_numbers, shipments_by_bl
@classmethod
def get_bl_number(cls, reports, name):
bl_numbers, _ = cls._get_report_data(reports)
return bl_numbers
@classmethod
def get_shipment(cls, reports, name):
bl_numbers, shipments_by_bl = cls._get_report_data(reports)
return {
report.id: (
shipments_by_bl[bl_numbers[report.id]].id
if bl_numbers[report.id]
and shipments_by_bl.get(bl_numbers[report.id])
else None)
for report in reports
}
@classmethod
def get_status(cls, reports, name):
bl_numbers, shipments_by_bl = cls._get_report_data(reports)
return {
report.id: (
'found'
if bl_numbers[report.id]
and shipments_by_bl.get(bl_numbers[report.id])
else 'error')
for report in reports
}

View File

@@ -0,0 +1,43 @@
<?xml version="1.0"?>
<tryton>
<data>
<record model="ir.ui.view" id="freight_booking_shipment_report_tree">
<field name="model">freight.booking.shipment.report</field>
<field name="type">tree</field>
<field name="name">freight_booking_shipment_report_tree</field>
</record>
<record model="ir.action.act_window" id="act_freight_booking_shipment_report">
<field name="name">Freight Booking Shipment Report</field>
<field name="res_model">freight.booking.shipment.report</field>
</record>
<record model="ir.action.act_window.view" id="act_freight_booking_shipment_report_view1">
<field name="sequence" eval="10"/>
<field name="view" ref="freight_booking_shipment_report_tree"/>
<field name="act_window" ref="act_freight_booking_shipment_report"/>
</record>
<record model="ir.model.access" id="access_freight_booking_shipment_report">
<field name="model">freight.booking.shipment.report</field>
<field name="perm_read" eval="False"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_delete" eval="False"/>
</record>
<record model="ir.model.access" id="access_freight_booking_shipment_report_automation">
<field name="model">freight.booking.shipment.report</field>
<field name="group" ref="group_automation"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_delete" eval="False"/>
</record>
<menuitem
name="Freight Booking Shipment Report"
action="act_freight_booking_shipment_report"
parent="menu_automation"
sequence="11"
id="menu_freight_booking_shipment_report" />
</data>
</tryton>

View File

@@ -219,6 +219,75 @@ class AutomationCron(ModelSQL, ModelView):
rows2 = cursor2.fetchall()
def normalize_bl_number(value):
value = str(value or '').strip()
if value.lower() in {'none', 'null'}:
return ''
if value.upper() in {
'MULTIPLE BL NUMBERS FOUND',
'MULTIPLE B/L NUMBERS FOUND',
'NO BL NUMBER FOUND',
'NO B/L NUMBER FOUND',
}:
return ''
return value
def normalize_booking_number(value):
value = str(value or '').strip()
if value.lower() in {'none', 'null'}:
return ''
return value
def sync_shipment_from_booking(shipment, bl_number, bl_date, etd_date,
controller):
changed = False
columns = []
values = []
booking_bl_number = normalize_bl_number(bl_number)
shipment_bl_number = normalize_bl_number(shipment.bl_number)
if booking_bl_number and booking_bl_number != shipment_bl_number:
logger.info(
"FREIGHT_BOOKING_SYNC_BL shipment=%s reference=%s "
"old_bl=%s new_bl=%s",
shipment.id, shipment.reference,
shipment.bl_number, booking_bl_number)
shipment.bl_number = booking_bl_number
columns.append('bl_number')
values.append(booking_bl_number)
changed = True
if bl_date and shipment.bl_date != bl_date:
logger.info(
"FREIGHT_BOOKING_SYNC_BL_DATE shipment=%s reference=%s "
"old_bl_date=%s new_bl_date=%s",
shipment.id, shipment.reference,
shipment.bl_date, bl_date)
shipment.bl_date = bl_date
columns.append('bl_date')
values.append(bl_date)
changed = True
if etd_date and shipment.etd != etd_date:
logger.info(
"FREIGHT_BOOKING_SYNC_ETD shipment=%s reference=%s "
"old_etd=%s new_etd=%s",
shipment.id, shipment.reference,
shipment.etd, etd_date)
shipment.etd = etd_date
columns.append('etd')
values.append(etd_date)
changed = True
if controller and shipment.controller_target != controller:
shipment.controller_target = controller
columns.append('controller_target')
values.append(controller)
changed = True
if changed:
table = ShipmentIn.__table__()
table_columns = [getattr(table, column) for column in columns]
cursor = Transaction().connection.cursor()
cursor.execute(*table.update(
table_columns, values, where=table.id == shipment.id))
return changed
for i, row in enumerate(rows2, 1):
(
si_number, si_date, si_quantity, si_unit,
@@ -229,30 +298,49 @@ class AutomationCron(ModelSQL, ModelView):
etd_date, bl_date, controller,
comments, fintrade_booking_key
) = row
si_reference = normalize_booking_number(si_number)
logger.info(f"Traitement shipment {i}/{len(rows2)} : SI {si_number}")
logger.info(
"Traitement shipment %s/%s : SI %s reference=%s",
i, len(rows2), si_number, si_reference)
try:
with Transaction().new_transaction() as trans_shipment:
logger.info(f"Debut transaction pour SI {si_number}")
logger.info(
"Debut transaction pour SI %s reference=%s",
si_number, si_reference)
existing_shipment = ShipmentIn.search([
('reference', '=', si_number)
], limit=1)
existing_shipments = ShipmentIn.search([
('reference', '=', si_reference)
])
if existing_shipment:
shipment = existing_shipment[0]
if existing_shipments:
if len(existing_shipments) > 1:
logger.info(
"FREIGHT_BOOKING_DUPLICATE_SHIPMENT_REFERENCE "
"reference=%s shipment_ids=%s",
si_reference,
[shipment.id for shipment in existing_shipments])
for existing in existing_shipments:
sync_shipment_from_booking(
existing, bl_number, bl_date, etd_date,
controller)
shipment = existing_shipments[0]
if shipment.incoming_moves:
logger.info(
"Shipment %s existe deja avec lots, ignore",
si_number)
"Shipment %s existe deja avec lots, "
"booking data synchronized",
si_reference)
trans_shipment.commit()
continue
logger.info(
"Shipment %s existe deja sans lots, verification freight_booking_lots",
si_number)
inv_date, inv_nb = shipment._create_lots_from_fintrade()
si_reference)
with Transaction().set_context(
_purchase_trade_skip_physical_lot_tolerance_warning=True):
inv_date, inv_nb = (
shipment._create_lots_from_fintrade())
shipment = ShipmentIn(shipment.id)
if shipment.incoming_moves:
shipment.controller = shipment.get_controller()
@@ -264,11 +352,11 @@ class AutomationCron(ModelSQL, ModelView):
ShipmentIn.save([shipment])
logger.info(
"Shipment %s mis a jour avec %s incoming move(s)",
si_number, len(shipment.incoming_moves))
si_reference, len(shipment.incoming_moves))
else:
logger.info(
"Shipment %s existe sans lots et aucun lot disponible pour l'instant",
si_number)
si_reference)
trans_shipment.commit()
continue
@@ -328,7 +416,7 @@ class AutomationCron(ModelSQL, ModelView):
raise ValueError(error_msg)
shipment = ShipmentIn()
shipment.reference = si_number
shipment.reference = si_reference
shipment.from_location = loc_from
shipment.to_location = loc_to
shipment.carrier = None # carrier
@@ -342,16 +430,26 @@ class AutomationCron(ModelSQL, ModelView):
shipment.etad = shipment.bl_date + timedelta(days=20)
ShipmentIn.save([shipment])
inv_date, inv_nb = shipment._create_lots_from_fintrade()
shipment.controller = shipment.get_controller()
shipment.controller_target = controller
shipment.create_fee(shipment.controller)
shipment.instructions = shipment.get_instructions_html(
inv_date, inv_nb)
ShipmentIn.save([shipment])
with Transaction().set_context(
_purchase_trade_skip_physical_lot_tolerance_warning=True):
inv_date, inv_nb = shipment._create_lots_from_fintrade()
shipment = ShipmentIn(shipment.id)
if shipment.incoming_moves:
shipment.controller = shipment.get_controller()
shipment.controller_target = controller
shipment.create_fee(shipment.controller)
shipment.instructions = shipment.get_instructions_html(
inv_date, inv_nb)
ShipmentIn.save([shipment])
else:
logger.info(
"Shipment %s cree sans lots : fees/instructions "
"non crees, voir FINTRADE_CLEANUP_NEEDED",
si_number)
trans_shipment.commit()
successful_shipments += 1
logger.info(f"Shipment {si_number} cree avec succes")
if shipment.incoming_moves:
successful_shipments += 1
logger.info(f"Shipment {si_number} cree avec succes")
except Exception as e:
error_details = {

View File

@@ -7,4 +7,5 @@ depends:
xml:
automation.xml
freight_booking.xml
cron.xml
booking_report.xml
cron.xml

View File

@@ -0,0 +1,7 @@
<tree>
<field name="bl_number"/>
<field name="status" widget="badge"
badge_colors="found:#16a34a,Found:#16a34a,error:#ef4444,Error:#ef4444"/>
<field name="shipment"/>
<field name="automation_document"/>
</tree>

View File

@@ -295,23 +295,60 @@ class Incoming(DeactivableMixin, Workflow, ModelSQL, ModelView):
@ModelView.button
@Workflow.transition('processing')
def process(cls, documents, with_children=False):
logger.info(
"DOCUMENT_INCOMING_PROCESS_CLICK documents=%s with_children=%s",
[
{
'id': document.id,
'name': document.name,
'type': document.type,
'state': document.state,
}
for document in documents
],
with_children)
transaction = Transaction()
context = transaction.context
with transaction.set_context(
queue_batch=context.get('queue_batch', True)):
logger.info(
"DOCUMENT_INCOMING_PROCESS_QUEUE documents=%s queue_batch=%s",
[document.id for document in documents],
transaction.context.get('queue_batch'))
cls.__queue__.proceed(documents, with_children=with_children)
@classmethod
@ModelView.button
@Workflow.transition('done')
def proceed(cls, documents, with_children=False):
logger.info(
"DOCUMENT_INCOMING_PROCEED_START documents=%s with_children=%s",
[
{
'id': document.id,
'name': document.name,
'type': document.type,
'state': document.state,
'active': document.active,
'result': str(document.result) if document.result else None,
}
for document in documents
],
with_children)
pool = Pool()
Attachment = pool.get('ir.attachment')
results = defaultdict(list)
attachments = []
for document in documents:
if document.result or not document.active:
logger.info(
"DOCUMENT_INCOMING_PROCEED_SKIP document=%s "
"result=%s active=%s",
document.id, document.result, document.active)
continue
logger.info(
"DOCUMENT_INCOMING_PROCEED_DISPATCH document=%s method=%s",
document.id, f'_process_{document.type}')
document.result = getattr(document, f'_process_{document.type}')()
results[document.result.__class__].append(document.result)
attachment = Attachment(

View File

@@ -7,6 +7,9 @@ from trytond.modules.document_incoming.exceptions import (
DocumentIncomingProcessError)
from trytond.pool import Pool, PoolMeta
import json
import logging
logger = logging.getLogger(__name__)
class IncomingConfiguration(metaclass=PoolMeta):
@@ -39,12 +42,22 @@ class Incoming(metaclass=PoolMeta):
return super()._get_results() | {'automation.document'}
def _process_weight_report(self):
logger.info(
"DOCUMENT_INCOMING_WR_START incoming=%s name=%s type=%s "
"data_size=%s",
getattr(self, 'id', None), getattr(self, 'name', None),
getattr(self, 'type', None),
len(getattr(self, 'data', None) or b""))
WR = Pool().get('automation.document')
wr = WR()
wr.document = self.id
wr.type = 'weight_report'
wr.state = 'draft'
WR.save([wr])
logger.info(
"DOCUMENT_INCOMING_WR_AUTOMATION_CREATED incoming=%s "
"automation_document=%s",
getattr(self, 'id', None), getattr(wr, 'id', None))
WR.run_ocr([wr])
WR.run_metadata([wr])
WR.run_pipeline([wr])

View File

@@ -492,8 +492,7 @@ class Lot(ModelSQL, ModelView):
st = state_id
else:
st = self.lot_state.id
logger.info("GET_HIST_QT:%s",st)
lot = [e for e in self.lot_hist if e.quantity_type.id == st][0]
lot = [e for e in self.lot_hist if e.quantity_type.id == st][0]
qt = round(lot.quantity,5)
gross_qt = round(lot.gross_quantity,5)
return qt, gross_qt

View File

@@ -36,30 +36,51 @@ class Price(
('composite', 'Composite'),
], 'Index type')
price_type = fields.Many2One('price.fixtype', "Fixation type")
price_unit = fields.Many2One('product.uom', "Unit")
price_currency = fields.Many2One('currency.currency', "Currency")
price_area = fields.Many2One('price.area',"Market area")
price_calendar = fields.Many2One('price.calendar',"Calendar")
price_values = fields.One2Many('price.price_value', 'price', "Prices Values")
price_composite = fields.One2Many('price.composite','price',"Composites")
price_product = fields.One2Many('price.product', 'price', "Product")
price_unit = fields.Many2One('product.uom', "Unit")
price_currency = fields.Many2One('currency.currency', "Currency")
enable_linked_currency = fields.Boolean("Linked currency")
linked_currency = fields.Many2One('currency.linked', "Linked currency",
domain=[
('currency', '=', Eval('price_currency')),
],
states={
'invisible': ~Eval('enable_linked_currency'),
'required': Eval('enable_linked_currency'),
}, depends=['enable_linked_currency', 'price_currency'])
price_area = fields.Many2One('price.area',"Market area")
price_calendar = fields.Many2One('price.calendar',"Calendar")
price_values = fields.One2Many('price.price_value', 'price', "Prices Values")
price_composite = fields.One2Many('price.composite','price',"Composites")
price_product = fields.One2Many('price.product', 'price', "Product")
price_ct_size = fields.Numeric("Ct size")
def get_qt(self,nb_ct,unit):
Uom = Pool().get('product.uom')
return round(Decimal(Uom.compute_qty(self.price_unit, float(self.price_ct_size * nb_ct), unit)),4)
def get_price_per_qt(self,price,unit,currency):
price_qt = float(0)
Uom = Pool().get('product.uom')
Currency = Pool().get('currency.currency')
if currency != self.price_currency:
rates = Currency._get_rate([self.price_currency])
if rates[self.price_currency.id]:
price_qt = float(price) * Uom.compute_qty(unit, float(1), self.price_unit) * float(rates[self.price_currency.id])
else:
price_qt = float(price) * Uom.compute_qty(unit, float(1), self.price_unit)
return round(price_qt,4)
def get_qt(self,nb_ct,unit):
Uom = Pool().get('product.uom')
return round(Decimal(Uom.compute_qty(self.price_unit, float(self.price_ct_size * nb_ct), unit)),4)
def _price_in_main_currency(self, price):
price = Decimal(str(price or 0))
if self.enable_linked_currency and self.linked_currency:
factor = Decimal(self.linked_currency.factor or 0)
if factor:
price *= factor
return price
def get_price_per_qt(self,price,unit,currency):
price_qt = Decimal(0)
Uom = Pool().get('product.uom')
Currency = Pool().get('currency.currency')
price = self._price_in_main_currency(price)
unit_factor = Decimal(str(
Uom.compute_qty(unit, float(1), self.price_unit) or 0))
if currency != self.price_currency:
rates = Currency._get_rate([self.price_currency])
if rates[self.price_currency.id]:
price_qt = price * unit_factor * Decimal(str(
rates[self.price_currency.id]))
else:
price_qt = price * unit_factor
return round(price_qt,4)
def get_amount_nb_ct(self,price,nb_ct,unit,currency):
amount = Decimal(0)

View File

@@ -12,10 +12,14 @@
<field name="price_type"/>
<label name="price_unit"/>
<field name="price_unit"/>
<label name="price_currency"/>
<field name="price_currency"/>
<label name="price_area"/>
<field name="price_area"/>
<label name="price_currency"/>
<field name="price_currency"/>
<label name="enable_linked_currency"/>
<field name="enable_linked_currency"/>
<label name="linked_currency"/>
<field name="linked_currency"/>
<label name="price_area"/>
<field name="price_area"/>
<label name="price_calendar"/>
<field name="price_calendar"/>
<label name="price_ct_size"/>

View File

@@ -519,6 +519,8 @@ class Purchase(
@property
def taxable_lines(self):
Date = Pool().get('ir.date')
tax_date = getattr(self, 'purchase_date', None) or Date.today()
taxable_lines = []
# In case we're called from an on_change we have to use some sensible
# defaults
@@ -529,7 +531,7 @@ class Purchase(
getattr(line, 'taxes', None) or [],
getattr(line, 'unit_price', None) or Decimal(0),
getattr(line, 'quantity', None) or 0,
None,
tax_date,
))
return taxable_lines

View File

@@ -215,6 +215,7 @@ def register():
lot.LotShippingStart,
lot.LotPlanTransportStart,
lot.LotPlanTransportLine,
lot.LotMarkFinishedStart,
lot.LotMatchingStart,
lot.LotWeighingStart,
lot.LotAddLot,
@@ -330,8 +331,9 @@ def register():
#lot.LotMatchingUnit,
lot.LotWeighing,
lot.CreateContracts,
lot.LotUnmatch,
lot.LotUnship,
lot.LotUnmatch,
lot.LotMarkFinished,
lot.LotUnship,
lot.LotRemove,
lot.LotInvoice,
lot.LotAdding,
@@ -366,5 +368,6 @@ def register():
stock.ShipmentPackingListReport,
stock.ShipmentLinkageReport,
stock.LotReportLinkageReport,
stock.LotReportLinkageFinalReport,
module='purchase_trade', type_='report')

View File

@@ -1074,6 +1074,17 @@ class CoffeeLabAnalysis(Workflow, ModelSQL, ModelView):
class CoffeeLineMixin:
_coffee_attribute_name_fields = [
('coffee_origin', None),
('coffee_type', dict(COFFEE_TYPES)),
('coffee_process', dict(COFFEE_PROCESS_TYPES)),
('coffee_variety', None),
('coffee_crop_year', None),
('coffee_screen_size', None),
('coffee_moisture_max', None),
('coffee_defect_max', None),
('coffee_cup_score_min', None),
]
_coffee_view_fields = {
'coffee_quality_status',
'coffee_origin',
@@ -1111,6 +1122,19 @@ class CoffeeLineMixin:
coffee_cup_score_min = fields.Numeric(
'Cup score min', digits=(5, 2))
coffee_packing_count = fields.Integer('Packages')
coffee_packing_unit_weight = fields.Numeric(
'Package weight', digits=(16, 5))
coffee_packing_weight_unit = fields.Many2One(
'product.uom', 'Package weight unit')
coffee_packing_unit = fields.Many2One(
'product.uom', 'Package unit')
coffee_packing_description = fields.Char('Packing description')
coffee_market_reference = fields.Many2One(
'price.price', 'Market reference')
coffee_market_price = fields.Numeric('Market price', digits=(16, 5))
coffee_market_delta = fields.Numeric('Market delta', digits=(16, 5))
def _coffee_samples(self):
return list(getattr(self, 'coffee_samples', None) or [])
@@ -1120,6 +1144,18 @@ class CoffeeLineMixin:
def on_change_with_active_coffee_compatibility(self, name=None):
return self.get_active_coffee_compatibility(name)
def _coffee_attributes_name(self):
values = []
for field_name, selection in self._coffee_attribute_name_fields:
value = getattr(self, field_name, None)
if value is None or value == '':
continue
if selection:
value = selection.get(value, value)
values.append(str(value))
if values:
return ' | '.join(values)
@classmethod
def fields_view_get(cls, view_id=None, view_type='form', level=None):
result = super().fields_view_get(

View File

@@ -68,6 +68,11 @@ this repository contains the full copyright notices and license terms. -->
<field name="type">tree</field>
<field name="name">coffee_cupping_result_line_tree</field>
</record>
<record model="ir.ui.view" id="coffee_cupping_result_line_view_form">
<field name="model">coffee.cupping.result.line</field>
<field name="type">form</field>
<field name="name">coffee_cupping_result_line_form</field>
</record>
<record model="ir.ui.view" id="coffee_lab_analysis_view_tree">
<field name="model">coffee.lab.analysis</field>
<field name="type">tree</field>

View File

@@ -68,6 +68,16 @@ def default_itsa_unit():
return record_id(unit) if unit else None
def default_itsa_bulk_unit():
unit = _search_first('product.uom', [
[('symbol', '=', 'Bulk')],
[('name', '=', 'Bulk')],
[('symbol', '=', 'bulk')],
[('name', '=', 'bulk')],
])
return record_id(unit) if unit else None
def _company_from_values(values):
company = values.get('company')
if company and hasattr(company, 'party'):

View File

@@ -12,22 +12,42 @@ class PurchaseConfiguration(metaclass=PoolMeta):
allow_modification_after_validation = fields.Boolean(
"Autorise modification after validation")
auto_hedging = fields.Boolean("Auto hedge")
auto_hedging_over = fields.Boolean("Over hedge")
@classmethod
def default_allow_modification_after_validation(cls):
return False
@classmethod
def default_auto_hedging(cls):
return False
@classmethod
def default_auto_hedging_over(cls):
return False
class SaleConfiguration(metaclass=PoolMeta):
__name__ = 'sale.configuration'
allow_modification_after_validation = fields.Boolean(
"Autorise modification after validation")
auto_hedging = fields.Boolean("Auto hedge")
auto_hedging_over = fields.Boolean("Over hedge")
@classmethod
def default_allow_modification_after_validation(cls):
return False
@classmethod
def default_auto_hedging(cls):
return False
@classmethod
def default_auto_hedging_over(cls):
return False
class AccountConfiguration(metaclass=PoolMeta):
__name__ = 'account.configuration'

View File

@@ -0,0 +1,861 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>User Guide - Coffee Features</title>
<style>
:root {
--ink: #17333a;
--muted: #66797d;
--line: #d9e3e1;
--panel: #f6faf9;
--accent: #0d7180;
--accent-soft: #e8f4f3;
--warn: #8a5a00;
--bad: #8a1f17;
--good: #126a38;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Arial, Helvetica, sans-serif;
color: var(--ink);
background: #fff;
line-height: 1.55;
}
header {
padding: 36px 44px 26px;
background: #f1f7f6;
border-bottom: 1px solid var(--line);
}
main {
max-width: 1180px;
padding: 28px 44px 56px;
}
h1 {
margin: 0 0 8px;
font-size: 32px;
line-height: 1.15;
}
h2 {
margin: 34px 0 12px;
padding-bottom: 8px;
font-size: 22px;
border-bottom: 1px solid var(--line);
}
h3 {
margin: 24px 0 8px;
font-size: 17px;
}
h4 {
margin: 18px 0 6px;
font-size: 15px;
}
p { margin: 8px 0 12px; }
ul, ol { margin: 8px 0 16px 24px; padding: 0; }
li { margin: 5px 0; }
table {
width: 100%;
border-collapse: collapse;
margin: 12px 0 20px;
font-size: 14px;
}
th, td {
border: 1px solid var(--line);
padding: 9px 10px;
vertical-align: top;
text-align: left;
}
th {
background: var(--panel);
color: #24484f;
}
code {
padding: 1px 4px;
border-radius: 4px;
background: var(--accent-soft);
color: #064d58;
font-family: Consolas, Monaco, monospace;
font-size: 0.95em;
}
.lead {
max-width: 920px;
color: var(--muted);
font-size: 16px;
}
.toc {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 10px;
margin: 22px 0 4px;
}
.toc a {
display: block;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 6px;
color: var(--accent);
text-decoration: none;
background: #fff;
}
.note, .warning, .success {
margin: 14px 0;
padding: 12px 14px;
border-left: 4px solid var(--accent);
background: var(--panel);
}
.warning {
border-left-color: var(--warn);
background: #fff8e8;
}
.success {
border-left-color: var(--good);
background: #ecf8f1;
}
.badge {
display: inline-block;
margin: 0 4px 4px 0;
padding: 2px 7px;
border-radius: 999px;
background: var(--accent-soft);
color: #075662;
font-size: 12px;
font-weight: bold;
}
.path {
font-weight: bold;
color: #0a5965;
}
.small { color: var(--muted); font-size: 13px; }
@media print {
header, main { padding-left: 22px; padding-right: 22px; }
.toc a { break-inside: avoid; }
}
</style>
</head>
<body>
<header>
<h1>User Guide - Coffee Features</h1>
<p class="lead">
This guide explains how to activate and use the coffee extensions in the CTRM:
coffee specifications on purchase and sale lines, sample tracking, laboratory
analyses, cupping sessions with blind cups, scoring criteria and quality decisions.
</p>
<p class="small">Documented version: purchase_trade coffee implementation, updated 17/07/2026.</p>
</header>
<main>
<nav class="toc" aria-label="Table of contents">
<a href="#overview">1. Functional overview</a>
<a href="#activation">2. Activating coffee mode</a>
<a href="#line">3. Coffee Quality tab on trade lines</a>
<a href="#packing">4. Packing and market data on trade lines</a>
<a href="#samples">5. Coffee samples</a>
<a href="#lab">6. Laboratory analyses</a>
<a href="#cupping">7. Cupping sessions</a>
<a href="#scoring">8. Cupping results and criteria scoring</a>
<a href="#decisions">9. Quality status and recommended decisions</a>
<a href="#routine">10. Recommended daily routine</a>
<a href="#troubleshooting">11. Troubleshooting and common questions</a>
</nav>
<section id="overview">
<h2>1. Functional overview</h2>
<p>
The coffee functionality adds a quality workflow around physical purchase
and sale contracts. It is designed to keep the commercial contract, the
sample lifecycle, lab results and cupping decisions connected without
forcing non-coffee contracts to use any coffee-specific fields.
</p>
<p>The workflow is built around these objects:</p>
<table>
<thead>
<tr>
<th>Object</th>
<th>Purpose</th>
<th>Where users usually access it</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>purchase.line</code> / <code>sale.line</code></td>
<td>Stores the expected coffee profile and target quality thresholds.</td>
<td>Purchase or Sale line, tab <span class="path">Coffee Quality</span>.</td>
</tr>
<tr>
<td><code>coffee.sample</code></td>
<td>Tracks a physical or logical sample linked to a contract line.</td>
<td>From the line tab, or menu <span class="path">Coffee / Samples</span>.</td>
</tr>
<tr>
<td><code>coffee.lab.analysis</code></td>
<td>Tracks a laboratory analysis requested for a sample.</td>
<td>From a sample, or menu <span class="path">Coffee / Lab Analyses</span>.</td>
</tr>
<tr>
<td><code>coffee.cupping.session</code></td>
<td>Groups samples into a tasting session and generates blind cups.</td>
<td>Menu <span class="path">Coffee / Cupping Sessions</span>.</td>
</tr>
<tr>
<td><code>coffee.cupping.result</code></td>
<td>Stores one cupper's evaluation for one cup.</td>
<td>Inside a cup generated by a cupping session.</td>
</tr>
<tr>
<td><code>coffee.cupping.result.line</code></td>
<td>Stores one scored criterion within a cupping result.</td>
<td>Inside a result, in the <span class="path">Criteria</span> list.</td>
</tr>
</tbody>
</table>
<div class="note">
The coffee process is advisory by design. It calculates quality status,
recommended decisions and warnings, but the user remains responsible for
approving, rejecting or requesting another cup.
</div>
</section>
<section id="activation">
<h2>2. Activating coffee mode</h2>
<p>
Coffee features are optional. They are hidden until the trade configuration
enables them.
</p>
<ol>
<li>Open the trade configuration.</li>
<li>Go to the trade options area.</li>
<li>Enable <code>Active coffee compatibility</code>.</li>
<li>Save the configuration.</li>
</ol>
<p>Once enabled, the following menus are available under Purchase and Sale:</p>
<p>
<span class="badge">Coffee / Samples</span>
<span class="badge">Coffee / Lab Analyses</span>
<span class="badge">Coffee / Cupping Sessions</span>
<span class="badge">Coffee / Cupping Criteria</span>
</p>
<div class="warning">
If the option is disabled, coffee menus and the Coffee Quality page on
purchase and sale lines are removed from the UI. Existing data remains in
the database, but it is not shown to users.
</div>
</section>
<section id="line">
<h2>3. Coffee Quality tab on purchase and sale lines</h2>
<p>
The <span class="path">Coffee Quality</span> tab on a trade line stores
the expected quality profile for the coffee and the thresholds used to
evaluate samples.
</p>
<table>
<thead>
<tr>
<th>Area</th>
<th>Main fields</th>
<th>How it is used</th>
</tr>
</thead>
<tbody>
<tr>
<td>Quality summary</td>
<td><code>Coffee quality</code>, <code>Coffee origin</code>, <code>Coffee type</code>, <code>Coffee process</code></td>
<td>Identifies the coffee and displays the aggregated quality state of linked samples.</td>
</tr>
<tr>
<td>Coffee identity</td>
<td><code>Variety</code>, <code>Crop year</code>, <code>Screen size</code></td>
<td>Completes the commercial and physical description of the product.</td>
</tr>
<tr>
<td>Quality targets</td>
<td><code>Moisture max %</code>, <code>Defects max</code>, <code>Cup score min</code></td>
<td>Defines the thresholds used by lab analysis and sample quality evaluation.</td>
</tr>
<tr>
<td>Coffee samples</td>
<td>One2Many list of linked samples</td>
<td>Creates or reviews all samples linked to the trade line.</td>
</tr>
</tbody>
</table>
<p>
The line's <code>Attributes Name</code> can display the coffee attributes
in a compact way, for example origin, type, process, variety, crop,
screen, moisture, defects and cup score.
</p>
</section>
<section id="packing">
<h2>4. Packing and market data on trade lines</h2>
<p>
The trade line also contains coffee operational fields in the general tab.
These fields are not part of the sample workflow, but they are useful for
physical quantity tracking and market hedging.
</p>
<table>
<thead>
<tr>
<th>Field</th>
<th>Meaning</th>
<th>Operational effect</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Packages</code></td>
<td>Number of bags, sacks or packages.</td>
<td>Copied to the virtual lot as <code>lot_qt</code> when the line is validated.</td>
</tr>
<tr>
<td><code>Package unit</code></td>
<td>UoM used to describe the package, for example Bag60 or Bag90.</td>
<td>Copied to the virtual lot as <code>lot_unit</code>.</td>
</tr>
<tr>
<td><code>Packing description</code></td>
<td>Free text used for readable contract or logistics descriptions.</td>
<td>Informational only.</td>
</tr>
<tr>
<td><code>Market reference</code></td>
<td>Price curve used as the market reference.</td>
<td>Used by pricing, valuation and auto-hedging rules.</td>
</tr>
<tr>
<td><code>Market price</code> / <code>Market delta</code></td>
<td>Market entry data used for pricing or hedge generation.</td>
<td>May be copied to a generated derivative entry price.</td>
</tr>
</tbody>
</table>
<div class="note">
The default virtual lot is created when the line is validated. The package
values are synchronized after creation and are also refreshed if the
packing values are later changed and the line is saved again.
</div>
</section>
<section id="samples">
<h2>5. Coffee samples</h2>
<p>
A coffee sample can be created from the <span class="path">Coffee Quality</span>
tab of a purchase or sale line, or from the menu
<span class="path">Coffee / Samples</span>. Creating it from a line is
preferred because the direction and line link are filled automatically.
</p>
<h3>Sample identity</h3>
<table>
<thead>
<tr>
<th>Field</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Reference</code></td>
<td>Internal or external sample reference.</td>
</tr>
<tr>
<td><code>Direction</code></td>
<td>Purchase or Sale. It is inferred when the sample is created from a trade line.</td>
</tr>
<tr>
<td><code>Sample Type</code></td>
<td>Offer, Pre-shipment, Shipment, Arrival, Stock, Customer or Retained sample.</td>
</tr>
<tr>
<td><code>Purchase Line</code> / <code>Sale Line</code></td>
<td>The contract line the sample belongs to.</td>
</tr>
<tr>
<td><code>Lot</code></td>
<td>Optional physical or virtual lot reference.</td>
</tr>
<tr>
<td><code>Product</code>, <code>Party</code>, <code>Origin</code>, <code>Coffee type</code></td>
<td>Computed from the linked trade line and used for sample review and cupping.</td>
</tr>
</tbody>
</table>
<h3>Storage and lifecycle</h3>
<table>
<thead>
<tr>
<th>Field</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Requested Date</code></td>
<td>Defaults to today when a sample is created.</td>
</tr>
<tr>
<td><code>Received Date</code></td>
<td>Filled when the sample is received, or manually entered if needed.</td>
</tr>
<tr>
<td><code>Shelf life (days)</code></td>
<td>Defaults to 90 days.</td>
</tr>
<tr>
<td><code>Expiry Date</code></td>
<td>Calculated from requested or received date plus shelf life when not manually set.</td>
</tr>
<tr>
<td><code>Quantity</code>, <code>Unit</code>, <code>Sample location</code></td>
<td>Used to track the physical sample stored in the office, lab or warehouse.</td>
</tr>
</tbody>
</table>
<h3>Sample workflow</h3>
<p>
Sample buttons are displayed depending on the current state. The typical
workflow is:
</p>
<p>
<span class="badge">Requested</span>
<span class="badge">Received</span>
<span class="badge">Sent to lab</span>
<span class="badge">Under review</span>
<span class="badge">Approved / Rejected</span>
<span class="badge">Expired</span>
<span class="badge">Archived</span>
</p>
<table>
<thead>
<tr>
<th>Button</th>
<th>Available from</th>
<th>Effect</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Receive</code></td>
<td>Requested</td>
<td>Moves the sample to received and fills <code>Received Date</code> if it is empty.</td>
</tr>
<tr>
<td><code>Send to lab</code></td>
<td>Received</td>
<td>Moves the sample to sent to lab and creates a lab analysis if a lab is selected.</td>
</tr>
<tr>
<td><code>Review</code></td>
<td>Received or sent to lab</td>
<td>Moves the sample to quality review.</td>
</tr>
<tr>
<td><code>Evaluate quality</code></td>
<td>Received, sent to lab or under review</td>
<td>Computes the recommended decision from lab and cupping data.</td>
</tr>
<tr>
<td><code>Approve</code> / <code>Reject</code></td>
<td>Under review, or other reviewable states depending on the button visibility</td>
<td>Records the final decision and decision date.</td>
</tr>
<tr>
<td><code>Expire</code></td>
<td>Active states</td>
<td>Forces the sample into expired state.</td>
</tr>
<tr>
<td><code>Archive</code></td>
<td>Approved, rejected or expired</td>
<td>Moves the sample out of active follow-up.</td>
</tr>
</tbody>
</table>
<div class="warning">
A sample is considered "expiring soon" when its expiry date is within the
next 14 days. Expired samples continue to affect the line's coffee quality
status until they are handled.
</div>
</section>
<section id="lab">
<h2>6. Laboratory analyses</h2>
<p>
A lab analysis records the external or internal laboratory control of a
sample. It can be created from a sample or from the global lab analyses
menu.
</p>
<h3>Creating a lab analysis from a sample</h3>
<ol>
<li>Open the sample.</li>
<li>Enable <code>Delegated to lab</code> if needed.</li>
<li>Select a <code>Lab</code> and optionally a <code>Lab Due Date</code>.</li>
<li>Click <code>Send to lab</code>.</li>
</ol>
<p>
The system creates a new active lab analysis only if the sample does not
already have an active one.
</p>
<h3>Lab analysis workflow</h3>
<table>
<thead>
<tr>
<th>State</th>
<th>Meaning</th>
<th>Common action</th>
</tr>
</thead>
<tbody>
<tr>
<td>Draft</td>
<td>The request is being prepared.</td>
<td>Fill lab, due date and requested date.</td>
</tr>
<tr>
<td>Sent</td>
<td>The sample has been sent to the lab.</td>
<td>Wait for the report.</td>
</tr>
<tr>
<td>Received</td>
<td>The lab report has been received.</td>
<td>Enter moisture, defects, cup score and summary.</td>
</tr>
<tr>
<td>Accepted</td>
<td>The lab result is accepted.</td>
<td>Use it for sample evaluation.</td>
</tr>
<tr>
<td>Rejected</td>
<td>The lab result is rejected or invalid.</td>
<td>Request another analysis if needed.</td>
</tr>
</tbody>
</table>
<p>
The computed field <code>Compliant</code> compares the lab values against
the target thresholds on the linked purchase or sale line:
</p>
<ul>
<li><code>Moisture %</code> must be less than or equal to <code>Moisture max %</code>.</li>
<li><code>Defects count</code> must be less than or equal to <code>Defects max</code>.</li>
<li><code>Cup score</code> must be greater than or equal to <code>Cup score min</code>.</li>
</ul>
</section>
<section id="cupping">
<h2>7. Cupping sessions</h2>
<p>
A cupping session is a tasting event. It groups samples, prepares blind
cups and lets cuppers enter one or more results per cup.
</p>
<h3>Before creating a session: define criteria</h3>
<p>
Go to <span class="path">Coffee / Cupping Criteria</span> and create the
scoring criteria used by cuppers. Each criterion can have:
</p>
<ul>
<li>a sequence used for display order;</li>
<li>a name;</li>
<li>a criterion type, if used by your local process;</li>
<li>a maximum score;</li>
<li>a flag telling whether the score must be subtracted from the total.</li>
</ul>
<p>
Subtractive criteria are useful for defects or penalties. When a result
total is calculated, normal criteria add to the total and subtractive
criteria reduce it.
</p>
<h3>Creating the session</h3>
<ol>
<li>Open <span class="path">Coffee / Cupping Sessions</span>.</li>
<li>Create a new session.</li>
<li>Fill <code>Name</code>, <code>Date</code>, <code>Responsible</code> and <code>Location</code>.</li>
<li>Check <code>Cup capacity</code>. The default is 24.</li>
<li>Check <code>Cups per sample</code>. The default is 3.</li>
<li>Add samples to the <code>Samples</code> list.</li>
</ol>
<p>
A session line is a link between one session and one sample. The line
also stores the sample's cupping decision: pending, approved, re-cup or
rejected.
</p>
<h3>Adding a sample from a sample record</h3>
<p>
You can also open a coffee sample and go to the <span class="path">Cupping sessions</span>
tab. When you add a row there, the sample is already known, but you must
choose the session. This creates the same session/sample link as adding
the sample from the cupping session screen.
</p>
<h3>The session workflow</h3>
<table>
<thead>
<tr>
<th>State</th>
<th>Button</th>
<th>What happens</th>
</tr>
</thead>
<tbody>
<tr>
<td>Draft</td>
<td><code>Prepare</code></td>
<td>Checks capacity, sorts samples and creates missing cups with blind codes.</td>
</tr>
<tr>
<td>Prepared</td>
<td><code>Start</code></td>
<td>Moves the session to in progress. Cuppers can enter results.</td>
</tr>
<tr>
<td>In progress</td>
<td><code>Done</code></td>
<td>Marks the tasting session as completed.</td>
</tr>
<tr>
<td>Draft, Prepared or In progress</td>
<td><code>Cancel</code></td>
<td>Cancels the session.</td>
</tr>
<tr>
<td>Cancelled</td>
<td><code>Draft</code></td>
<td>Reopens the session in draft state.</td>
</tr>
</tbody>
</table>
<h3>What Prepare does</h3>
<p>
<code>Prepare</code> is the important operational step. The system:
</p>
<ul>
<li>calculates the requested number of cups from each session line;</li>
<li>blocks the preparation if the total exceeds <code>Cup capacity</code>;</li>
<li>sorts samples by coffee type, origin, reference and id;</li>
<li>assigns the sequence on each session/sample line;</li>
<li>creates any missing cups;</li>
<li>generates blind codes such as <code>S01-C1</code>, <code>S01-C2</code>, <code>S02-C1</code>.</li>
</ul>
<p>
The default tasting order proposes arabicas first, blends and other
coffees next, and robustas at the end.
</p>
<div class="note">
If you change the number of cups per sample after preparation, running
Prepare again creates only the missing cups. Existing cups are not deleted.
</div>
<h3>Blind information</h3>
<p>
Cuppers see operational tasting information such as origin and coffee type.
They do not need to see the full purchase/sale contract, supplier or
customer information during the tasting.
</p>
</section>
<section id="scoring">
<h2>8. Cupping results and criteria scoring</h2>
<p>
Once cups exist, users enter tasting results inside each cup.
</p>
<h3>Data hierarchy</h3>
<ol>
<li>A <code>Cupping Session</code> contains session/sample lines.</li>
<li>Each session/sample line contains generated <code>Cups</code>.</li>
<li>Each cup can contain one or more <code>Results</code>.</li>
<li>Each result contains scored <code>Criteria</code> lines.</li>
</ol>
<h3>Entering a result</h3>
<ol>
<li>Open a cupping session.</li>
<li>Open the relevant sample line.</li>
<li>Open the relevant cup.</li>
<li>Add a result.</li>
<li>Select the <code>Cupper</code>.</li>
<li>Enter the date if it differs from today.</li>
<li>Add criteria lines and enter scores.</li>
<li>Add notes if needed.</li>
</ol>
<p>
The field <code>Total score</code> is computed from the result lines. It
is empty until at least one criterion has a score.
</p>
<h3>Criteria lines</h3>
<table>
<thead>
<tr>
<th>Field</th>
<th>Meaning</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Criterion</code></td>
<td>The scoring criterion configured in <span class="path">Coffee / Cupping Criteria</span>.</td>
</tr>
<tr>
<td><code>Score</code></td>
<td>The numeric score entered by the cupper.</td>
</tr>
<tr>
<td><code>Defect found</code></td>
<td>Marks the criterion as identifying a defect.</td>
</tr>
<tr>
<td><code>Notes</code></td>
<td>Free tasting notes for this criterion.</td>
</tr>
</tbody>
</table>
<h3>Score consolidation</h3>
<p>
Scores are consolidated upwards:
</p>
<ul>
<li>Result total = sum of its criteria, with subtractive criteria deducted.</li>
<li>Cup average = average of result totals entered for that cup.</li>
<li>Session/sample average = average of all result totals across the line's cups.</li>
<li>Sample cupping average = average of the cupping results linked to the sample.</li>
</ul>
<div class="warning">
A criteria line requires a criterion. If the Add button appears to do
nothing, check that the Criteria list is configured with both tree and
form views and that cupping criteria exist.
</div>
</section>
<section id="decisions">
<h2>9. Quality status and recommended decisions</h2>
<p>
Quality status is computed from direct sample values, lab analysis values
and cupping information. The system compares available values with the
thresholds stored on the purchase or sale line.
</p>
<table>
<thead>
<tr>
<th>Indicator</th>
<th>Calculation / logic</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Quality status</code></td>
<td>Pass, warning, fail or pending depending on available checks and cupping decisions.</td>
</tr>
<tr>
<td><code>Recommended decision</code></td>
<td>Pass recommends approve, fail recommends reject, warning recommends re-cup, otherwise pending.</td>
</tr>
<tr>
<td><code>Lab analysis status</code></td>
<td>Shows the status of the latest active lab analysis.</td>
</tr>
<tr>
<td><code>Cupping score</code></td>
<td>Uses the average cupping result when available.</td>
</tr>
</tbody>
</table>
<p>
Cupping session line decisions have a direct impact:
</p>
<ul>
<li>If a linked cupping line is marked <code>rejected</code>, the sample quality can fail.</li>
<li>If a linked cupping line is marked <code>re_cup</code>, the sample quality can become warning.</li>
<li>If the values are compliant and no warning/rejection exists, the recommended decision can be approve.</li>
</ul>
<p>
On purchase and sale lines, the computed coffee quality summarizes all
linked samples. Rejected, expired or warning samples are therefore visible
directly on the contract line.
</p>
</section>
<section id="routine">
<h2>10. Recommended daily routine</h2>
<ol>
<li>Review <span class="path">Coffee / Samples / Expiring soon</span>.</li>
<li>Review <span class="path">Coffee / Samples / Pending</span>.</li>
<li>Check lab analyses waiting for reports.</li>
<li>Create or prepare upcoming cupping sessions.</li>
<li>After cupping, enter result criteria and session/sample decisions.</li>
<li>After lab or cupping data is entered, use <code>Evaluate quality</code> on the sample.</li>
<li>Approve, reject or request a re-cup according to the quality process.</li>
<li>Archive completed samples when they no longer need active follow-up.</li>
</ol>
<div class="success">
A simple full flow is: create sample from the trade line, receive it,
send it to the lab and/or add it to a cupping session, enter results,
evaluate quality, then approve or reject.
</div>
</section>
<section id="troubleshooting">
<h2>11. Troubleshooting and common questions</h2>
<h3>The Coffee Quality tab is not visible</h3>
<p>
Check that <code>Active coffee compatibility</code> is enabled in trade
configuration and that the view has been reloaded after saving.
</p>
<h3>The Add button does nothing in a One2Many list</h3>
<p>
This usually means that the child record has another required parent
field that is not known from the current screen. Examples:
</p>
<ul>
<li>From a sample, adding a cupping session line requires selecting the <code>Session</code>.</li>
<li>From a result, adding a criteria line requires selecting the <code>Criterion</code>.</li>
</ul>
<p>
The current views provide form popups for these cases. If the popup does
not appear, reload the client after updating the module views.
</p>
<h3>No cups are created</h3>
<p>
Cups are created by the <code>Prepare</code> button on the cupping
session. Check that the session has sample lines and that the requested
number of cups does not exceed the session capacity.
</p>
<h3>A sample remains pending</h3>
<p>
The system can only recommend a decision when it has enough data to
compare against the trade line thresholds. Check moisture, defects,
cupping score and lab analysis status.
</p>
<h3>The result total does not match the simple sum</h3>
<p>
Some criteria may be configured as subtractive. In that case their score
is deducted from the total instead of added.
</p>
<h3>Prepare blocks the session</h3>
<p>
The total requested cups are above <code>Cup capacity</code>. Increase
capacity, reduce <code>Cups per sample</code>, or remove some samples.
</p>
</section>
</main>
</body>
</html>

View File

@@ -290,21 +290,35 @@ class Fee(ModelSQL,ModelView):
else:
return self.unit
def get_lots(self, name):
logger.info("GET_LOTS_LINE:%s",self.line)
logger.info("GET_LOTS_SHIPMENT_IN:%s",self.shipment_in)
Lot = Pool().get('lot.lot')
if self.line:
return self.line.lots
if self.shipment_in:
lots = Lot.search([('lot_shipment_in','=',self.shipment_in.id)])
logger.info("LOTSDOMAIN:%s",lots)
if lots:
return lots + [lots[0].getVlot_p()]
if self.shipment_internal:
return Lot.search([('lot_shipment_internal','=',self.shipment_internal.id)])
if self.shipment_out:
return Lot.search([('lot_shipment_out','=',self.shipment_out.id)])
def get_lots(self, name):
logger.info("GET_LOTS_LINE:%s",self.line)
logger.info("GET_LOTS_SHIPMENT_IN:%s",self.shipment_in)
Lot = Pool().get('lot.lot')
if self.line:
return self.line.lots
if self.shipment_in:
LotQt = Pool().get('lot.qt')
lots = Lot.search([('lot_shipment_in','=',self.shipment_in.id)])
logger.info("LOTSDOMAIN:%s",lots)
domain_lots = []
seen = set()
for lot in lots:
lot_id = getattr(lot, 'id', None)
if lot_id not in seen:
domain_lots.append(lot)
seen.add(lot_id)
for lqt in LotQt.search(['lot_shipment_in','=',self.shipment_in.id]):
lot = getattr(lqt, 'lot_p', None)
lot_id = getattr(lot, 'id', None)
if lot and lot_id not in seen:
domain_lots.append(lot)
seen.add(lot_id)
if domain_lots:
return domain_lots
if self.shipment_internal:
return Lot.search([('lot_shipment_internal','=',self.shipment_internal.id)])
if self.shipment_out:
return Lot.search([('lot_shipment_out','=',self.shipment_out.id)])
return Lot.search(['id','>',0])
@@ -771,6 +785,14 @@ class Fee(ModelSQL,ModelView):
quantity = self.get_quantity()
return Decimal(quantity or 0)
def _get_per_quantity_amount_quantity(self):
quantity = self._get_effective_fee_lots_quantity()
if quantity is None:
quantity = self.quantity
if quantity is None:
quantity = self.get_quantity()
return Decimal(quantity or 0)
@staticmethod
def _unsigned_amount(amount):
if amount is None:
@@ -821,18 +843,9 @@ class Fee(ModelSQL,ModelView):
if self.add_padding:
return self._unsigned_amount(round(
self.get_billing_quantity() * self.price * sign, 2))
if self.shipment_in:
StockMove = Pool().get('stock.move')
sm = StockMove.search(['shipment','=','stock.shipment.in,'+str(self.shipment_in.id)])
if sm:
unique_lots = {e.lot for e in sm if e.lot}
return self._unsigned_amount(round(self.price * Decimal(sum([e.get_current_quantity_converted(0,self.unit) for e in unique_lots])) * sign,2))
LotQt = Pool().get('lot.qt')
lqts = LotQt.search(['lot_shipment_in','=',self.shipment_in.id])
if lqts:
return self._unsigned_amount(round(self.price * Decimal(lqts[0].lot_quantity) * sign,2))
return self._unsigned_amount(round((self.quantity if self.quantity else 0) * self.price * sign,2))
return self._unsigned_amount(round(
self._get_per_quantity_amount_quantity()
* self.price * sign, 2))
elif self.mode == 'pprice':
if self.add_padding:
return self._unsigned_amount(round(

View File

@@ -23,7 +23,7 @@ import logging
from trytond.exceptions import UserWarning, UserError
from trytond.modules.purchase_trade.service import ContractFactory
from trytond.modules.purchase_trade.company_defaults import (
default_itsa_unit, is_itsa_company)
default_itsa_bulk_unit, default_itsa_unit, is_itsa_company)
logger = logging.getLogger(__name__)
@@ -911,10 +911,9 @@ class Lot(metaclass=PoolMeta):
virtual_lots = [l for l in lots if l.lot_type == 'virtual']
total_physic = sum(l.get_current_quantity_converted() for l in physical_lots)
theorical = line.quantity_theorical if hasattr(line, 'quantity_theorical') else line.quantity
virtual_qty = round(theorical - total_physic, 5)
logger.info("RECOMPUTE_VIRTUAL_LOT:%s",virtual_qty)
if virtual_lots:
theorical = line.quantity_theorical if hasattr(line, 'quantity_theorical') else line.quantity
virtual_qty = round(theorical - total_physic, 5)
if virtual_lots:
vlot = virtual_lots[0]
lh = vlot.lot_hist[0]
lh.quantity = virtual_qty
@@ -1014,9 +1013,8 @@ class Lot(metaclass=PoolMeta):
else:
lqt.lot_s = lot_s
lqt.lot_p = None
if sh:
logger.info("IDSHIP:%s",sh)
str_sh = str(sh)
if sh:
str_sh = str(sh)
index = str_sh.rfind(",")
if index != -1:
id = int(str_sh[index+1:].strip())
@@ -1059,10 +1057,8 @@ class Lot(metaclass=PoolMeta):
lqts = LotQt.search([('lot_p','=',vlot),('lot_shipment_out','=',id),('lot_s','=',lot_s)])
else:
lqts = LotQt.search([('lot_p','=',vlot),('lot_s','=',lot_s),('lot_shipment_in','=',None),('lot_shipment_internal','=',None),('lot_shipment_out','=',None)])
if lqts:
logger.info("UVP_QT:%s",qt)
logger.info("UVP_LQ:%s",lqts[0].lot_quantity)
lqts[0].lot_quantity += qt
if lqts:
lqts[0].lot_quantity += qt
if lqts[0].lot_quantity < 0:
lqts[0].lot_quantity = 0
LotQt.save(lqts)
@@ -1199,8 +1195,7 @@ class Lot(metaclass=PoolMeta):
line = SL(line_id)
qt = Decimal(0)
lots = list(line.lots or [])
logger.info("DELETE_RECALC:%s",lots)
for lot in lots:
for lot in lots:
if len(lots) > 1:
if lot.lot_type == 'physic':
qt += lot.get_current_quantity_converted()
@@ -1694,11 +1689,51 @@ class LotQt(
affected_lines.append(lqt.lot_s.sale_line)
return affected_lines
@classmethod
def unmatch_lotqt(cls, lqt):
if not lqt or not lqt.lot_p or not lqt.lot_s:
return []
lot = lqt.lot_p
qt = lqt.lot_quantity
if not lot.updateVirtualPart(qt, lqt.lot_shipment_origin, None):
lot.createVirtualPart(qt, lqt.lot_shipment_origin, None)
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')
lot.updateVirtualPart(-qt, lqt.lot_shipment_origin, lqt.lot_s)
return cls._affected_lines_for_lqt(lqt)
@classmethod
def mark_lines_finished_from_lotqt(cls, lqt, mark_purchase=False,
mark_sale=False, unmatch=False):
if not mark_purchase and not mark_sale:
return []
affected_lines = []
if mark_purchase and lqt.lot_p and lqt.lot_p.line:
purchase_line = lqt.lot_p.line
purchase_line.finished = True
Pool().get('purchase.line').save([purchase_line])
affected_lines.append(purchase_line)
if mark_sale and lqt.lot_s and lqt.lot_s.sale_line:
sale_line = lqt.lot_s.sale_line
sale_line.finished = True
Pool().get('sale.line').save([sale_line])
affected_lines.append(sale_line)
if unmatch:
if not lqt.lot_p or not lqt.lot_s:
raise UserError(
"Mark as finished is only available on matched virtual "
"lines.")
affected_lines.extend(cls.unmatch_lotqt(lqt))
return affected_lines
def isVirtualP(self):
if self.lot_p:
Lot = Pool().get('lot.lot')
l = Lot(self.lot_p)
return (l.lot_type == 'virtual')
return (l.lot_type == 'virtual')
return False
def isVirtualS(self):
@@ -1996,6 +2031,9 @@ class LotQt(
@classmethod
def _warn_physical_lot_tolerance(cls, lqt, vlots):
if Transaction().context.get(
'_purchase_trade_skip_physical_lot_tolerance_warning'):
return
Warning = Pool().get('res.user.warning')
for line in cls._affected_lines_for_lqt(lqt):
theoretical = cls._line_theoretical_quantity(line)
@@ -3441,7 +3479,7 @@ class LotShipping(Wizard):
if self.ship.shipment != 'in':
raise UserError("Shipment creation is only available for Shipment In.")
if not self.ship.shipment_supplier:
raise UserError("Shipment supplier is required.")
raise UserError("Broker is required.")
values = {
'supplier': self._record_id(self.ship.shipment_supplier),
}
@@ -3449,6 +3487,9 @@ class LotShipping(Wizard):
values['from_location'] = self.ship.planned_from_location.id
if self.ship.planned_to_location:
values['to_location'] = self.ship.planned_to_location.id
vessel = getattr(self.ship, 'shipment_vessel', None)
if vessel:
values['vessel'] = vessel.id
bl_number = getattr(self.ship, 'shipment_bl_number', None)
bl_date = getattr(self.ship, 'shipment_bl_date', None)
if bl_number:
@@ -3565,12 +3606,16 @@ class LotShippingStart(ModelView):
"Create a new shipment",
states={'invisible': Eval('shipment') != 'in'})
shipment_supplier = fields.Many2One(
'party.party', "Shipment supplier",
'party.party', "Broker",
states={
'invisible': ~Bool(Eval('create_new_shipment')),
'required': Bool(Eval('create_new_shipment')),
},
depends=['create_new_shipment'])
shipment_vessel = fields.Many2One(
'trade.vessel', "Vessel",
states={'invisible': ~Bool(Eval('create_new_shipment'))},
depends=['create_new_shipment'])
planned_from_location = fields.Many2One(
'stock.location', "Planned From", readonly=True)
planned_to_location = fields.Many2One(
@@ -4163,9 +4208,9 @@ class LotMatchingLot(ModelView):
return None
class LotUnmatch(Wizard):
"Unmatch"
__name__ = "lot.unmatch"
class LotUnmatch(Wizard):
"Unmatch"
__name__ = "lot.unmatch"
start = StateTransition()
@@ -4178,20 +4223,7 @@ class LotUnmatch(Wizard):
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
#Increase forecasted virtual parts non matched
if not lot.updateVirtualPart(qt,lqt.lot_shipment_origin,None):
lot.createVirtualPart(qt,lqt.lot_shipment_origin,None)
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)
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)
affected_lines.extend(LotQt.unmatch_lotqt(lqt))
else:
lot = Lot(r.id)
qt = lot.get_current_quantity_converted()
@@ -4213,12 +4245,65 @@ class LotUnmatch(Wizard):
Lot.assert_lines_quantity_consistency(affected_lines)
return 'end'
def end(self):
return 'reload'
def end(self):
return 'reload'
class LotMarkFinished(Wizard):
"Mark as finished"
__name__ = "lot.mark_finished"
start = StateView(
'lot.mark_finished.start',
'purchase_trade.lot_mark_finished_start_view_form', [
Button("Cancel", 'end', 'tryton-cancel'),
Button("Apply", 'marking', 'tryton-ok', default=True),
])
marking = StateTransition()
def transition_marking(self):
if not (self.start.mark_purchase_line_finished
or self.start.mark_sale_line_finished):
raise UserError("Select at least one line side to mark.")
Lot = Pool().get('lot.lot')
LotQt = Pool().get('lot.qt')
affected_lines = []
with Lot.skip_quantity_consistency():
for record in self.records:
if record.id <= 10000000:
raise UserError(
"Mark as finished is only available on matched "
"virtual lines.")
lqt = LotQt(record.id - 10000000)
if not lqt.lot_p or not lqt.lot_s:
raise UserError(
"Mark as finished is only available on matched "
"virtual lines.")
affected_lines.extend(LotQt.mark_lines_finished_from_lotqt(
lqt,
self.start.mark_purchase_line_finished,
self.start.mark_sale_line_finished,
unmatch=True))
Lot.assert_lines_quantity_consistency(affected_lines)
return 'end'
def end(self):
return 'reload'
class LotMarkFinishedStart(ModelView):
"Mark as finished"
__name__ = "lot.mark_finished.start"
mark_purchase_line_finished = fields.Boolean(
"Mark purchase line as finished")
mark_sale_line_finished = fields.Boolean(
"Mark sale line as finished")
class LotUnship(Wizard):
"Unlink transport"
__name__ = "lot.unship"
__name__ = "lot.unship"
start = StateTransition()
@@ -4392,6 +4477,10 @@ class LotAdding(Wizard):
'stock.shipment.in,%s' % shipment.id)
LotQt.add_physical_lots(
lqt, self.add.lots, self.add.unlink_remainder)
LotQt.mark_lines_finished_from_lotqt(
lqt,
getattr(self.add, 'mark_purchase_line_finished', False),
getattr(self.add, 'mark_sale_line_finished', False))
return 'end'
def end(self):
@@ -4403,6 +4492,10 @@ class LotAddLot(ModelView):
unit = fields.Many2One('product.uom',"Unit",readonly=True)
quantity = fields.Numeric("Quantity available",digits='unit',readonly=True)
unlink_remainder = fields.Boolean("Unlink remaining quantity")
mark_purchase_line_finished = fields.Boolean(
"Mark purchase line as finished")
mark_sale_line_finished = fields.Boolean(
"Mark sale line as finished")
transport_missing = fields.Boolean(
"Transport missing", readonly=True,
states={'invisible': ~Eval('transport_missing')})
@@ -4445,6 +4538,14 @@ class LotAddLot(ModelView):
def default_unlink_remainder(cls):
return True
@classmethod
def default_mark_purchase_line_finished(cls):
return False
@classmethod
def default_mark_sale_line_finished(cls):
return False
@classmethod
def default_create_new_shipment(cls):
return False
@@ -4462,18 +4563,30 @@ class LotAddLine(ModelView):
lot_premium = fields.Numeric("Premium")
lot_chunk_key = fields.Integer("Chunk key")
@classmethod
def default_lot_qt(cls):
if is_itsa_company():
return Decimal('1')
@classmethod
def default_lot_unit(cls):
if is_itsa_company():
return default_itsa_unit()
return default_itsa_bulk_unit()
@classmethod
def default_lot_unit_line(cls):
if is_itsa_company():
return default_itsa_unit()
# @fields.depends('lot_qt')
# def on_change_with_lot_quantity(self):
@fields.depends('lot_quantity', 'lot_gross_quantity')
def on_change_lot_quantity(self):
if (is_itsa_company()
and self.lot_quantity
and not self.lot_gross_quantity):
self.lot_gross_quantity = self.lot_quantity
# @fields.depends('lot_qt')
# def on_change_with_lot_quantity(self):
# if not self.lot_quantity:
# return self.lot_qt

View File

@@ -245,7 +245,12 @@ this repository contains the full copyright notices and license terms. -->
<field name="type">tree</field>
<field name="name">lot_weighing_lot_tree</field>
</record>
<record model="ir.action.wizard" id="act_weighing">
<record model="ir.ui.view" id="lot_mark_finished_start_view_form">
<field name="model">lot.mark_finished.start</field>
<field name="type">form</field>
<field name="name">lot_mark_finished_start_form</field>
</record>
<record model="ir.action.wizard" id="act_weighing">
<field name="name">📦 Do weighing</field>
<field name="wiz_name">lot.weighing</field>
<field name="model">lot.report</field>
@@ -389,7 +394,18 @@ this repository contains the full copyright notices and license terms. -->
<field name="action" ref="act_unmatch"/>
</record>
<record model="ir.action.wizard" id="act_unship">
<record model="ir.action.wizard" id="act_mark_finished">
<field name="name">Mark as finished</field>
<field name="wiz_name">lot.mark_finished</field>
<field name="model">lot.report</field>
</record>
<record model="ir.action.keyword" id="act_mark_finished_keyword">
<field name="keyword">form_action</field>
<field name="model">lot.report,-1</field>
<field name="action" ref="act_mark_finished"/>
</record>
<record model="ir.action.wizard" id="act_unship">
<field name="name">🚢 Unlink transport</field>
<field name="wiz_name">lot.unship</field>
<field name="model">lot.report</field>

View File

@@ -9,7 +9,7 @@ from trytond.pyson import Bool, Eval, Id, If, PYSONEncoder
from trytond.model import (ModelSQL, ModelView)
from trytond.tools import (cursor_dict, is_full_text, lstrip_wildcard)
from trytond.transaction import Transaction, inactive_records
from decimal import getcontext, Decimal, ROUND_HALF_UP
from decimal import getcontext, Decimal, ROUND_CEILING, ROUND_FLOOR, ROUND_HALF_UP
from sql.aggregate import Count, Max, Min, Sum, Avg, BoolOr
from sql.conditionals import Case, Coalesce
from sql import Column, Literal
@@ -2448,14 +2448,22 @@ class Line(metaclass=PoolMeta):
@fields.depends('product')
def on_change_with_attribute_set(self, name=None):
if self.product and self.product.template and self.product.template.attribute_set:
return self.product.template.attribute_set.id
@fields.depends('product', 'attributes')
def on_change_with_attributes_name(self, name=None):
if not self.product or not self.product.attribute_set or not self.attributes:
return
if self.product and self.product.template and self.product.template.attribute_set:
return self.product.template.attribute_set.id
@fields.depends(
'product', 'attributes',
'coffee_origin', 'coffee_type', 'coffee_process', 'coffee_variety',
'coffee_crop_year', 'coffee_screen_size', 'coffee_moisture_max',
'coffee_defect_max', 'coffee_cup_score_min')
def on_change_with_attributes_name(self, name=None):
coffee_attributes_name = getattr(
self, '_coffee_attributes_name', lambda: None)()
if coffee_attributes_name:
return coffee_attributes_name
if not self.product or not self.product.attribute_set or not self.attributes:
return
def key(attribute):
return getattr(attribute, 'sequence', attribute.name)
@@ -2874,6 +2882,81 @@ class Line(metaclass=PoolMeta):
def _should_regenerate_valuation(cls, values):
return bool(cls._valuation_regeneration_fields() & set(values))
@classmethod
def _sync_virtual_lot_packing(cls, line):
Lot = Pool().get('lot.lot')
if not getattr(line, 'lots', None):
line = cls(line.id)
virtual_lots = [
lot for lot in (line.lots or [])
if getattr(lot, 'lot_type', None) == 'virtual']
if not virtual_lots:
return
lot = virtual_lots[0]
packing_count = getattr(line, 'coffee_packing_count', None)
packing_unit = getattr(line, 'coffee_packing_unit', None)
lot_unit_id = getattr(lot.lot_unit, 'id', lot.lot_unit)
packing_unit_id = getattr(packing_unit, 'id', packing_unit)
if lot.lot_qt == packing_count and lot_unit_id == packing_unit_id:
return
lot.lot_qt = packing_count
lot.lot_unit = packing_unit
Lot.save([lot])
@classmethod
def _auto_hedge_configuration(cls):
Configuration = Pool().get('purchase.configuration')
configurations = Configuration.search([], limit=1)
if configurations:
return configurations[0]
@classmethod
def _auto_hedge_contract_count(cls, line, over_hedge=False):
price_index = getattr(line, 'coffee_market_reference', None)
if not price_index or not getattr(line, 'unit', None):
return 0
quantity = Decimal(str(
getattr(line, 'quantity_theorical', None)
or getattr(line, 'quantity', None)
or 0))
if quantity <= 0:
return 0
contract_quantity = Decimal(str(price_index.get_qt(1, line.unit) or 0))
if contract_quantity <= 0:
return 0
rounding = ROUND_CEILING if over_hedge else ROUND_FLOOR
return int((quantity / contract_quantity).to_integral_value(
rounding=rounding))
@classmethod
def _ensure_auto_hedge_derivative(cls, line):
config = cls._auto_hedge_configuration()
if not config or not getattr(config, 'auto_hedging', False):
return
if getattr(line, 'derivatives', None):
return
price_index = getattr(line, 'coffee_market_reference', None)
nb_ct = cls._auto_hedge_contract_count(
line, over_hedge=getattr(config, 'auto_hedging_over', False))
if not price_index or nb_ct <= 0:
return
Derivative = Pool().get('derivative.derivative')
Date = Pool().get('ir.date')
quantity = price_index.get_qt(nb_ct, line.unit)
Derivative.create([{
'purchase': line.purchase.id if line.purchase else None,
'line': line.id,
'product': line.product.id if line.product else None,
'party': line.purchase.party.id
if line.purchase and line.purchase.party else None,
'price_index': price_index.id,
'nb_ct': nb_ct,
'price': getattr(line, 'coffee_market_price', None),
'direction': 'short',
'trade_date': Date.today(),
'open_qty': quantity,
}])
@classmethod
def write(cls, *args):
actions = iter(args)
@@ -3096,17 +3179,19 @@ class Line(metaclass=PoolMeta):
if Decimal(str(line.quantity_theorical or 0)) > 0:
Lot.save([lot])
#check if fees need to be updated
if line.fees:
for fee in line.fees:
fl_check = FeeLots.search([('fee','=',fee.id),('lot','=',lot.id),('line','=',line.id)])
if not fl_check:
fl = FeeLots()
if line.fees:
for fee in line.fees:
fl_check = FeeLots.search([('fee','=',fee.id),('lot','=',lot.id),('line','=',line.id)])
if not fl_check:
fl = FeeLots()
fl.fee = fee.id
fl.lot = lot.id
fl.line = line.id
FeeLots.save([fl])
if line.fee_:
fl.line = line.id
FeeLots.save([fl])
cls._sync_virtual_lot_packing(line)
cls._ensure_auto_hedge_derivative(line)
if line.fee_:
if not line.fee_.purchase:
Fee = Pool().get('fee.fee')
f = Fee(line.fee_)

View File

@@ -7,7 +7,7 @@ from trytond.model import (ModelSQL, ModelView)
from trytond.i18n import gettext
from trytond.wizard import Button, StateTransition, StateView, Wizard, StateAction
from trytond.transaction import Transaction, inactive_records
from decimal import getcontext, Decimal, ROUND_HALF_UP
from decimal import getcontext, Decimal, ROUND_CEILING, ROUND_FLOOR, ROUND_HALF_UP
from sql.aggregate import Count, Max, Min, Sum, Avg, BoolOr
from sql.conditionals import Case
from sql import Column, Literal
@@ -2264,8 +2264,16 @@ class SaleLine(metaclass=PoolMeta):
if self.product and self.product.template and self.product.template.attribute_set:
return self.product.template.attribute_set.id
@fields.depends('product', 'attributes')
@fields.depends(
'product', 'attributes',
'coffee_origin', 'coffee_type', 'coffee_process', 'coffee_variety',
'coffee_crop_year', 'coffee_screen_size', 'coffee_moisture_max',
'coffee_defect_max', 'coffee_cup_score_min')
def on_change_with_attributes_name(self, name=None):
coffee_attributes_name = getattr(
self, '_coffee_attributes_name', lambda: None)()
if coffee_attributes_name:
return coffee_attributes_name
if not self.product or not self.product.attribute_set or not self.attributes:
return
@@ -2994,6 +3002,81 @@ class SaleLine(metaclass=PoolMeta):
default.setdefault('quantity_theorical', None)
default.setdefault('price_pricing', None)
return super().copy(lines, default=default)
@classmethod
def _sync_virtual_lot_packing(cls, line):
Lot = Pool().get('lot.lot')
if not getattr(line, 'lots', None):
line = cls(line.id)
virtual_lots = [
lot for lot in (line.lots or [])
if getattr(lot, 'lot_type', None) == 'virtual']
if not virtual_lots:
return
lot = virtual_lots[0]
packing_count = getattr(line, 'coffee_packing_count', None)
packing_unit = getattr(line, 'coffee_packing_unit', None)
lot_unit_id = getattr(lot.lot_unit, 'id', lot.lot_unit)
packing_unit_id = getattr(packing_unit, 'id', packing_unit)
if lot.lot_qt == packing_count and lot_unit_id == packing_unit_id:
return
lot.lot_qt = packing_count
lot.lot_unit = packing_unit
Lot.save([lot])
@classmethod
def _auto_hedge_configuration(cls):
Configuration = Pool().get('sale.configuration')
configurations = Configuration.search([], limit=1)
if configurations:
return configurations[0]
@classmethod
def _auto_hedge_contract_count(cls, line, over_hedge=False):
price_index = getattr(line, 'coffee_market_reference', None)
if not price_index or not getattr(line, 'unit', None):
return 0
quantity = Decimal(str(
getattr(line, 'quantity_theorical', None)
or getattr(line, 'quantity', None)
or 0))
if quantity <= 0:
return 0
contract_quantity = Decimal(str(price_index.get_qt(1, line.unit) or 0))
if contract_quantity <= 0:
return 0
rounding = ROUND_CEILING if over_hedge else ROUND_FLOOR
return int((quantity / contract_quantity).to_integral_value(
rounding=rounding))
@classmethod
def _ensure_auto_hedge_derivative(cls, line):
config = cls._auto_hedge_configuration()
if not config or not getattr(config, 'auto_hedging', False):
return
if getattr(line, 'derivatives', None):
return
price_index = getattr(line, 'coffee_market_reference', None)
nb_ct = cls._auto_hedge_contract_count(
line, over_hedge=getattr(config, 'auto_hedging_over', False))
if not price_index or nb_ct <= 0:
return
Derivative = Pool().get('derivative.derivative')
Date = Pool().get('ir.date')
quantity = price_index.get_qt(nb_ct, line.unit)
Derivative.create([{
'sale': line.sale.id if line.sale else None,
'sale_line': line.id,
'product': line.product.id if line.product else None,
'party': line.sale.party.id
if line.sale and line.sale.party else None,
'price_index': price_index.id,
'nb_ct': nb_ct,
'price': getattr(line, 'coffee_market_price', None),
'direction': 'long',
'trade_date': Date.today(),
'open_qty': quantity,
}])
@classmethod
def validate(cls, salelines):
@@ -3013,7 +3096,6 @@ class SaleLine(metaclass=PoolMeta):
line.check_pricing()
line.check_targeted_qt_tolerance()
#no lot need to create one with line quantity
logger.info("FROM_VALIDATE_LINE:%s",line.created_by_code)
if not line.created_by_code:
if cls._sync_initial_quantity_from_theorical(line):
cls.save([line])
@@ -3053,6 +3135,8 @@ class SaleLine(metaclass=PoolMeta):
fl.lot = lot.id
fl.sale_line = line.id
FeeLots.save([fl])
cls._sync_virtual_lot_packing(line)
cls._ensure_auto_hedge_derivative(line)
#generate valuation for purchase and sale
LotQt = Pool().get('lot.qt')
@@ -3061,7 +3145,6 @@ class SaleLine(metaclass=PoolMeta):
if line.lots:
for lot in line.lots:
lqts = LotQt.search([('lot_s','=',lot.id),('lot_p','>',0)])
logger.info("VALIDATE_SL:%s",lqts)
if lqts:
generated_purchase_side = True
purchase_lines = [e.lot_p.line for e in lqts]

View File

@@ -344,6 +344,33 @@ class ContractFactory:
requested = sum(
cls._normalize_quantity(contract.quantity)
for contract in contracts)
logger.info(
"CONTRACT_FACTORY_VALIDATE_QUANTITY matched=%s requested=%s "
"available=%s ct_lot=%s ct_quantity=%s active_ids=%s "
"contracts=%s sources=%s",
getattr(ct, 'matched', None), requested, available,
getattr(getattr(ct, 'lot', None), 'id', None),
getattr(ct, 'quantity', None),
(Transaction().context or {}).get('active_ids'),
[
{
'quantity': cls._normalize_quantity(contract.quantity),
'party': getattr(getattr(contract, 'party', None), 'id', None),
'unit': getattr(getattr(contract, 'unit', None), 'id', None),
'price_type': getattr(contract, 'price_type', None),
}
for contract in contracts
],
[
{
'quantity': source['quantity'],
'lot': getattr(source.get('lot'), 'id', None),
'lqt': getattr(source.get('lqt'), 'id', None),
'trade_line': getattr(source.get('trade_line'), 'id', None),
'shipment_origin': source.get('shipment_origin'),
}
for source in sources
])
if requested > available:
raise UserError(
'The requested quantity exceeds the selected open quantity.')

View File

@@ -775,10 +775,16 @@ class ShipmentIn(metaclass=PoolMeta):
fields.One2Many('valuation.valuation.line', '', "Pnl"),
'get_pnl_lines')
lotqt = fields.One2Many('lot.qt','lot_shipment_in',"Lots")
quantity = fields.Function(fields.Numeric("Quantity"),'get_quantity')
unit = fields.Function(fields.Many2One('product.uom',"Unit"),'get_unit')
bl_date = fields.Date("BL date")
bl_number = fields.Char("BL number")
quantity = fields.Function(fields.Numeric("Quantity"),'get_quantity')
unit = fields.Function(fields.Many2One('product.uom',"Unit"),'get_unit')
list_from_locations = fields.Function(
fields.Char("From"), 'get_list_from_locations')
list_to_locations = fields.Function(
fields.Char("To"), 'get_list_to_locations')
list_purchase_suppliers = fields.Function(
fields.Char("Supplier"), 'get_list_purchase_suppliers')
bl_date = fields.Date("BL date")
bl_number = fields.Char("BL number")
etl = fields.Date("Est. Loading")
eta = fields.Date("🏗ETA POL")
etad = fields.Date("⚓ETA")
@@ -1639,11 +1645,11 @@ class ShipmentIn(metaclass=PoolMeta):
@classmethod
def _report_currency_code(cls, record):
currency = getattr(record, 'currency', None)
return (
getattr(currency, 'code', None)
or getattr(currency, 'name', None)
or getattr(currency, 'symbol', None)
or '')
for name in ('code', 'rec_name', 'name', 'symbol'):
value = getattr(currency, name, None)
if value and not str(value).isdigit():
return value
return ''
@classmethod
def _report_unit_symbol(cls, line):
@@ -1697,7 +1703,112 @@ class ShipmentIn(metaclass=PoolMeta):
+ self._get_report_linkage_sale_lines()):
for fee in getattr(line, 'fees', []) or []:
add_fee(fee)
return fees
return [
fee for fee in fees
if self._report_linkage_fee_applies_to_report_lots(fee)]
def _get_report_linkage_fee_lots(self, fee):
get_lots = getattr(fee, '_get_effective_fee_lots', None)
if callable(get_lots):
lots = get_lots()
if lots:
return list(lots)
lots = getattr(fee, 'lots', None)
if lots:
return list(lots)
return []
def _report_linkage_fee_applies_to_report_lots(self, fee):
report_lots = self._get_report_linkage_lots()
if not report_lots:
return True
fee_lots = self._get_report_linkage_fee_lots(fee)
if fee_lots:
report_lot_keys = {
self._report_record_key(lot) for lot in report_lots}
return any(
self._report_record_key(lot) in report_lot_keys
for lot in fee_lots)
trade_line = self._get_report_linkage_fee_trade_line(fee)
report_lines = (
self._get_report_linkage_purchase_lines()
+ self._get_report_linkage_sale_lines())
report_line_keys = {
self._report_record_key(line) for line in report_lines}
return (
not trade_line
or self._report_record_key(trade_line) in report_line_keys)
def _get_report_linkage_fee_trade_line(self, fee):
return (
getattr(fee, 'line', None)
or getattr(fee, 'sale_line', None)
or (self._get_report_linkage_purchase_lines()
or self._get_report_linkage_sale_lines()
or [None])[0])
def _get_report_linkage_fee_trade(self, fee):
trade_line = self._get_report_linkage_fee_trade_line(fee)
return (
getattr(trade_line, 'purchase', None)
or getattr(trade_line, 'sale', None)
or getattr(fee, 'purchase', None)
or getattr(fee, 'sale', None))
@classmethod
def _report_linkage_fee_type(cls, fee):
return getattr(fee, 'type', None) or 'budgeted'
def _get_report_linkage_fee_pairs(self):
groups = {}
order = []
fallback_trade = (
self._get_report_linkage_purchase()
or self._get_report_linkage_sale())
for fee in self._get_report_linkage_fees():
trade_line = self._get_report_linkage_fee_trade_line(fee)
trade = self._get_report_linkage_fee_trade(fee) or fallback_trade
key = (
self._report_record_key(trade_line or trade),
self._report_record_key(getattr(fee, 'product', None)),
self._report_record_key(getattr(fee, 'supplier', None)),
)
if key not in groups:
groups[key] = {
'trade': trade,
'budgeted': [],
'ordered': [],
}
order.append(key)
fee_type = self._report_linkage_fee_type(fee)
if fee_type in ('budgeted', 'ordered'):
groups[key][fee_type].append(fee)
pairs = []
for key in order:
group = groups[key]
budgeted = group['budgeted']
if not budgeted:
continue
ordered = group['ordered']
estimated = sum(
(self._report_fee_amount(fee) for fee in budgeted),
Decimal('0'))
validated = estimated
if ordered:
validated = sum(
(self._report_fee_amount(fee) for fee in ordered),
Decimal('0'))
pairs.append({
'fee': budgeted[0],
'trade': group['trade'],
'estimated': estimated,
'validated': validated,
'ordered': ordered,
})
return pairs
@classmethod
def _report_fee_amount(cls, fee):
@@ -1737,23 +1848,38 @@ class ShipmentIn(metaclass=PoolMeta):
rows.append((
'Purchases', '', purchase_total, purchase_total,
'major'))
rows.append(('', '', '', '', 'spacer'))
if sale_lines:
rows.append(('Sales', '', sale_total, sale_total, 'major'))
rows.append(('', '', '', '', 'spacer'))
fee_total = Decimal('0')
fee_estimated_total = Decimal('0')
fee_validated_total = Decimal('0')
fee_rows = {}
for fee in self._get_report_linkage_fees():
for pair in self._get_report_linkage_fee_pairs():
fee = pair['fee']
label = self._report_linkage_fee_label(fee)
amount = self._report_fee_amount(fee)
fee_total += amount
fee_rows[label] = fee_rows.get(label, Decimal('0')) + amount
for label, amount in fee_rows.items():
rows.append(('Costs', label, amount, amount, 'cost'))
estimated = pair['estimated']
validated = pair['validated']
fee_estimated_total += estimated
fee_validated_total += validated
current = fee_rows.get(label, (Decimal('0'), Decimal('0')))
fee_rows[label] = (
current[0] + estimated,
current[1] + validated,
)
for label, amounts in fee_rows.items():
rows.append((
'Costs', label, amounts[0], amounts[1], 'cost'))
if rows:
rows.append(('Costs', 'Total', fee_total, fee_total, 'cost'))
pnl = purchase_total + sale_total + fee_total
rows.append(('P&L', '', pnl, pnl, 'pnl'))
rows.append((
'Costs', 'Total', fee_estimated_total,
fee_validated_total, 'cost'))
rows.append(('', '', '', '', 'spacer'))
estimated_pnl = purchase_total + sale_total + fee_estimated_total
validated_pnl = purchase_total + sale_total + fee_validated_total
rows.append(('P&L', '', estimated_pnl, validated_pnl, 'pnl'))
return rows
def _get_report_linkage_movement_rows(self):
@@ -1844,12 +1970,17 @@ class ShipmentIn(metaclass=PoolMeta):
rows.append(self._get_report_linkage_detail_row(
'A', 'Deal expense', purchase, line,
-self._report_line_amount(line)))
for fee in self._get_report_linkage_fees():
rows.append(self._get_report_linkage_fee_detail_row(fee))
total = sum((row[7] for row in rows), Decimal('0'))
for pair in self._get_report_linkage_fee_pairs():
rows.append(self._get_report_linkage_fee_detail_row(pair))
estimated_total = sum((row[6] for row in rows), Decimal('0'))
validated_total = sum((row[7] for row in rows), Decimal('0'))
if rows:
rows.append(('A', 'Total', '', '', '', '', total, total, 'total'))
rows.append(('P&L', '', '', '', '', '', total, total, 'pnl'))
rows.append((
'A', 'Total', '', '', '', '', estimated_total,
validated_total, 'total'))
rows.append((
'P&L', '', '', '', '', '', estimated_total,
validated_total, 'pnl'))
return rows
def _get_report_linkage_detail_row(self, group, label, trade, line, amount):
@@ -1872,20 +2003,13 @@ class ShipmentIn(metaclass=PoolMeta):
'line',
)
def _get_report_linkage_fee_detail_row(self, fee):
def _get_report_linkage_fee_detail_row(self, pair):
fee = pair['fee']
product = getattr(fee, 'product', None)
trade_line = (
getattr(fee, 'line', None)
or getattr(fee, 'sale_line', None)
or (self._get_report_linkage_purchase_lines()
or self._get_report_linkage_sale_lines()
or [None])[0])
trade = (
getattr(trade_line, 'purchase', None)
or getattr(trade_line, 'sale', None))
trade_line = self._get_report_linkage_fee_trade_line(fee)
trade = pair.get('trade') or self._get_report_linkage_fee_trade(fee)
currency = self._report_currency_code(fee) or self._report_currency_code(trade)
unit = self._get_report_unit_text(getattr(fee, 'unit', None))
amount = self._report_fee_amount(fee)
quantity = getattr(fee, 'quantity', None)
if quantity in (None, '') and trade_line:
quantity = self._report_line_quantity(trade_line)
@@ -1899,8 +2023,8 @@ class ShipmentIn(metaclass=PoolMeta):
'/'.join(part for part in [currency, unit.upper() if unit else ''] if part)]
if part),
self._format_report_quantity(quantity or 0),
amount,
amount,
pair['estimated'],
pair['validated'],
'fee',
)
@@ -1940,15 +2064,13 @@ class ShipmentIn(metaclass=PoolMeta):
self._report_number(purchase),
self._report_number(sale),
]))
return ' '.join(part for part in ['Linkage', numbers, reference] if part)
vessel = self._report_rec_name(getattr(self, 'vessel', None))
return ' '.join(part for part in [
'Linkage', numbers, reference, vessel] if part)
@property
def report_linkage_desk(self):
purchase_lines = self._get_report_linkage_purchase_lines()
product = (
getattr(purchase_lines[0], 'product', None)
if purchase_lines else self._get_report_linkage_product())
return self._report_rec_name(product)
return 'Sulfuric Acid'
@property
def report_linkage_book(self):
@@ -2283,9 +2405,9 @@ class ShipmentIn(metaclass=PoolMeta):
# return "\n".join(lines)
def _create_lots_from_fintrade(self):
t = Table('freight_booking_lots')
cursor = Transaction().connection.cursor()
def _create_lots_from_fintrade(self):
t = Table('freight_booking_lots')
cursor = Transaction().connection.cursor()
query = t.select(
t.BOOKING_NUMBER,
t.LOT_NUMBER,
@@ -2307,16 +2429,98 @@ class ShipmentIn(metaclass=PoolMeta):
t.SHIPMENT_CHUNK_KEY,
where=(t.BOOKING_NUMBER == int(self.reference))
)
cursor.execute(*query)
rows = cursor.fetchall()
logger.info("ROWS:%s",rows)
inv_date = None
inv_nb = None
if rows:
sale_line = None
for row in rows:
logger.info("ROW:%s",row)
#Purchase & Sale creation
cursor.execute(*query)
rows = cursor.fetchall()
logger.info(
"FINTRADE_LOTS_START shipment=%s reference=%s bl=%s rows=%s",
getattr(self, 'id', None), self.reference, self.bl_number,
len(rows))
inv_date = None
inv_nb = None
cleanup_needed = []
skipped_rows = 0
def normalize_quantity(value):
return Decimal(str(value or 0)).quantize(Decimal('0.00001'))
def lot_summary(lot):
return {
'id': lot.id,
'name': getattr(lot, 'lot_name', None),
'shipment_in': (
lot.lot_shipment_in.id
if getattr(lot, 'lot_shipment_in', None)
else None),
'purchase_line': (
lot.line.id
if getattr(lot, 'line', None)
else None),
'sale_line': (
lot.sale_line.id
if getattr(lot, 'sale_line', None)
else None),
}
def lqt_summary(lqt):
return {
'id': lqt.id,
'lot_p': lqt.lot_p.id if lqt.lot_p else None,
'lot_s': lqt.lot_s.id if lqt.lot_s else None,
'quantity': lqt.lot_quantity,
'unit': lqt.lot_unit.id if lqt.lot_unit else None,
'shipment_in': (
lqt.lot_shipment_in.id
if lqt.lot_shipment_in else None),
'shipment_origin': lqt.lot_shipment_origin,
'status': lqt.lot_status,
'availability': lqt.lot_av,
}
def add_cleanup(reason, declaration, chunk, lot_number, quantity,
details=None):
item = {
'reason': reason,
'shipment': getattr(self, 'id', None),
'reference': self.reference,
'bl': self.bl_number,
'declaration': declaration,
'chunk': chunk,
'lot_number': lot_number,
'quantity': quantity,
'details': details or {},
}
cleanup_needed.append(item)
logger.info("FINTRADE_CLEANUP_NEEDED %s", item)
def select_or_link_lqt(lqts, quantity):
quantity = normalize_quantity(quantity)
current = [
q for q in lqts
if q.lot_p
and q.lot_shipment_in
and q.lot_shipment_in.id == self.id
and normalize_quantity(q.lot_quantity) >= quantity
]
if current:
return current[0]
unshipped = [
q for q in lqts
if q.lot_p
and not q.lot_shipment_in
and not q.lot_shipment_internal
and not q.lot_shipment_out
and normalize_quantity(q.lot_quantity) >= quantity
]
if not unshipped:
return None
return LotQt.link_to_transport(
unshipped[0], quantity,
'stock.shipment.in,%s' % self.id)
if rows:
sale_line = None
for row in rows:
#Purchase & Sale creation
LotQt = Pool().get('lot.qt')
Lot = Pool().get('lot.lot')
LotAdd = Pool().get('lot.add.line')
@@ -2324,15 +2528,24 @@ class ShipmentIn(metaclass=PoolMeta):
Product = Pool().get('product.product')
Party = Pool().get('party.party')
Uom = Pool().get('product.uom')
Sale = Pool().get('sale.sale')
SaleLine = Pool().get('sale.line')
dec_key = str(row[16]).strip()
chunk_key = str(row[17]).strip()
lot_unit = str(row[5]).strip().lower()
product = str(row[6]).strip().upper()
lot_net_weight = Decimal(row[4])
logger.info("LOT_NET_WEIGHT:%s",lot_net_weight)
lot_gross_weight = Decimal(row[3])
Sale = Pool().get('sale.sale')
SaleLine = Pool().get('sale.line')
dec_key = str(row[16]).strip()
chunk_key = str(row[17]).strip()
chunk_key_int = None
if chunk_key and chunk_key.lower() not in {'none', 'null'}:
try:
chunk_key_int = int(chunk_key)
except (TypeError, ValueError):
logger.info(
"FINTRADE_INVALID_CHUNK_KEY shipment=%s "
"booking=%s declaration=%s chunk=%s",
getattr(self, 'id', None), self.reference,
dec_key, chunk_key)
lot_unit = str(row[5]).strip().lower()
product = str(row[6]).strip().upper()
lot_net_weight = Decimal(row[4])
lot_gross_weight = Decimal(row[3])
lot_bales = Decimal(row[2])
lot_number = row[1]
customer = str(row[7]).strip().upper()
@@ -2341,25 +2554,110 @@ class ShipmentIn(metaclass=PoolMeta):
inv_date = str(row[13]).strip()
inv_nb = str(row[11]).strip()
sell_price = Decimal(row[10])
premium = Decimal(row[14])
reference = Decimal(row[15])
logger.info("DECLARATION_KEY:%s",dec_key)
declaration = SaleLine.search(['note','=',dec_key])
if declaration:
sale_line = declaration[0]
logger.info("WITH_DEC:%s",sale_line)
vlot = sale_line.lots[0]
lqt = LotQt.search([('lot_s','=',vlot.id)])
if lqt:
for lq in lqt:
if lq.lot_p:
logger.info("VLOT_P:%s",lq.lot_p)
sale_line.quantity_theorical += round(lot_net_weight,2)
SaleLine.save([sale_line])
lq.lot_p.updateVirtualPart(round(lot_net_weight,2),self,lq.lot_s)
vlot.set_current_quantity(round(lot_net_weight,2),round(lot_gross_weight,2),1)
Lot.save([vlot])
else:
premium = Decimal(row[14])
reference = Decimal(row[15])
logger.info(
"FINTRADE_LOT_ROW shipment=%s booking=%s lot_number=%s "
"chunk=%s declaration=%s net=%s gross=%s bales=%s "
"lot_unit=%s sell_currency=%s sell_unit=%s customer=%s "
"sale_contract=%s",
getattr(self, 'id', None), self.reference, lot_number,
chunk_key, dec_key, lot_net_weight, lot_gross_weight,
lot_bales, lot_unit, sell_price_currency,
sell_price_unit, customer, reference)
if chunk_key_int is None:
skipped_rows += 1
add_cleanup(
'invalid_chunk_key', dec_key, chunk_key, lot_number,
lot_net_weight)
continue
if chunk_key_int is not None:
existing_chunk_lots = Lot.search([
('lot_chunk_key', '=', chunk_key_int),
])
if existing_chunk_lots:
duplicate_details = {
'existing_count': len(existing_chunk_lots),
'existing_sample': [
lot_summary(lot)
for lot in existing_chunk_lots[:20]
],
'existing_shipments': sorted({
lot.lot_shipment_in.id
for lot in existing_chunk_lots
if getattr(lot, 'lot_shipment_in', None)
}),
}
skipped_rows += 1
add_cleanup(
'chunk_already_has_physical_lots',
dec_key, chunk_key, lot_number,
lot_net_weight, duplicate_details)
logger.info(
"FINTRADE_SKIP_DUPLICATE_CHUNK shipment=%s "
"reference=%s chunk=%s existing_count=%s "
"existing_shipments=%s",
getattr(self, 'id', None), self.reference,
chunk_key, duplicate_details['existing_count'],
duplicate_details['existing_shipments'])
continue
logger.info(
"FINTRADE_CHUNK_CHECK chunk=%s existing_count=%s "
"existing_sample=%s",
chunk_key,
len(existing_chunk_lots),
[lot_summary(lot) for lot in existing_chunk_lots[:20]])
declaration = SaleLine.search(['note','=',dec_key])
logger.info(
"FINTRADE_DECLARATION_SEARCH declaration=%s count=%s "
"sale_line_ids=%s",
dec_key, len(declaration), [d.id for d in declaration])
if declaration:
sale_line = declaration[0]
logger.info(
"FINTRADE_DECLARATION_REUSE declaration=%s "
"sale_line=%s sale=%s quantity=%s theoretical=%s "
"lot_ids=%s",
dec_key, sale_line.id,
sale_line.sale.id if sale_line.sale else None,
sale_line.quantity, sale_line.quantity_theorical,
[lot.id for lot in sale_line.lots])
if not sale_line.lots:
skipped_rows += 1
add_cleanup(
'declaration_without_virtual_lot',
dec_key, chunk_key, lot_number, lot_net_weight,
{'sale_line': sale_line.id})
continue
vlot = sale_line.lots[0]
lqt = LotQt.search([('lot_s','=',vlot.id)])
logger.info(
"FINTRADE_DECLARATION_LQT declaration=%s vlot=%s "
"vlot_qty=%s lqt_count=%s lqts=%s",
dec_key, vlot.id, vlot.lot_quantity, len(lqt),
[lqt_summary(q) for q in lqt])
lqt = select_or_link_lqt(lqt, lot_net_weight)
if not lqt:
skipped_rows += 1
add_cleanup(
'no_open_matched_lot_qt_for_declaration',
dec_key, chunk_key, lot_number, lot_net_weight,
{
'sale_line': sale_line.id,
'vlot': vlot.id,
'available_lqts': [
lqt_summary(q)
for q in LotQt.search([
('lot_s', '=', vlot.id),
('lot_p', '>', 0),
])
],
})
continue
sale_line.quantity_theorical += round(lot_net_weight,2)
SaleLine.save([sale_line])
else:
sale = Sale()
sale_line = SaleLine()
sale.party = Party.getPartyByName(customer,'CLIENT')
@@ -2397,12 +2695,33 @@ class ShipmentIn(metaclass=PoolMeta):
sale_line.created_by_code = False
sale_line.note = dec_key
SaleLine.save([sale_line])
#need to link the virtual part to the shipment
lqt = LotQt.search([('lot_s','=',sale_line.lots[0])])
if lqt:
lqt[0].lot_shipment_in = self
LotQt.save(lqt)
#need to link the virtual part to the shipment
lqt = LotQt.search([('lot_s','=',sale_line.lots[0])])
logger.info(
"FINTRADE_NEW_SALE_LQT sale=%s sale_line=%s "
"vlot=%s vlot_qty=%s lqt_count=%s lqts=%s",
sale.id, sale_line.id, sale_line.lots[0].id,
sale_line.lots[0].lot_quantity, len(lqt),
[
{
'id': q.id,
'lot_p': q.lot_p.id if q.lot_p else None,
'lot_s': q.lot_s.id if q.lot_s else None,
'quantity': q.lot_quantity,
'unit': q.lot_unit.id if q.lot_unit else None,
'shipment_in': (
q.lot_shipment_in.id
if q.lot_shipment_in else None),
'shipment_origin': q.lot_shipment_origin,
'status': q.lot_status,
'availability': q.lot_av,
}
for q in lqt
])
if lqt:
lqt[0].lot_shipment_in = self
LotQt.save(lqt)
logger.info("SALE_LINKED_TO_SHIPMENT:%s",self)
ContractStart = Pool().get('contracts.start')
@@ -2410,11 +2729,12 @@ class ShipmentIn(metaclass=PoolMeta):
ct = ContractStart()
d = ContractDetail()
ct.type = 'Purchase'
ct.matched = True
ct.shipment_in = self
ct.lot = sale_line.lots[0]
ct.product = sale_line.product
ct.unit = sale_line.unit
ct.matched = True
ct.shipment_in = self
ct.lot = sale_line.lots[0]
ct.quantity = sale_line.quantity
ct.product = sale_line.product
ct.unit = sale_line.unit
d.party = Party.getPartyByName('FAIRCOT')
if sale_line.enable_linked_currency:
d.currency_unit = str(sale_line.linked_currency.id) + '_' + str(sale_line.linked_unit.id)
@@ -2434,30 +2754,87 @@ class ShipmentIn(metaclass=PoolMeta):
d.del_period = None
d.from_del = None
d.to_del = None
d.payment_term = sale.payment_term
ct.contracts = [d]
ContractFactory.create_contracts(
ct.contracts,
type_=ct.type,
ct=ct,
)
#Lots creation
vlot = sale_line.lots[0]
lqt = LotQt.search([('lot_s','=',vlot.id),('lot_p','>',0)])
if lqt and vlot.lot_quantity > 0:
lqt = lqt[0]
l = LotAdd()
l.lot_qt = lot_bales
l.lot_unit = Uom.get_by_name('bale')
d.payment_term = sale.payment_term
ct.contracts = [d]
logger.info(
"FINTRADE_CREATE_PURCHASE_FROM_SALE shipment=%s "
"declaration=%s chunk=%s sale=%s sale_line=%s "
"source_lot=%s source_lot_qty=%s requested=%s "
"ct_quantity=%s active_ids=%s",
getattr(self, 'id', None), dec_key, chunk_key,
sale.id, sale_line.id, ct.lot.id,
ct.lot.lot_quantity, d.quantity,
getattr(ct, 'quantity', None),
Transaction().context.get('active_ids'))
ContractFactory.create_contracts(
ct.contracts,
type_=ct.type,
ct=ct,
)
#Lots creation
vlot = sale_line.lots[0]
lqts = LotQt.search([
('lot_s', '=', vlot.id),
('lot_p', '>', 0),
])
if not declaration:
lqt = select_or_link_lqt(lqts, lot_net_weight)
logger.info(
"FINTRADE_PHYSICAL_LOT_SOURCE shipment=%s declaration=%s "
"chunk=%s vlot=%s vlot_qty=%s source_lqt_count=%s "
"source_lqts=%s",
getattr(self, 'id', None), dec_key, chunk_key, vlot.id,
vlot.lot_quantity, len(lqts),
[lqt_summary(q) for q in lqts])
if lqt and normalize_quantity(lqt.lot_quantity) >= normalize_quantity(lot_net_weight):
l = LotAdd()
l.lot_qt = lot_bales
l.lot_unit = Uom.get_by_name('bale')
l.lot_unit_line = Uom.get_by_name(lot_unit)
l.lot_quantity = round(lot_net_weight,2)
l.lot_gross_quantity = round(lot_gross_weight,2)
l.lot_premium = premium
l.lot_chunk_key = int(chunk_key)
logger.info("ADD_LOT:%s",int(chunk_key))
LotQt.add_physical_lots(lqt,[l])
l.lot_chunk_key = int(chunk_key)
logger.info(
"FINTRADE_ADD_PHYSICAL_LOT shipment=%s "
"declaration=%s chunk=%s source_lqt=%s "
"source_open_before=%s add_net=%s add_gross=%s "
"add_bales=%s",
getattr(self, 'id', None), dec_key, chunk_key,
lqt.id, lqt.lot_quantity, l.lot_quantity,
l.lot_gross_quantity, l.lot_qt)
LotQt.add_physical_lots(lqt,[l])
logger.info(
"FINTRADE_ADD_PHYSICAL_LOT_DONE shipment=%s "
"declaration=%s chunk=%s source_lqt=%s",
getattr(self, 'id', None), dec_key, chunk_key,
lqt.id)
else:
skipped_rows += 1
reason = 'no matched purchase lqt'
if lqt:
reason = 'matched purchase lqt open quantity too low'
add_cleanup(
reason, dec_key, chunk_key, lot_number, lot_net_weight,
{
'selected_lqt': lqt_summary(lqt) if lqt else None,
'source_lqts': [lqt_summary(q) for q in lqts],
})
logger.info(
"FINTRADE_SKIP_PHYSICAL_LOT shipment=%s "
"declaration=%s chunk=%s reason=%s vlot_qty=%s "
"source_lqt_count=%s",
getattr(self, 'id', None), dec_key, chunk_key,
reason, vlot.lot_quantity, len(lqts))
if cleanup_needed:
logger.info(
"FINTRADE_CLEANUP_SUMMARY shipment=%s reference=%s "
"skipped_rows=%s cleanup_count=%s cleanup_items=%s",
getattr(self, 'id', None), self.reference, skipped_rows,
len(cleanup_needed), cleanup_needed)
return inv_date,inv_nb
def html_to_text(self,html_content):
@@ -2695,6 +3072,54 @@ class ShipmentIn(metaclass=PoolMeta):
lines.append(line)
return lines
@staticmethod
def _list_rec_names(records):
names = []
seen = set()
for record in records:
name = (
getattr(record, 'rec_name', None)
or getattr(record, 'name', None)
or getattr(record, 'vessel_name', None))
if not name or name in seen:
continue
seen.add(name)
names.append(name)
return ', '.join(names)
def get_list_from_locations(self, name=None):
locations = []
for lot_qt in getattr(self, 'lotqt', []) or []:
location = (
getattr(lot_qt, 'planned_from_location', None)
or getattr(self, 'from_location', None))
if location:
locations.append(location)
if not locations and getattr(self, 'from_location', None):
locations.append(self.from_location)
return self._list_rec_names(locations)
def get_list_to_locations(self, name=None):
locations = []
for lot_qt in getattr(self, 'lotqt', []) or []:
location = (
getattr(lot_qt, 'planned_to_location', None)
or getattr(self, 'to_location', None))
if location:
locations.append(location)
if not locations and getattr(self, 'to_location', None):
locations.append(self.to_location)
return self._list_rec_names(locations)
def get_list_purchase_suppliers(self, name=None):
suppliers = []
for line in self._get_purchase_lines_from_lots():
purchase = getattr(line, 'purchase', None)
supplier = getattr(purchase, 'party', None)
if supplier:
suppliers.append(supplier)
return self._list_rec_names(suppliers)
def _get_sale_lines_from_lots(self):
lines = []
seen = set()
@@ -4440,10 +4865,11 @@ class LinkageTemplateReport(ShipmentTemplateReportMixin, BaseSupplierShipping):
class LotReportLinkageRecord:
def __init__(self, lot, lot_report=None):
def __init__(self, lot, lot_report=None, linkage_status='PROVISIONAL'):
self.lot = lot
self.lot_report = lot_report
self.id = getattr(lot_report, 'id', None) or getattr(lot, 'id', None)
self.report_linkage_status = linkage_status
@property
def rec_name(self):
@@ -4547,6 +4973,11 @@ class LotReportLinkageRecord:
def _get_report_linkage_lots(self):
return [self.lot]
@property
def vessel(self):
shipment = getattr(self.lot, 'lot_shipment_in', None)
return getattr(shipment, 'vessel', None)
_get_report_linkage_purchase_lines = (
ShipmentIn._get_report_linkage_purchase_lines)
_get_report_linkage_sale_lines = ShipmentIn._get_report_linkage_sale_lines
@@ -4556,7 +4987,37 @@ class LotReportLinkageRecord:
def _get_report_linkage_fees(self):
shipment = getattr(self.lot, 'lot_shipment_in', None)
return list(getattr(shipment, 'fees', []) or [])
fees = []
seen = set()
def add_fee(fee):
if not fee:
return
key = self._report_record_key(fee)
if key in seen:
return
seen.add(key)
fees.append(fee)
for fee in getattr(shipment, 'fees', []) or []:
add_fee(fee)
for line in (
self._get_report_linkage_purchase_lines()
+ self._get_report_linkage_sale_lines()):
for fee in getattr(line, 'fees', []) or []:
add_fee(fee)
return [
fee for fee in fees
if self._report_linkage_fee_applies_to_report_lots(fee)]
_get_report_linkage_fee_lots = ShipmentIn._get_report_linkage_fee_lots
_report_linkage_fee_applies_to_report_lots = (
ShipmentIn._report_linkage_fee_applies_to_report_lots)
_get_report_linkage_fee_trade_line = (
ShipmentIn._get_report_linkage_fee_trade_line)
_get_report_linkage_fee_trade = ShipmentIn._get_report_linkage_fee_trade
_get_report_linkage_fee_pairs = ShipmentIn._get_report_linkage_fee_pairs
_report_linkage_fee_type = ShipmentIn._report_linkage_fee_type
_get_report_linkage_summary_rows = (
ShipmentIn._get_report_linkage_summary_rows)
@@ -4573,7 +5034,14 @@ class LotReportLinkageRecord:
_get_report_linkage_fee_detail_row = (
ShipmentIn._get_report_linkage_fee_detail_row)
report_linkage_title = ShipmentIn.report_linkage_title
@property
def report_linkage_title(self):
title = ShipmentIn.report_linkage_title.fget(self)
status = getattr(self, 'report_linkage_status', None)
if status:
title = ' - '.join(part for part in [title, status] if part)
return title
report_linkage_desk = ShipmentIn.report_linkage_desk
report_linkage_book = ShipmentIn.report_linkage_book
report_linkage_strategy = ShipmentIn.report_linkage_strategy
@@ -4650,6 +5118,7 @@ class LotReportLinkageRecord:
class LotReportLinkageReport(LinkageTemplateReport):
__name__ = 'lot.report.linkage'
linkage_status = 'PROVISIONAL'
@classmethod
def _get_records(cls, ids, model, data):
@@ -4665,10 +5134,16 @@ class LotReportLinkageReport(LinkageTemplateReport):
if not lot:
raise UserError(
'Linkage report needs a physical lot on the selected line.')
linkage_records.append(LotReportLinkageRecord(lot, record))
linkage_records.append(LotReportLinkageRecord(
lot, record, linkage_status=cls.linkage_status))
return linkage_records
class LotReportLinkageFinalReport(LotReportLinkageReport):
__name__ = 'lot.report.linkage.final'
linkage_status = 'FINAL'
class ShipmentCOOReport(ShipmentTemplateReportMixin, BaseSupplierShipping):
__name__ = 'stock.shipment.in.coo'

View File

@@ -13,6 +13,22 @@ this repository contains the full copyright notices and license terms. -->
<field name="inherit" ref="stock.shipment_in_view_tree"/>
<field name="name">shipment_in_tree</field>
</record>
<record model="ir.action.act_window.domain" id="stock.act_shipment_in_form_domain_all">
<field name="sequence" eval="1"/>
</record>
<record model="ir.action.act_window.domain" id="stock.act_shipment_in_form_domain_draft">
<field name="sequence" eval="2"/>
</record>
<record model="ir.action.act_window.domain" id="act_shipment_in_form_domain_started">
<field name="name">Started</field>
<field name="sequence" eval="3"/>
<field name="domain" eval="[('state', '=', 'started')]" pyson="1"/>
<field name="count" eval="True"/>
<field name="act_window" ref="stock.act_shipment_in_form"/>
</record>
<record model="ir.action.act_window.domain" id="stock.act_shipment_in_form_domain_received">
<field name="sequence" eval="4"/>
</record>
<record model="ir.ui.view" id="lot_qt_planned_view_tree">
<field name="model">lot.qt</field>
<field name="type">tree</field>
@@ -274,7 +290,7 @@ this repository contains the full copyright notices and license terms. -->
</record>
<record model="ir.action.report" id="report_shipment_in_linkage">
<field name="name">Linkage</field>
<field name="name">Linkage provisional</field>
<field name="model">lot.report</field>
<field name="report_name">lot.report.linkage</field>
<field name="report">stock/linkage.fodt</field>
@@ -284,6 +300,17 @@ this repository contains the full copyright notices and license terms. -->
<field name="model">lot.report,-1</field>
<field name="action" ref="report_shipment_in_linkage"/>
</record>
<record model="ir.action.report" id="report_shipment_in_linkage_final">
<field name="name">Linkage final</field>
<field name="model">lot.report</field>
<field name="report_name">lot.report.linkage.final</field>
<field name="report">stock/linkage.fodt</field>
</record>
<record model="ir.action.keyword" id="report_shipment_in_linkage_final_keyword">
<field name="keyword">form_print</field>
<field name="model">lot.report,-1</field>
<field name="action" ref="report_shipment_in_linkage_final"/>
</record>
<record model="ir.action.wizard" id="act_update_sof">
<field name="name">Update with SoF PDF</field>

View File

@@ -82,6 +82,62 @@ class PurchaseTradeTestCase(ModuleTestCase):
self.assertEqual([line.sample.reference for line in ordered], ['A', 'R'])
def test_coffee_line_attributes_name_uses_quality_fields(self):
'coffee line attribute name is built from coffee quality fields'
line = SimpleNamespace(
coffee_origin='Brazil',
coffee_type='arabica',
coffee_process='natural',
coffee_variety='Bourbon',
coffee_crop_year='26/27',
coffee_screen_size='17/18',
coffee_moisture_max=Decimal('11.50'),
coffee_defect_max=12,
coffee_cup_score_min=Decimal('82.00'))
self.assertEqual(
coffee_module.CoffeeLineMixin._coffee_attributes_name(line),
'Brazil | Arabica | Natural | Bourbon | 26/27 | 17/18 | '
'11.50 | 12 | 82.00')
def test_coffee_line_attributes_name_ignores_empty_quality_fields(self):
'empty coffee quality fields keep the standard product attributes name'
line = SimpleNamespace(
coffee_origin='',
coffee_type=None,
coffee_process=None,
coffee_variety=None,
coffee_crop_year=None,
coffee_screen_size=None,
coffee_moisture_max=None,
coffee_defect_max=None,
coffee_cup_score_min=None)
self.assertIsNone(
coffee_module.CoffeeLineMixin._coffee_attributes_name(line))
def test_purchase_line_attributes_name_prefers_coffee_quality(self):
'purchase line attributes name uses coffee quality when available'
line = SimpleNamespace(
_coffee_attributes_name=lambda: 'Brazil | Arabica',
product=None,
attributes=None)
self.assertEqual(
purchase_module.Line.on_change_with_attributes_name(line),
'Brazil | Arabica')
def test_sale_line_attributes_name_prefers_coffee_quality(self):
'sale line attributes name uses coffee quality when available'
line = SimpleNamespace(
_coffee_attributes_name=lambda: 'Brazil | Arabica',
product=None,
attributes=None)
self.assertEqual(
sale_module.SaleLine.on_change_with_attributes_name(line),
'Brazil | Arabica')
def test_coffee_lab_analysis_compliance_uses_line_targets(self):
'coffee lab analysis compliance checks sample line targets'
line = SimpleNamespace(
@@ -154,6 +210,168 @@ class PurchaseTradeTestCase(ModuleTestCase):
self.assertEqual(
line.get_coffee_quality_status(), 'approved')
def test_purchase_line_syncs_packing_to_virtual_lot(self):
'purchase line packing updates the existing virtual lot'
package_unit = Mock(id=60)
lot = Mock(lot_type='virtual', lot_qt=None, lot_unit=None)
line = Mock(
lots=[lot],
coffee_packing_count=440,
coffee_packing_unit=package_unit)
lot_model = Mock()
pool = Mock()
pool.get.return_value = lot_model
with patch.object(purchase_module, 'Pool', return_value=pool):
purchase_module.Line._sync_virtual_lot_packing(line)
self.assertEqual(lot.lot_qt, 440)
self.assertEqual(lot.lot_unit, package_unit)
lot_model.save.assert_called_once_with([lot])
def test_sale_line_syncs_packing_to_virtual_lot(self):
'sale line packing updates the existing virtual lot'
package_unit = Mock(id=90)
old_unit = Mock(id=60)
lot = Mock(lot_type='virtual', lot_qt=440, lot_unit=old_unit)
line = Mock(
lots=[lot],
coffee_packing_count=300,
coffee_packing_unit=package_unit)
lot_model = Mock()
pool = Mock()
pool.get.return_value = lot_model
with patch.object(sale_module, 'Pool', return_value=pool):
sale_module.SaleLine._sync_virtual_lot_packing(line)
self.assertEqual(lot.lot_qt, 300)
self.assertEqual(lot.lot_unit, package_unit)
lot_model.save.assert_called_once_with([lot])
def test_purchase_line_does_not_resave_aligned_virtual_lot_packing(self):
'aligned purchase packing does not resave the virtual lot'
package_unit = Mock(id=60)
lot = Mock(lot_type='virtual', lot_qt=440, lot_unit=Mock(id=60))
line = Mock(
lots=[lot],
coffee_packing_count=440,
coffee_packing_unit=package_unit)
lot_model = Mock()
pool = Mock()
pool.get.return_value = lot_model
with patch.object(purchase_module, 'Pool', return_value=pool):
purchase_module.Line._sync_virtual_lot_packing(line)
lot_model.save.assert_not_called()
def test_purchase_auto_hedge_under_uses_floor_contract_count(self):
'purchase auto hedge under creates a short floor contract quantity'
unit = Mock()
price_index = Mock(id=7)
price_index.get_qt.side_effect = [Decimal('17'), Decimal('17')]
line = Mock(
id=12,
derivatives=[],
coffee_market_reference=price_index,
coffee_market_price=Decimal('300'),
quantity_theorical=Decimal('26.4'),
quantity=None,
unit=unit,
product=Mock(id=3),
purchase=Mock(id=4, party=Mock(id=5)))
config_model = Mock()
config_model.search.return_value = [
Mock(auto_hedging=True, auto_hedging_over=False)]
derivative_model = Mock()
date_model = Mock()
date_model.today.return_value = datetime.date(2026, 7, 16)
pool = Mock()
pool.get.side_effect = lambda name: {
'purchase.configuration': config_model,
'derivative.derivative': derivative_model,
'ir.date': date_model,
}[name]
with patch.object(purchase_module, 'Pool', return_value=pool):
purchase_module.Line._ensure_auto_hedge_derivative(line)
derivative_model.create.assert_called_once_with([{
'purchase': 4,
'line': 12,
'product': 3,
'party': 5,
'price_index': 7,
'nb_ct': 1,
'price': Decimal('300'),
'direction': 'short',
'trade_date': datetime.date(2026, 7, 16),
'open_qty': Decimal('17'),
}])
def test_sale_auto_hedge_over_uses_ceiling_contract_count(self):
'sale auto hedge over creates a long ceiling contract quantity'
unit = Mock()
price_index = Mock(id=8)
price_index.get_qt.side_effect = [Decimal('17'), Decimal('34')]
line = Mock(
id=22,
derivatives=[],
coffee_market_reference=price_index,
coffee_market_price=Decimal('301'),
quantity_theorical=Decimal('26.4'),
quantity=None,
unit=unit,
product=Mock(id=13),
sale=Mock(id=14, party=Mock(id=15)))
config_model = Mock()
config_model.search.return_value = [
Mock(auto_hedging=True, auto_hedging_over=True)]
derivative_model = Mock()
date_model = Mock()
date_model.today.return_value = datetime.date(2026, 7, 16)
pool = Mock()
pool.get.side_effect = lambda name: {
'sale.configuration': config_model,
'derivative.derivative': derivative_model,
'ir.date': date_model,
}[name]
with patch.object(sale_module, 'Pool', return_value=pool):
sale_module.SaleLine._ensure_auto_hedge_derivative(line)
derivative_model.create.assert_called_once_with([{
'sale': 14,
'sale_line': 22,
'product': 13,
'party': 15,
'price_index': 8,
'nb_ct': 2,
'price': Decimal('301'),
'direction': 'long',
'trade_date': datetime.date(2026, 7, 16),
'open_qty': Decimal('34'),
}])
def test_auto_hedge_keeps_existing_manual_derivative(self):
'auto hedge does not create a duplicate when a derivative exists'
line = Mock(derivatives=[Mock()])
config_model = Mock()
config_model.search.return_value = [
Mock(auto_hedging=True, auto_hedging_over=True)]
derivative_model = Mock()
pool = Mock()
pool.get.side_effect = lambda name: {
'purchase.configuration': config_model,
'derivative.derivative': derivative_model,
}[name]
with patch.object(purchase_module, 'Pool', return_value=pool):
purchase_module.Line._ensure_auto_hedge_derivative(line)
derivative_model.create.assert_not_called()
def test_itsa_book_year_suffix_uses_april_fiscal_start(self):
'ITSA book year changes on April 1st'
self.assertEqual(
@@ -163,6 +381,68 @@ class PurchaseTradeTestCase(ModuleTestCase):
company_defaults._book_year_suffix(datetime.date(2026, 4, 1)),
'26')
def test_itsa_bulk_unit_default_prefers_bulk_uom(self):
'ITSA bulk unit default resolves the Bulk UoM'
bulk = Mock(id=42)
Uom = Mock()
Uom.search.side_effect = [[bulk]]
pool = Mock()
pool.get.return_value = Uom
with patch.object(company_defaults, 'Pool', return_value=pool):
self.assertEqual(company_defaults.default_itsa_bulk_unit(), 42)
Uom.search.assert_called_once_with(
[('symbol', '=', 'Bulk')], limit=1)
def test_itsa_add_physical_lot_defaults(self):
'ITSA Add Physical lot defaults packing to 1 Bulk and weight unit to Mt'
with patch('trytond.modules.purchase_trade.lot.is_itsa_company',
return_value=True), patch(
'trytond.modules.purchase_trade.lot.default_itsa_bulk_unit',
return_value=42), patch(
'trytond.modules.purchase_trade.lot.default_itsa_unit',
return_value=7):
self.assertEqual(
lot_module.LotAddLine.default_lot_qt(), Decimal('1'))
self.assertEqual(lot_module.LotAddLine.default_lot_unit(), 42)
self.assertEqual(lot_module.LotAddLine.default_lot_unit_line(), 7)
def test_itsa_add_physical_lot_defaults_gross_from_net(self):
'ITSA Add Physical lot copies net weight to empty gross weight'
line = lot_module.LotAddLine()
line.lot_quantity = Decimal('12.500')
line.lot_gross_quantity = None
with patch('trytond.modules.purchase_trade.lot.is_itsa_company',
return_value=True):
line.on_change_lot_quantity()
self.assertEqual(line.lot_gross_quantity, Decimal('12.500'))
def test_itsa_add_physical_lot_keeps_existing_gross_weight(self):
'ITSA Add Physical lot does not overwrite an existing gross weight'
line = lot_module.LotAddLine()
line.lot_quantity = Decimal('12.500')
line.lot_gross_quantity = Decimal('13.000')
with patch('trytond.modules.purchase_trade.lot.is_itsa_company',
return_value=True):
line.on_change_lot_quantity()
self.assertEqual(line.lot_gross_quantity, Decimal('13.000'))
def test_non_itsa_add_physical_lot_does_not_default_gross_weight(self):
'non-ITSA Add Physical lot does not copy net weight to gross weight'
line = lot_module.LotAddLine()
line.lot_quantity = Decimal('12.500')
line.lot_gross_quantity = None
with patch('trytond.modules.purchase_trade.lot.is_itsa_company',
return_value=False):
line.on_change_lot_quantity()
self.assertIsNone(line.lot_gross_quantity)
@with_transaction()
def test_purchase_list_fields_use_first_trade_line(self):
'purchase list helper fields use the first real purchase line'
@@ -544,9 +824,10 @@ class PurchaseTradeTestCase(ModuleTestCase):
self.assertEqual(wizard.default_ship(None), {})
def test_lot_shipping_link_creates_shipment_from_dialog_supplier(self):
'link to transport creates shipment in from dialog supplier'
'link to transport creates shipment in from dialog broker'
wizard = lot_module.LotShipping()
supplier = Mock(id=30)
vessel = Mock(id=50)
created_shipment = Mock(id=40)
ShipmentIn = Mock()
ShipmentIn.create.return_value = [created_shipment]
@@ -558,6 +839,7 @@ class PurchaseTradeTestCase(ModuleTestCase):
shipment_internal=None,
create_new_shipment=True,
shipment_supplier=supplier,
shipment_vessel=vessel,
planned_from_location=Mock(id=10),
planned_to_location=Mock(id=20),
)
@@ -576,10 +858,93 @@ class PurchaseTradeTestCase(ModuleTestCase):
'supplier': 30,
'from_location': 10,
'to_location': 20,
'vessel': 50,
}])
self.assertEqual(wizard.ship.shipment_in, created_shipment)
Lot.assert_lines_quantity_consistency.assert_called_once_with([])
def test_lot_shipping_start_uses_broker_label(self):
'link to transport uses the shipment in Broker label'
self.assertEqual(
lot_module.LotShippingStart.shipment_supplier.string, 'Broker')
def test_lotqt_mark_lines_finished_saves_selected_lines(self):
'mark finished helper can finish purchase and sale lines'
purchase_line = Mock()
sale_line = Mock()
lqt = Mock(
lot_p=Mock(line=purchase_line),
lot_s=Mock(sale_line=sale_line))
PurchaseLine = Mock()
SaleLine = Mock()
pool = Mock()
pool.get.side_effect = lambda name: {
'purchase.line': PurchaseLine,
'sale.line': SaleLine,
}[name]
with patch('trytond.modules.purchase_trade.lot.Pool',
return_value=pool):
affected = lot_module.LotQt.mark_lines_finished_from_lotqt(
lqt, mark_purchase=True, mark_sale=True)
self.assertTrue(purchase_line.finished)
self.assertTrue(sale_line.finished)
PurchaseLine.save.assert_called_once_with([purchase_line])
SaleLine.save.assert_called_once_with([sale_line])
self.assertEqual(affected, [purchase_line, sale_line])
def test_lotqt_mark_lines_finished_can_unmatch(self):
'mark finished helper can release a matched virtual quantity'
purchase_line = Mock()
sale_line = Mock()
lot_s = Mock(sale_line=sale_line)
lot_p = Mock(line=purchase_line)
lot_p.updateVirtualPart.side_effect = [False, False, True]
lqt = Mock(
lot_p=lot_p,
lot_s=lot_s,
lot_quantity=Decimal('12.5'),
lot_shipment_origin='stock.shipment.in,10')
PurchaseLine = Mock()
pool = Mock()
pool.get.return_value = PurchaseLine
with patch('trytond.modules.purchase_trade.lot.Pool',
return_value=pool):
affected = lot_module.LotQt.mark_lines_finished_from_lotqt(
lqt, mark_purchase=True, unmatch=True)
self.assertTrue(purchase_line.finished)
PurchaseLine.save.assert_called_once_with([purchase_line])
lot_p.createVirtualPart.assert_any_call(
Decimal('12.5'), 'stock.shipment.in,10', None)
lot_p.createVirtualPart.assert_any_call(
Decimal('12.5'), 'stock.shipment.in,10', lot_s, 'only sale')
lot_p.updateVirtualPart.assert_any_call(
-Decimal('12.5'), 'stock.shipment.in,10', lot_s)
self.assertEqual(affected, [
purchase_line, purchase_line, sale_line])
def test_lot_mark_finished_requires_matched_virtual_lotqt(self):
'mark finished action rejects non matched rows'
wizard = lot_module.LotMarkFinished()
wizard.start = Mock(
mark_purchase_line_finished=True,
mark_sale_line_finished=False)
wizard.records = [Mock(id=10000007)]
Lot = Mock()
Lot.skip_quantity_consistency.return_value.__enter__ = Mock()
Lot.skip_quantity_consistency.return_value.__exit__ = Mock()
LotQt = Mock(return_value=Mock(lot_p=Mock(), lot_s=None))
pool = Mock()
pool.get.side_effect = [Lot, LotQt]
with patch('trytond.modules.purchase_trade.lot.Pool',
return_value=pool):
with self.assertRaises(UserError):
wizard.transition_marking()
def test_lot_shipping_rejects_existing_and_new_shipment(self):
'link to transport rejects choosing existing shipment and create new'
wizard = lot_module.LotShipping()
@@ -2069,6 +2434,99 @@ class PurchaseTradeTestCase(ModuleTestCase):
{'type': 'derivative'},
])
def test_purchase_derivative_pnl_uses_direction_and_market_delta(self):
'purchase derivative pnl is entry versus market with long short direction'
Valuation = Pool().get('valuation.valuation')
today = datetime.date(2026, 7, 16)
currency = Mock(id=1)
company_currency = Mock(id=2)
unit = Mock(id=3)
price_index = Mock(
price_index='ICE',
get_price_per_qt=Mock(return_value=Decimal('100')),
get_price=Mock(return_value=Decimal('80')))
party = Mock(id=4)
product = Mock(id=5)
purchase = Mock(
id=6,
currency=currency,
company=Mock(currency=company_currency))
line = Mock(id=7, unit=unit, purchase=purchase)
def derivative(direction):
return Mock(
price_index=price_index,
price=Decimal('100'),
direction=direction,
quantity=Decimal('10'),
party=party,
product=product)
line.derivatives = [derivative('long'), derivative('short')]
with patch('trytond.modules.purchase_trade.valuation.Pool') as PoolMock, patch.object(
Valuation, '_base_amount_values',
side_effect=[
(Decimal('-200'), Decimal('1'), 2),
(Decimal('200'), Decimal('1'), 2),
]):
PoolMock.return_value.get.return_value = Mock(
today=Mock(return_value=today))
values = Valuation.create_pnl_der_from_line(line)
self.assertEqual(values[0]['amount'], Decimal('-200.00'))
self.assertEqual(values[0]['base_amount'], Decimal('-200'))
self.assertEqual(values[0]['date'], today)
self.assertIsNone(values[0]['mtm_price'])
self.assertIsNone(values[0]['mtm'])
self.assertEqual(values[1]['amount'], Decimal('200.00'))
self.assertEqual(values[1]['base_amount'], Decimal('200'))
self.assertEqual(values[1]['date'], today)
self.assertIsNone(values[1]['mtm_price'])
self.assertIsNone(values[1]['mtm'])
def test_sale_derivative_pnl_uses_direction_and_market_delta(self):
'sale derivative pnl is stored in amount and base amount only'
Valuation = Pool().get('valuation.valuation')
today = datetime.date(2026, 7, 16)
currency = Mock(id=1)
company_currency = Mock(id=2)
unit = Mock(id=3)
price_index = Mock(
price_index='ICE',
get_price_per_qt=Mock(return_value=Decimal('100')),
get_price=Mock(return_value=Decimal('120')))
derivative = Mock(
price_index=price_index,
price=Decimal('100'),
direction='long',
quantity=Decimal('10'),
party=Mock(id=4),
product=Mock(id=5))
sale = Mock(
id=6,
currency=currency,
company=Mock(currency=company_currency))
sale_line = Mock(
id=7,
unit=unit,
sale=sale,
derivatives=[derivative])
with patch('trytond.modules.purchase_trade.valuation.Pool') as PoolMock, patch.object(
Valuation, '_base_amount_values',
return_value=(Decimal('200'), Decimal('1'), 2)):
PoolMock.return_value.get.return_value = Mock(
today=Mock(return_value=today))
values = Valuation.create_pnl_der_from_sale_line(sale_line)
self.assertEqual(values[0]['price'], Decimal('100.0000'))
self.assertEqual(values[0]['date'], today)
self.assertEqual(values[0]['amount'], Decimal('200.00'))
self.assertEqual(values[0]['base_amount'], Decimal('200'))
self.assertIsNone(values[0]['mtm_price'])
self.assertIsNone(values[0]['mtm'])
def test_update_daily_snapshot_generates_all_purchases_and_unmatched_sales(self):
'daily valuation cron snapshots purchases and unmatched sale lines'
Valuation = Pool().get('valuation.valuation')
@@ -2242,6 +2700,57 @@ class PurchaseTradeTestCase(ModuleTestCase):
self.assertEqual(fee.quantity, Decimal('40.00000'))
save.assert_called_once_with([fee])
def test_shipment_per_quantity_fee_amount_uses_fee_lots_quantity(self):
'shipment per quantity fee amount ignores unrelated shipment moves'
Fee = Pool().get('fee.fee')
fee = Fee()
fee.mode = 'perqt'
fee.price = Decimal('3.14')
fee.quantity = Decimal('3000')
fee.add_padding = False
fee.shipment_in = Mock(id=7)
with patch.object(
fee, '_get_effective_fee_lots_quantity',
return_value=Decimal('3000')) as lots_quantity, patch(
'trytond.modules.purchase_trade.fee.Pool') as PoolMock:
amount = fee.get_amount()
self.assertEqual(amount, Decimal('9420.00'))
lots_quantity.assert_called_once_with()
PoolMock.assert_not_called()
def test_shipment_fee_lot_domain_keeps_open_virtual_lots(self):
'shipment fee lot domain keeps virtual lots after partial physical add'
Fee = Pool().get('fee.fee')
fee = Fee()
fee.line = None
fee.sale_line = None
fee.shipment_in = Mock(id=7)
fee.shipment_internal = None
fee.shipment_out = None
physical = Mock(id=2599, lot_type='physic')
virtual_a = Mock(id=2666, lot_type='virtual')
virtual_b = Mock(id=2623, lot_type='virtual')
physical.getVlot_p.return_value = virtual_a
Lot = Mock()
Lot.search.return_value = [physical]
LotQt = Mock()
LotQt.search.return_value = [
Mock(lot_p=virtual_a),
Mock(lot_p=virtual_b),
]
with patch('trytond.modules.purchase_trade.fee.Pool') as PoolMock:
PoolMock.return_value.get.side_effect = lambda name: {
'lot.lot': Lot,
'lot.qt': LotQt,
}[name]
lots = fee.get_lots(None)
self.assertEqual(lots, [physical, virtual_a, virtual_b])
def test_fee_ppack_auto_sync_keeps_auto_quantity(self):
'per packing auto fee keeps the on-change quantity on save'
Fee = Pool().get('fee.fee')
@@ -2926,6 +3435,46 @@ class PurchaseTradeTestCase(ModuleTestCase):
'shipment_in': shipment.id,
}])
def test_ppack_fee_valuation_uses_fee_packing_not_lot_packing(self):
'per packing fee valuation keeps the fee quantity and amount'
Valuation = valuation_module.ValuationBase
fee = SimpleNamespace(
mode='ppack',
quantity=Decimal('2'),
price=Decimal('2800'),
amount=Decimal('5600'),
)
lot = Mock(lot_qt=Decimal('440'))
segment = {'quantity': Decimal('26.4'), 'share': None}
qty = Valuation._fee_segment_quantity(fee, lot, segment)
price, amount = Valuation._fee_segment_amount(
fee, lot, qty, Decimal('-1'), segment)
self.assertEqual(qty, Decimal('2'))
self.assertEqual(price, Decimal('2800'))
self.assertEqual(amount, Decimal('-5600'))
def test_ppack_fee_valuation_splits_fee_packing_by_segment_share(self):
'per packing fee valuation prorates fee amount on segment shares'
Valuation = valuation_module.ValuationBase
fee = SimpleNamespace(
mode='ppack',
quantity=Decimal('2'),
price=Decimal('2800'),
amount=Decimal('5600'),
)
lot = Mock(lot_qt=Decimal('440'))
segment = {'quantity': Decimal('13.2'), 'share': Decimal('0.5')}
qty = Valuation._fee_segment_quantity(fee, lot, segment)
price, amount = Valuation._fee_segment_amount(
fee, lot, qty, Decimal('-1'), segment)
self.assertEqual(qty, Decimal('1.00000'))
self.assertEqual(price, Decimal('2800'))
self.assertEqual(amount, Decimal('-2800.0'))
def test_snapshot_identity_prefers_sale_line_over_purchase_line(self):
'valuation snapshot identity ignores purchase line when sale line exists'
Valuation = Pool().get('valuation.valuation')
@@ -5536,6 +6085,65 @@ description</t></is></c>
('price_date', '<=', '2026-04-10'),
], order=[('price_date', 'DESC')])
def test_price_get_price_per_qt_converts_linked_currency_to_main_currency(self):
'price curve values in linked currency are normalized to main currency'
Price = Pool().get('price.price')
price = Price()
usd = SimpleNamespace(id=1)
price.price_currency = usd
price.enable_linked_currency = True
price.linked_currency = SimpleNamespace(factor=Decimal('0.01'))
price.price_unit = SimpleNamespace()
uom_model = Mock(compute_qty=Mock(return_value=1))
currency_model = Mock()
def get_model(name):
if name == 'product.uom':
return uom_model
if name == 'currency.currency':
return currency_model
return Mock()
with patch('trytond.modules.price.price.Pool') as PricePool:
PricePool.return_value.get.side_effect = get_model
result = price.get_price_per_qt(Decimal('72.5'), Mock(), usd)
self.assertEqual(result, Decimal('0.7250'))
currency_model._get_rate.assert_not_called()
def test_price_get_price_applies_linked_currency_conversion(self):
'market price lookup applies linked currency conversion'
Price = Pool().get('price.price')
price = Price()
price.id = 42
usd = SimpleNamespace(id=1)
price.price_currency = usd
price.enable_linked_currency = True
price.linked_currency = SimpleNamespace(factor=Decimal('0.01'))
price.price_unit = SimpleNamespace()
price.price_values = [Mock()]
price_value = Mock(price_value=Decimal('72.5'))
price_value_model = Mock(
search=Mock(return_value=[price_value]))
uom_model = Mock(compute_qty=Mock(return_value=1))
def get_model(name):
if name == 'price.price_value':
return price_value_model
if name == 'product.uom':
return uom_model
if name == 'currency.currency':
return Mock()
return Mock()
with patch('trytond.modules.price.price.Pool') as PricePool:
PricePool.return_value.get.side_effect = get_model
result = price.get_price(
datetime.date(2026, 4, 10), Mock(), usd, last=True)
self.assertEqual(result, Decimal('0.7250'))
def test_pricing_component_matrix_prefers_specific_line_over_generic(self):
'matrix pricing keeps generic lines as fallback below matching lines'
Component = Pool().get('pricing.component')
@@ -6909,10 +7517,12 @@ description</t></is></c>
line=purchase_line,
sale_line=sale_line)
shipment.incoming_moves = [SimpleNamespace(lot=lot)]
shipment.vessel = SimpleNamespace(vessel_name='MV ATLANTIC')
self.assertEqual(
shipment.report_linkage_title, 'Linkage P-10/S-20 26.0001')
self.assertEqual(shipment.report_linkage_desk, 'Sulphuric Acid')
shipment.report_linkage_title,
'Linkage P-10/S-20 26.0001 MV ATLANTIC')
self.assertEqual(shipment.report_linkage_desk, 'Sulfuric Acid')
self.assertEqual(shipment.report_linkage_book, 'H2SO4 Chile FY26')
self.assertEqual(
shipment.report_linkage_bl_date, 'Thursday, April 30, 2026')
@@ -6936,6 +7546,100 @@ description</t></is></c>
'linkage report must not alter the lot.qt ORM model'
self.assertFalse(hasattr(stock_module, 'LotQt'))
def test_linkage_report_final_validates_ordered_matching_fee(self):
'linkage final keeps budgeted estimate and validates matching ordered fee'
ShipmentIn = Pool().get('stock.shipment.in')
shipment = ShipmentIn()
shipment.bl_date = datetime.date(2026, 4, 30)
shipment.fees = []
shipment.moves = []
shipment.lotqt = []
currency = SimpleNamespace(id=1, rec_name='USD')
unit = SimpleNamespace(symbol='MT')
product = SimpleNamespace(id=1, rec_name='Sulphuric Acid')
purchase = SimpleNamespace(
id=10,
number='P-10',
reference='26.0001',
currency=currency,
party=SimpleNamespace(id=20, rec_name='SUPPLIER SA'),
to_location=SimpleNamespace(rec_name='Odda'),
incoterm=SimpleNamespace(code='FOB'))
purchase_line = SimpleNamespace(
id=11,
product=product,
purchase=purchase,
quantity_theorical=Decimal('100'),
quantity=Decimal('100'),
unit_price=Decimal('80'),
unit=unit,
fees=[],
mtm=[])
fee_product = SimpleNamespace(id=30, name='Maritime freight')
supplier = SimpleNamespace(id=40, rec_name='FREIGHT CO')
budgeted_fee = SimpleNamespace(
id=201,
type='budgeted',
line=purchase_line,
sale_line=None,
product=fee_product,
supplier=supplier,
price=Decimal('100'),
quantity=Decimal('1'),
mode='lumpsum',
p_r='pay')
ordered_fee = SimpleNamespace(
id=202,
type='ordered',
line=purchase_line,
sale_line=None,
product=fee_product,
supplier=supplier,
price=Decimal('125'),
quantity=Decimal('1'),
mode='lumpsum',
p_r='pay')
other_ordered_fee = SimpleNamespace(
id=203,
type='ordered',
line=purchase_line,
sale_line=None,
product=fee_product,
supplier=supplier,
price=Decimal('999'),
quantity=Decimal('1'),
mode='lumpsum',
p_r='pay')
purchase_line.fees = [budgeted_fee, ordered_fee, other_ordered_fee]
lot = SimpleNamespace(
id=101,
lot_type='physic',
line=purchase_line,
sale_line=None)
other_lot = SimpleNamespace(
id=102,
lot_type='physic',
line=purchase_line,
sale_line=None)
budgeted_fee.lots = [lot]
ordered_fee.lots = [lot]
other_ordered_fee.lots = [other_lot]
shipment.incoming_moves = [SimpleNamespace(lot=lot)]
provisional_fee_row = [
row for row in shipment._get_report_linkage_summary_rows()
if row[0] == 'Costs' and row[1] == 'Maritime freight'][0]
self.assertEqual(provisional_fee_row[2], Decimal('-100'))
self.assertEqual(provisional_fee_row[3], Decimal('-125'))
shipment.report_linkage_status = 'FINAL'
final_fee_row = [
row for row in shipment._get_report_linkage_summary_rows()
if row[0] == 'Costs' and row[1] == 'Maritime freight'][0]
self.assertEqual(final_fee_row[2], Decimal('-100'))
self.assertEqual(final_fee_row[3], Decimal('-125'))
def test_lot_report_linkage_rejects_non_physical_lines(self):
'lot.report linkage report is only available for physical lines'
linkage_report = Pool().get('lot.report.linkage', type='report')
@@ -7020,6 +7724,7 @@ description</t></is></c>
sale_line=sale_line,
lot_shipment_in=SimpleNamespace(
bl_date=datetime.date(2026, 4, 30),
vessel=SimpleNamespace(vessel_name='MV ATLANTIC'),
fees=[]),
move=None)
lot_report = SimpleNamespace(
@@ -7032,7 +7737,13 @@ description</t></is></c>
physical_lot, lot_report)
self.assertEqual(
record.report_linkage_title, 'Linkage P-10/S-20 26.0001')
record.report_linkage_title,
'Linkage P-10/S-20 26.0001 MV ATLANTIC - PROVISIONAL')
final_record = stock_module.LotReportLinkageRecord(
physical_lot, lot_report, linkage_status='FINAL')
self.assertEqual(
final_record.report_linkage_title,
'Linkage P-10/S-20 26.0001 MV ATLANTIC - FINAL')
self.assertEqual(record.report_linkage_bl_date, 'Thursday, April 30, 2026')
self.assertIn('Purchases', record.report_linkage_summary_groups)
self.assertIn('Sales', record.report_linkage_summary_groups)
@@ -8682,6 +9393,26 @@ description</t></is></c>
patch.object(lot_module.LotQt, 'search', return_value=[]):
lot_module.LotQt.match_lots([purchase_lot], [])
def test_physical_lot_tolerance_warning_can_be_skipped_by_context(self):
'physical lot tolerance warning can be skipped for automation cron'
line = Mock(
purchase=Mock(number='P-1'),
sale=None,
id=12,
quantity_theorical=Decimal('100'),
tol_max=Decimal('0'),
inherit_tol=False,
unit=None,
lots=[],
)
lqt = Mock(lot_p=Mock(line=line), lot_s=None)
physical_lot = Mock(lot_quantity=Decimal('120'), lot_unit_line=None)
with Transaction().set_context(
_purchase_trade_skip_physical_lot_tolerance_warning=True):
lot_module.LotQt._warn_physical_lot_tolerance(
lqt, [physical_lot])
def test_lot_matching_gauge_uses_entered_quantity_to_match(self):
'go to matching gauge projects tolerance from qt to match'
matching_lot = lot_module.LotMatchingLot()
@@ -9822,6 +10553,44 @@ description</t></is></c>
)),
[])
def test_shipment_in_list_columns_use_planned_lots_and_purchase_supplier(self):
'shipment list helper columns aggregate lot plans and purchase supplier'
ShipmentIn = Pool().get('stock.shipment.in')
supplier = Mock(rec_name='SUMITOMO')
purchase = Mock(party=supplier)
purchase_line = Mock(id=10, purchase=purchase)
lot_p = Mock(line=purchase_line)
shipment = ShipmentIn()
shipment.from_location = Mock(rec_name='Fallback From')
shipment.to_location = Mock(rec_name='Fallback To')
shipment.lotqt = [
Mock(
lot_p=lot_p,
planned_from_location=Mock(rec_name='Aomori'),
planned_to_location=Mock(rec_name='Stockton')),
Mock(
lot_p=lot_p,
planned_from_location=Mock(rec_name='Sagano'),
planned_to_location=Mock(rec_name='Stockton')),
]
self.assertEqual(shipment.get_list_from_locations(), 'Aomori, Sagano')
self.assertEqual(shipment.get_list_to_locations(), 'Stockton')
self.assertEqual(shipment.get_list_purchase_suppliers(), 'SUMITOMO')
def test_shipment_in_list_locations_fallback_to_shipment_locations(self):
'shipment list location helpers fallback when no planned lots exist'
ShipmentIn = Pool().get('stock.shipment.in')
shipment = ShipmentIn()
shipment.from_location = Mock(rec_name='Kobe')
shipment.to_location = Mock(rec_name='Rotterdam')
shipment.lotqt = []
self.assertEqual(shipment.get_list_from_locations(), 'Kobe')
self.assertEqual(shipment.get_list_to_locations(), 'Rotterdam')
def test_invoice_report_transportation_uses_truck_label(self):
'invoice_melya Transportation displays By Truck for truck shipments'
Invoice = Pool().get('account.invoice')

View File

@@ -1278,11 +1278,8 @@ class ValuationBase(ModelSQL):
@classmethod
def _fee_amount_for_lot_or_zero(cls, fee, lot):
if fee.mode == 'ppack' and getattr(lot, 'lot_qt', None):
return abs(round(
Decimal(fee.price or 0)
* Decimal(str(lot.lot_qt or 0)),
2))
if fee.mode == 'ppack':
return cls._fee_amount_or_zero(fee)
if fee.mode == 'rate':
line = fee.line or getattr(fee, 'sale_line', None)
if line and line.estimated_date:
@@ -1305,16 +1302,15 @@ class ValuationBase(ModelSQL):
@classmethod
def _fee_segment_quantity(cls, fee, lot, segment):
quantity = segment.get('quantity')
if quantity is None:
qty = round(lot.get_current_quantity_converted(), 5)
if fee.mode == 'ppack' and getattr(lot, 'lot_qt', None):
qty = Decimal(str(lot.lot_qt or 0))
return qty
if fee.mode == 'ppack' and getattr(lot, 'lot_qt', None):
if fee.mode == 'ppack':
fee_quantity = Decimal(str(getattr(fee, 'quantity', None) or 0))
share = segment.get('share')
if share is not None:
return round(Decimal(str(lot.lot_qt or 0)) * share, 5)
return Decimal(str(lot.lot_qt or 0))
return round(fee_quantity * share, 5)
return fee_quantity
if quantity is None:
qty = round(lot.get_current_quantity_converted(), 5)
return qty
return round(Decimal(str(quantity)), 5)
@classmethod
@@ -1596,17 +1592,15 @@ class ValuationBase(ModelSQL):
@classmethod
def create_pnl_der_from_line(cls, line):
Date = Pool().get('ir.date')
der_lines = []
Date = Pool().get('ir.date')
for d in line.derivatives or []:
price = Decimal(d.price_index.get_price_per_qt(
d.price, line.unit, line.purchase.currency
))
mtm_price = Decimal(d.price_index.get_price(
Date.today(), line.unit, line.purchase.currency, True
))
currency = line.purchase.currency
price, _market_price, amount = cls._derivative_pnl_values(
d, line.unit, currency)
base_amount, rate, base_currency = cls._base_amount_values(
amount, currency, line.purchase.company.currency)
der_lines.append({
'purchase': line.purchase.id,
@@ -1619,28 +1613,29 @@ class ValuationBase(ModelSQL):
'product': d.product.id,
'state': 'fixed',
'quantity': round(d.quantity, 5),
'amount': round(price * d.quantity * Decimal(-1), 2),
'mtm_price': round(mtm_price, 4),
'mtm': round((price * d.quantity * Decimal(-1)) - (mtm_price * d.quantity * Decimal(-1)), 2),
'amount': amount,
'base_amount': base_amount,
'base_currency': base_currency,
'rate': rate,
'mtm_price': None,
'mtm': None,
'unit': line.unit.id,
'currency': line.purchase.currency.id,
'currency': currency.id,
})
return der_lines
@classmethod
def create_pnl_der_from_sale_line(cls, sale_line):
Date = Pool().get('ir.date')
der_lines = []
Date = Pool().get('ir.date')
for d in sale_line.derivatives or []:
price = Decimal(d.price_index.get_price_per_qt(
d.price, sale_line.unit, sale_line.sale.currency
))
mtm_price = Decimal(d.price_index.get_price(
Date.today(), sale_line.unit, sale_line.sale.currency, True
))
currency = sale_line.sale.currency
price, _market_price, amount = cls._derivative_pnl_values(
d, sale_line.unit, currency)
base_amount, rate, base_currency = cls._base_amount_values(
amount, currency, sale_line.sale.company.currency)
der_lines.append({
'sale': sale_line.sale.id,
@@ -1653,14 +1648,31 @@ class ValuationBase(ModelSQL):
'product': d.product.id,
'state': 'fixed',
'quantity': round(d.quantity, 5),
'amount': round(price * d.quantity * Decimal(-1), 2),
'mtm_price': round(mtm_price, 4),
'mtm': round((price * d.quantity * Decimal(-1)) - (mtm_price * d.quantity * Decimal(-1)), 2),
'amount': amount,
'base_amount': base_amount,
'base_currency': base_currency,
'rate': rate,
'mtm_price': None,
'mtm': None,
'unit': sale_line.unit.id,
'currency': sale_line.sale.currency.id,
'currency': currency.id,
})
return der_lines
@classmethod
def _derivative_pnl_values(cls, derivative, unit, currency):
Date = Pool().get('ir.date')
entry_price = Decimal(derivative.price_index.get_price_per_qt(
derivative.price, unit, currency))
market_price = Decimal(derivative.price_index.get_price(
Date.today(), unit, currency, True))
quantity = Decimal(derivative.quantity or 0)
direction = Decimal(1)
if getattr(derivative, 'direction', None) == 'short':
direction = Decimal(-1)
amount = round((market_price - entry_price) * quantity * direction, 2)
return round(entry_price, 4), round(market_price, 4), amount
@classmethod
def generate(cls, line, valuation_type='all'):

View File

@@ -31,7 +31,7 @@
<field name="laytime_start_offset"/>
<label name="laytime_start_offset_unit"/>
<field name="laytime_start_offset_unit"/>
<separator string="Start candidates" colspan="4"/>
<separator id="start_candidates" string="Start candidates" colspan="4"/>
<field name="laytime_start_rules" colspan="4" mode="tree,form"
view_ids="purchase_trade.charter_condition_laytime_start_rule_view_tree,purchase_trade.charter_condition_laytime_start_rule_view_form"/>
<label name="laytime_clause"/>

View File

@@ -11,8 +11,8 @@
<label name="total_score"/>
<field name="total_score"/>
<newline/>
<field name="lines" colspan="4" mode="tree"
view_ids="purchase_trade.coffee_cupping_result_line_view_tree"/>
<field name="lines" colspan="4" mode="tree,form"
view_ids="purchase_trade.coffee_cupping_result_line_view_tree,purchase_trade.coffee_cupping_result_line_view_form"/>
<newline/>
<field name="notes" colspan="4"/>
</form>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0"?>
<form col="4">
<label name="criterion"/>
<field name="criterion"/>
<label name="score"/>
<field name="score" width="90" xexpand="0" xfill="0"/>
<label name="defect_found"/>
<field name="defect_found" xexpand="0" xfill="0"/>
<newline/>
<field name="notes" colspan="4"/>
</form>

View File

@@ -2,6 +2,8 @@
<form col="4">
<label name="sequence"/>
<field name="sequence"/>
<label name="session"/>
<field name="session"/>
<label name="sample"/>
<field name="sample"/>
<label name="public_origin"/>

View File

@@ -1,12 +1,12 @@
<?xml version="1.0"?>
<tree sequence="sequence">
<field name="sequence"/>
<field name="sample"/>
<field name="public_origin"/>
<field name="public_coffee_type"/>
<field name="cups_per_sample"/>
<field name="result_count"/>
<field name="average_score"/>
<field name="defect_count"/>
<field name="decision"/>
<field name="sequence" width="70"/>
<field name="sample" width="160"/>
<field name="public_origin" width="120"/>
<field name="public_coffee_type" width="110"/>
<field name="cups_per_sample" width="110"/>
<field name="result_count" width="90"/>
<field name="average_score" width="90"/>
<field name="defect_count" width="100"/>
<field name="decision" width="110"/>
</tree>

View File

@@ -1,14 +1,14 @@
<?xml version="1.0"?>
<tree>
<field name="sample"/>
<field name="lab"/>
<field name="state"/>
<field name="sent_date"/>
<field name="due_date"/>
<field name="received_date"/>
<field name="report_reference"/>
<field name="moisture"/>
<field name="defect_count"/>
<field name="cup_score"/>
<field name="compliant"/>
<field name="sample" width="160"/>
<field name="lab" width="140"/>
<field name="state" width="110"/>
<field name="sent_date" width="100"/>
<field name="due_date" width="100"/>
<field name="received_date" width="100"/>
<field name="report_reference" width="140"/>
<field name="moisture" width="90"/>
<field name="defect_count" width="100"/>
<field name="cup_score" width="90"/>
<field name="compliant" width="90"/>
</tree>

View File

@@ -2,99 +2,109 @@
<form col="12">
<group id="sample_summary" colspan="12" col="8">
<label name="reference"/>
<field name="reference"/>
<field name="reference" width="220" xexpand="0" xfill="0"/>
<label name="state"/>
<field name="state"/>
<field name="state" width="160" xexpand="0" xfill="0"/>
<label name="quality_status"/>
<field name="quality_status"/>
<field name="quality_status" width="140" xexpand="0" xfill="0"/>
<label name="recommended_decision"/>
<field name="recommended_decision"/>
<field name="recommended_decision" width="140" xexpand="0" xfill="0"/>
</group>
<button name="receive" string="Receive"/>
<button name="send_to_lab" string="Send to lab"/>
<button name="review" string="Review"/>
<button name="evaluate_quality" string="Evaluate quality"/>
<button name="approve" string="Approve"/>
<button name="reject" string="Reject"/>
<button name="expire" string="Expire"/>
<button name="archive" string="Archive"/>
<button name="reset_to_requested" string="Reset"/>
<newline/>
<group id="sample_identity" string="Sample identity" colspan="6" col="4">
<label name="direction"/>
<field name="direction"/>
<label name="sample_type"/>
<field name="sample_type"/>
<label name="purchase_line"/>
<field name="purchase_line"/>
<label name="sale_line"/>
<field name="sale_line"/>
<label name="lot"/>
<field name="lot"/>
<label name="product"/>
<field name="product"/>
<label name="party"/>
<field name="party"/>
<label name="public_origin"/>
<field name="public_origin"/>
<label name="public_coffee_type"/>
<field name="public_coffee_type"/>
<group id="sample_actions" colspan="12" col="9">
<button name="receive" string="Receive"/>
<button name="send_to_lab" string="Send to lab"/>
<button name="review" string="Review"/>
<button name="evaluate_quality" string="Evaluate quality"/>
<button name="approve" string="Approve"/>
<button name="reject" string="Reject"/>
<button name="expire" string="Expire"/>
<button name="archive" string="Archive"/>
<button name="reset_to_requested" string="Reset"/>
</group>
<group id="sample_lifecycle" string="Lifecycle" colspan="6" col="4">
<label name="requested_date"/>
<field name="requested_date"/>
<label name="received_date"/>
<field name="received_date"/>
<label name="sent_date"/>
<field name="sent_date"/>
<label name="review_date"/>
<field name="review_date"/>
<label name="decision_date"/>
<field name="decision_date"/>
<label name="shelf_life_days"/>
<field name="shelf_life_days"/>
<label name="expiry_date"/>
<field name="expiry_date"/>
<label name="expires_soon"/>
<field name="expires_soon"/>
<group id="sample_left_column" colspan="7" col="1">
<group id="sample_identity" string="Sample identity" colspan="1" col="4">
<label name="direction"/>
<field name="direction" width="160" xexpand="0" xfill="0"/>
<label name="sample_type"/>
<field name="sample_type" width="220" xexpand="0" xfill="0"/>
<label name="purchase_line"/>
<field name="purchase_line" colspan="3"/>
<label name="sale_line"/>
<field name="sale_line" colspan="3"/>
<label name="lot"/>
<field name="lot"/>
<label name="product"/>
<field name="product"/>
<label name="party"/>
<field name="party"/>
<label name="public_origin"/>
<field name="public_origin" width="160" xexpand="0" xfill="0"/>
<label name="public_coffee_type"/>
<field name="public_coffee_type" width="140" xexpand="0" xfill="0"/>
</group>
<group id="sample_storage" string="Sample storage" colspan="1" col="4">
<label name="quantity"/>
<field name="quantity" width="120" xexpand="0" xfill="0"/>
<label name="unit"/>
<field name="unit"/>
<label name="storage_location"/>
<field name="storage_location" colspan="3"/>
</group>
<group id="sample_lab" string="Lab delegation" colspan="1" col="6">
<label name="delegated_to_lab"/>
<field name="delegated_to_lab" xexpand="0" xfill="0"/>
<label name="lab"/>
<field name="lab"/>
<label name="lab_due_date"/>
<field name="lab_due_date" width="120" xexpand="0" xfill="0"/>
</group>
</group>
<group id="sample_storage" string="Sample storage" colspan="6" col="4">
<label name="quantity"/>
<field name="quantity"/>
<label name="unit"/>
<field name="unit"/>
<label name="storage_location"/>
<field name="storage_location"/>
</group>
<group id="sample_quality" string="Quality values" colspan="6" col="4">
<label name="moisture"/>
<field name="moisture" width="120" xexpand="0" xfill="0"/>
<label name="screen_size"/>
<field name="screen_size"/>
<label name="defect_count"/>
<field name="defect_count" width="120" xexpand="0" xfill="0"/>
<label name="cupping_average_score"/>
<field name="cupping_average_score" width="120" xexpand="0" xfill="0"/>
<label name="lab_analysis_status"/>
<field name="lab_analysis_status"/>
</group>
<group id="sample_lab" string="Lab delegation" colspan="12" col="4">
<label name="delegated_to_lab"/>
<field name="delegated_to_lab"/>
<label name="lab"/>
<field name="lab"/>
<label name="lab_due_date"/>
<field name="lab_due_date"/>
<newline/>
<field name="lab_analyses" colspan="4" mode="tree,form"
view_ids="purchase_trade.coffee_lab_analysis_view_tree,purchase_trade.coffee_lab_analysis_view_form"/>
</group>
<group id="sample_cupping" string="Cupping sessions" colspan="12" col="1">
<field name="cupping_lines" colspan="1" mode="tree"
view_ids="purchase_trade.coffee_cupping_session_sample_view_tree"/>
</group>
<group id="sample_notes" string="Notes" colspan="12" col="1">
<field name="notes"/>
<field name="decision_note"/>
<group id="sample_right_column" colspan="5" col="1">
<group id="sample_lifecycle" string="Lifecycle" colspan="1" col="4">
<label name="requested_date"/>
<field name="requested_date" width="120" xexpand="0" xfill="0"/>
<label name="received_date"/>
<field name="received_date" width="120" xexpand="0" xfill="0"/>
<label name="sent_date"/>
<field name="sent_date" width="120" xexpand="0" xfill="0"/>
<label name="review_date"/>
<field name="review_date" width="120" xexpand="0" xfill="0"/>
<label name="decision_date"/>
<field name="decision_date" width="120" xexpand="0" xfill="0"/>
<label name="shelf_life_days"/>
<field name="shelf_life_days" width="80" xexpand="0" xfill="0"/>
<label name="expiry_date"/>
<field name="expiry_date" width="120" xexpand="0" xfill="0"/>
<label name="expires_soon"/>
<field name="expires_soon" xexpand="0" xfill="0"/>
</group>
<group id="sample_quality" string="Quality values" colspan="1" col="4">
<label name="moisture"/>
<field name="moisture" width="90" xexpand="0" xfill="0"/>
<label name="screen_size"/>
<field name="screen_size" width="90" xexpand="0" xfill="0"/>
<label name="defect_count"/>
<field name="defect_count" width="90" xexpand="0" xfill="0"/>
<label name="cupping_average_score"/>
<field name="cupping_average_score" width="90" xexpand="0" xfill="0"/>
<label name="lab_analysis_status"/>
<field name="lab_analysis_status" colspan="3" width="180" xexpand="0" xfill="0"/>
</group>
</group>
<notebook colspan="12">
<page string="Lab analyses" id="lab_analyses" col="1">
<field name="lab_analyses" colspan="1" mode="tree,form"
view_ids="purchase_trade.coffee_lab_analysis_view_tree,purchase_trade.coffee_lab_analysis_view_form"
height="180"/>
</page>
<page string="Cupping sessions" id="cupping_sessions" col="1">
<field name="cupping_lines" colspan="1" mode="tree,form"
view_ids="purchase_trade.coffee_cupping_session_sample_view_tree,purchase_trade.coffee_cupping_session_sample_view_form"
height="160"/>
</page>
<page string="Notes" id="notes" col="2">
<field name="notes"/>
<field name="decision_note"/>
</page>
</notebook>
</form>

View File

@@ -2,15 +2,19 @@
col_widths="1fr,1fr,1fr,1fr,1fr,1fr,1fr,1fr,1fr,1fr,1fr,1fr">
<field name="transport_missing" invisible="1"/>
<group id="physical_lot_summary" colspan="12" col="6"
<group id="physical_lot_summary" colspan="12" col="10"
panel="summary" xalign="0" yalign="0"
col_widths="min-content,160px,min-content,140px,min-content,60px">
col_widths="min-content,160px,min-content,140px,min-content,60px,min-content,80px,min-content,80px">
<label name="quantity"/>
<field name="quantity"/>
<label name="unit"/>
<field name="unit"/>
<label name="unlink_remainder"/>
<field name="unlink_remainder"/>
<label name="mark_purchase_line_finished"/>
<field name="mark_purchase_line_finished"/>
<label name="mark_sale_line_finished"/>
<field name="mark_sale_line_finished"/>
</group>
<group id="physical_lot_transport" string="Transport"

View File

@@ -0,0 +1,7 @@
<?xml version="1.0"?>
<form col="2">
<label name="mark_purchase_line_finished"/>
<field name="mark_purchase_line_finished"/>
<label name="mark_sale_line_finished"/>
<field name="mark_sale_line_finished"/>
</form>

View File

@@ -33,7 +33,7 @@
<field name="r_operator" width="130" optional="1"/>
<field name="r_bl_date" width="90" optional="1"/>
<field name="r_bl_number" width="110" optional="1"/>
<field name="r_shipping_status" widget="badge" string="Shipping status" badge_colors="shipped:#16a34a,unshipped:#ef4444,planned:#06b6d4,scheduled:#f59e0b" width="120"/>
<field name="r_shipping_status" widget="badge" string="Shipping status" badge_colors="shipped:#16a34a,unshipped:#ef4444,planned:#06b6d4,scheduled:#f59e0b,received:#64748b" width="120"/>
<field name="r_lot_status"/>
<field name="r_lot_av" width="80" optional="1" tree_invisible="1"/>
<field name="r_purchase_currency_symbol" tree_invisible="1"/>

View File

@@ -8,6 +8,8 @@
<field name="create_new_shipment"/>
<label name="shipment_supplier"/>
<field name="shipment_supplier"/>
<label name="shipment_vessel"/>
<field name="shipment_vessel"/>
<label name="shipment_out"/>
<field name="shipment_out"/>
<label name="shipment_internal"/>

View File

@@ -4,5 +4,10 @@
<label name="allow_modification_after_validation"/>
<field name="allow_modification_after_validation"/>
<newline/>
<label name="auto_hedging"/>
<field name="auto_hedging"/>
<label name="auto_hedging_over"/>
<field name="auto_hedging_over"/>
<newline/>
</xpath>
</data>

View File

@@ -39,6 +39,17 @@ this repository contains the full copyright notices and license terms. -->
<label name="unit"/>
<field name="unit"/>
</group>
<group id="coffee_packing" string="Packing"
colspan="1" col="4" panel="card" icon="tryton-archive"
xalign="0" yalign="0"
col_widths="min-content,1fr,min-content,1fr">
<label name="coffee_packing_count"/>
<field name="coffee_packing_count" width="120" xexpand="0" xfill="0"/>
<label name="coffee_packing_unit"/>
<field name="coffee_packing_unit"/>
<label name="coffee_packing_description"/>
<field name="coffee_packing_description"/>
</group>
<group id="tolerances" string="Tolerances &amp; Inheritance"
colspan="1" col="4" panel="card" icon="tryton-switch"
xalign="0" yalign="0"
@@ -110,7 +121,7 @@ this repository contains the full copyright notices and license terms. -->
<group id="currencies_links" string="Currencies &amp; Links"
colspan="1" col="4" panel="card" icon="tryton-link"
xalign="0" yalign="0"
col_widths="min-content,min-content,min-content,1fr">
col_widths="min-content,1fr,min-content,1fr">
<label name="enable_linked_currency"/>
<field name="enable_linked_currency" xexpand="0" xfill="0"/>
<label name="linked_currency"/>
@@ -124,6 +135,17 @@ this repository contains the full copyright notices and license terms. -->
<label name="certif"/>
<field name="certif" colspan="3"/>
</group>
<group id="coffee_market_price" string="Market price"
colspan="1" col="4" panel="card" icon="tradon-price"
xalign="0" yalign="0"
col_widths="min-content,1fr,min-content,1fr">
<label name="coffee_market_reference"/>
<field name="coffee_market_reference"/>
<label name="coffee_market_price"/>
<field name="coffee_market_price" width="120" xexpand="0" xfill="0"/>
<label name="coffee_market_delta"/>
<field name="coffee_market_delta" width="120" xexpand="0" xfill="0"/>
</group>
</group>
<group id="attributes_description" string="Description"
colspan="12" col="1" panel="card" icon="tryton-note"

View File

@@ -4,5 +4,10 @@
<label name="allow_modification_after_validation"/>
<field name="allow_modification_after_validation"/>
<newline/>
<label name="auto_hedging"/>
<field name="auto_hedging"/>
<label name="auto_hedging_over"/>
<field name="auto_hedging_over"/>
<newline/>
</xpath>
</data>

View File

@@ -36,6 +36,17 @@ this repository contains the full copyright notices and license terms. -->
<label name="unit"/>
<field name="unit"/>
</group>
<group id="coffee_packing" string="Packing"
colspan="1" col="4" panel="card" icon="tryton-archive"
xalign="0" yalign="0"
col_widths="min-content,1fr,min-content,1fr">
<label name="coffee_packing_count"/>
<field name="coffee_packing_count" width="120" xexpand="0" xfill="0"/>
<label name="coffee_packing_unit"/>
<field name="coffee_packing_unit"/>
<label name="coffee_packing_description"/>
<field name="coffee_packing_description"/>
</group>
<group id="tolerances" string="Tolerances &amp; Inheritance"
colspan="1" col="4" panel="card" icon="tryton-switch"
xalign="0" yalign="0"
@@ -103,7 +114,7 @@ this repository contains the full copyright notices and license terms. -->
<group id="currencies_links" string="Currencies &amp; Links"
colspan="1" col="4" panel="card" icon="tryton-link"
xalign="0" yalign="0"
col_widths="min-content,min-content,min-content,1fr">
col_widths="min-content,1fr,min-content,1fr">
<label name="enable_linked_currency"/>
<field name="enable_linked_currency" xexpand="0" xfill="0"/>
<label name="linked_currency"/>
@@ -117,6 +128,17 @@ this repository contains the full copyright notices and license terms. -->
<label name="certif"/>
<field name="certif" colspan="3"/>
</group>
<group id="coffee_market_price" string="Market price"
colspan="1" col="4" panel="card" icon="tradon-price"
xalign="0" yalign="0"
col_widths="min-content,1fr,min-content,1fr">
<label name="coffee_market_reference"/>
<field name="coffee_market_reference"/>
<label name="coffee_market_price"/>
<field name="coffee_market_price" width="120" xexpand="0" xfill="0"/>
<label name="coffee_market_delta"/>
<field name="coffee_market_delta" width="120" xexpand="0" xfill="0"/>
</group>
</group>
<group id="description" string="Description"
colspan="12" col="1" panel="card" icon="tryton-note"

View File

@@ -4,5 +4,16 @@ this repository contains the full copyright notices and license terms. -->
<data>
<xpath expr="/tree/field[@name='number']" position="after">
<field name="bl_number"/>
<field name="bl_date" optional="1"/>
</xpath>
<xpath expr="/tree/field[@name='reference']" position="after">
<field name="vessel" string="Vessel name" optional="1"/>
<field name="list_from_locations" optional="1"/>
<field name="list_to_locations" optional="1"/>
</xpath>
<xpath expr="/tree/field[@name='supplier']" position="replace">
<field name="carrier_" string="SSCO/Owner" expand="2" optional="0"/>
<field name="supplier" string="Broker" optional="1"/>
<field name="list_purchase_suppliers" optional="1"/>
</xpath>
</data>

View File

@@ -1596,11 +1596,14 @@ class SaleLine(TaxableMixin, sequence_ordered(), ModelSQL, ModelView):
# In case we're called from an on_change
# we have to use some sensible defaults
if getattr(self, 'type', None) == 'line':
Date = Pool().get('ir.date')
sale = getattr(self, 'sale', None)
tax_date = getattr(sale, 'sale_date', None) or Date.today()
return [(
getattr(self, 'taxes', None) or [],
getattr(self, 'unit_price', None) or Decimal(0),
getattr(self, 'quantity', None) or 0,
None,
tax_date,
)]
else:
return []

View File

@@ -1 +0,0 @@
,PC_LO/baron,PC_LO,14.07.2026 15:18,file:///C:/Users/baron/AppData/Roaming/LibreOffice/4;

File diff suppressed because it is too large Load Diff