Compare commits
34 Commits
b3e8dc9c33
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 493dd54606 | |||
| ab80f8d909 | |||
| 2fe13c1e76 | |||
| a70c2c4853 | |||
| d6fa273d11 | |||
| 816d1fcaec | |||
| 8973f8a6d0 | |||
| 572f6ef902 | |||
| bd72f8cef3 | |||
| a94f36e26f | |||
| 669320c976 | |||
| facd627f5e | |||
| b2bd777ec8 | |||
| 71751a96c0 | |||
| c655427be5 | |||
| 0272fc70bc | |||
| 39afd9dc32 | |||
| 0e30f9ac30 | |||
| 8be68db6db | |||
| aba25d62dc | |||
| 4c85f80412 | |||
| a0bd6f0e28 | |||
| cda8b4067b | |||
| 728e2c7d60 | |||
| 4652a1a94a | |||
| b53d7a216d | |||
| 0e004027df | |||
| df8439f3f5 | |||
| cb35de91ba | |||
| 59171ec07b | |||
| 9b0dd0e7c1 | |||
| cf0c0923a6 | |||
| bdf13f4fac | |||
| e184da898f |
@@ -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">
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
# -------------------------------------------------------
|
||||
|
||||
128
modules/automation/booking_report.py
Normal file
128
modules/automation/booking_report.py
Normal 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
|
||||
}
|
||||
43
modules/automation/booking_report.xml
Normal file
43
modules/automation/booking_report.xml
Normal 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>
|
||||
@@ -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 = {
|
||||
|
||||
@@ -7,4 +7,5 @@ depends:
|
||||
xml:
|
||||
automation.xml
|
||||
freight_booking.xml
|
||||
cron.xml
|
||||
booking_report.xml
|
||||
cron.xml
|
||||
|
||||
@@ -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>
|
||||
@@ -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(
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'):
|
||||
|
||||
@@ -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'
|
||||
|
||||
861
modules/purchase_trade/docs/coffee_user_guide.en.html
Normal file
861
modules/purchase_trade/docs/coffee_user_guide.en.html
Normal 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>
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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_)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.')
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'):
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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 & 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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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 & 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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user