diff --git a/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py b/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py index 52715b5f..50647a3e 100644 --- a/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py +++ b/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py @@ -5,7 +5,7 @@ { "name": "Fusion Plating — MRP Bridge", - 'version': '19.0.6.1.0', + 'version': '19.0.6.2.0', 'category': 'Manufacturing/Plating', 'summary': 'Bridge Fusion Plating facilities, baths and tanks to Odoo MRP work orders.', 'description': """ diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/fp_proficiency.py b/fusion_plating/fusion_plating_bridge_mrp/models/fp_proficiency.py index e8ca3437..7fb38e86 100644 --- a/fusion_plating/fusion_plating_bridge_mrp/models/fp_proficiency.py +++ b/fusion_plating/fusion_plating_bridge_mrp/models/fp_proficiency.py @@ -13,6 +13,8 @@ Shop Roles automatically. The operator never has to fill in a form; their growing skill set just unlocks itself. """ +from markupsafe import Markup + from odoo import _, api, fields, models @@ -160,13 +162,14 @@ class FpOperatorProficiency(models.Model): 'x_fc_work_role_ids': [(4, role.id)], }) employee.message_post( - body=_( + body=Markup(_( '🎉 %(name)s promoted — qualified for ' '%(role)s after %(count)s successful ' - 'completions.', - name=employee.name, - role=role.name, - count=rec.completed_count, - ), + 'completions.' + )) % { + 'name': employee.name, + 'role': role.name, + 'count': rec.completed_count, + }, subtype_xmlid='mail.mt_note', ) diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/mrp_production.py b/fusion_plating/fusion_plating_bridge_mrp/models/mrp_production.py index da07c4ca..d2112145 100644 --- a/fusion_plating/fusion_plating_bridge_mrp/models/mrp_production.py +++ b/fusion_plating/fusion_plating_bridge_mrp/models/mrp_production.py @@ -5,6 +5,8 @@ import logging +from markupsafe import Markup + from odoo import api, fields, models, _ from odoo.exceptions import UserError @@ -423,7 +425,7 @@ class MrpProduction(models.Model): steps_txt = wo_steps.get(wo.sequence) if steps_txt: wo.message_post( - body=_('Recipe steps:
%s
') % steps_txt, + body=Markup(_('Recipe steps:
%s
')) % steps_txt, subtype_xmlid='mail.mt_note', ) production.message_post( diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/sale_order.py b/fusion_plating/fusion_plating_bridge_mrp/models/sale_order.py index 0ba0b80b..740d64d0 100644 --- a/fusion_plating/fusion_plating_bridge_mrp/models/sale_order.py +++ b/fusion_plating/fusion_plating_bridge_mrp/models/sale_order.py @@ -3,6 +3,8 @@ # License OPL-1 (Odoo Proprietary License v1.0) # Part of the Fusion Plating product family. +from markupsafe import Markup + from odoo import api, fields, models, _ @@ -86,8 +88,8 @@ class SaleOrder(models.Model): # Don't block SO confirm — log + continue. The manager # can still create the MO manually. so.message_post( - body=_('Auto-MO creation failed: %s. ' - 'Create the MO manually from MRP.') % exc, + body=Markup(_('Auto-MO creation failed: %s. ' + 'Create the MO manually from MRP.')) % exc, ) return res @@ -145,11 +147,11 @@ class SaleOrder(models.Model): if recipe and 'x_fc_recipe_id' in Production._fields: mo_vals['x_fc_recipe_id'] = recipe.id mo = Production.create(mo_vals) - self.message_post(body=_( + self.message_post(body=Markup(_( 'Draft Manufacturing Order %s ' 'auto-created. Accept the parts and click Assign to Me to ' 'release it to the floor.' - ) % (mo.id, mo.name)) + )) % (mo.id, mo.name)) @api.depends( 'state', 'invoice_status', @@ -182,17 +184,22 @@ class SaleOrder(models.Model): )) # Paid vs invoiced - if so.invoice_status == 'invoiced' and so.invoice_ids: - latest = so.invoice_ids.filtered(lambda i: i.state == 'posted') - all_paid = latest and all( - i.payment_state in ('paid', 'in_payment') for i in latest - ) - if shipped and all_paid: - so.x_fc_workflow_stage = 'complete' - continue - if all_paid and not shipped: - so.x_fc_workflow_stage = 'paid' - continue + posted_invoices = so.invoice_ids.filtered(lambda i: i.state == 'posted') + has_posted_invoice = bool(posted_invoices) + all_paid = has_posted_invoice and all( + i.payment_state in ('paid', 'in_payment') for i in posted_invoices + ) + if shipped and all_paid: + so.x_fc_workflow_stage = 'complete' + continue + if all_paid and not shipped: + so.x_fc_workflow_stage = 'paid' + continue + # Once an invoice is posted (regardless of payment), the SO has + # moved past 'shipped' — the action is on accounting, not us. + if shipped and has_posted_invoice: + so.x_fc_workflow_stage = 'invoicing' + continue if shipped: so.x_fc_workflow_stage = 'shipped' @@ -263,7 +270,7 @@ class SaleOrder(models.Model): if 'x_fc_assigned_manager_id' in mo._fields and not mo.x_fc_assigned_manager_id: mo.x_fc_assigned_manager_id = user.id self.message_post( - body=_('Job assigned to %s. %d MO(s) released to the floor.') + body=Markup(_('Job assigned to %s. %d MO(s) released to the floor.')) % (user.name, len(mos)), ) return True diff --git a/fusion_plating/fusion_plating_bridge_mrp/views/sale_order_views.xml b/fusion_plating/fusion_plating_bridge_mrp/views/sale_order_views.xml index aab6f25a..a8b59f3d 100644 --- a/fusion_plating/fusion_plating_bridge_mrp/views/sale_order_views.xml +++ b/fusion_plating/fusion_plating_bridge_mrp/views/sale_order_views.xml @@ -92,12 +92,15 @@ help="Close the open delivery record(s) and fire auto-invoice per strategy."/> - - + +
+ invisible="x_fc_workflow_stage in ('draft', 'invoicing', 'paid', 'complete', 'cancelled')"> Current stage: diff --git a/fusion_plating/fusion_plating_configurator/__manifest__.py b/fusion_plating/fusion_plating_configurator/__manifest__.py index 92aac477..9bea941c 100644 --- a/fusion_plating/fusion_plating_configurator/__manifest__.py +++ b/fusion_plating/fusion_plating_configurator/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — Configurator', - 'version': '19.0.5.0.0', + 'version': '19.0.5.1.0', 'category': 'Manufacturing/Plating', 'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.', 'description': """ diff --git a/fusion_plating/fusion_plating_configurator/models/fp_part_catalog.py b/fusion_plating/fusion_plating_configurator/models/fp_part_catalog.py index 1f4d4c14..cf2049ee 100644 --- a/fusion_plating/fusion_plating_configurator/models/fp_part_catalog.py +++ b/fusion_plating/fusion_plating_configurator/models/fp_part_catalog.py @@ -3,6 +3,8 @@ # License OPL-1 (Odoo Proprietary License v1.0) # Part of the Fusion Plating product family. +from markupsafe import Markup + from odoo import api, fields, models, _ @@ -235,11 +237,11 @@ class FpPartCatalog(models.Model): old = snap['model'] new = rec.model_attachment_id if not old and new: - messages.append(_('3D model attached: %s') % new.name) + messages.append(Markup(_('3D model attached: %s')) % new.name) elif old and not new: - messages.append(_('3D model removed: %s') % old.name) + messages.append(Markup(_('3D model removed: %s')) % old.name) elif old and new and old.id != new.id: - messages.append(_('3D model changed: %s → %s') % (old.name, new.name)) + messages.append(Markup(_('3D model changed: %s → %s')) % (old.name, new.name)) # Drawing changes (added or removed) if track_drawings: @@ -250,15 +252,15 @@ class FpPartCatalog(models.Model): for att_id in added: att = self.env['ir.attachment'].browse(att_id) if att.exists(): - messages.append(_('Drawing attached: %s') % att.name) + messages.append(Markup(_('Drawing attached: %s')) % att.name) for att_id in removed: att = self.env['ir.attachment'].browse(att_id) # Browse even if deleted — may still have name if not purged name = att.exists() and att.name or f'#{att_id}' - messages.append(_('Drawing removed: %s') % name) + messages.append(Markup(_('Drawing removed: %s')) % name) if messages: - body = '
'.join(messages) + body = Markup('
').join(messages) # Post to part catalog chatter rec.message_post( body=body, @@ -271,7 +273,7 @@ class FpPartCatalog(models.Model): ]) for cfg in configurators: cfg.message_post( - body=_('Part %s: %s') % (rec.name, body), + body=Markup(_('Part %s: %s')) % (rec.name, body), message_type='notification', subtype_xmlid='mail.mt_note', ) diff --git a/fusion_plating/fusion_plating_configurator/models/fp_quote_configurator.py b/fusion_plating/fusion_plating_configurator/models/fp_quote_configurator.py index fa0eea24..9a914bba 100644 --- a/fusion_plating/fusion_plating_configurator/models/fp_quote_configurator.py +++ b/fusion_plating/fusion_plating_configurator/models/fp_quote_configurator.py @@ -5,6 +5,8 @@ import math +from markupsafe import Markup + from odoo import api, fields, models, _ from odoo.exceptions import UserError @@ -549,7 +551,7 @@ class FpQuoteConfigurator(models.Model): 'won_date': fields.Date.today(), }) self.message_post( - body=_('Sale Order %s created.') % (so.id, so.name), + body=Markup(_('Sale Order %s created.')) % (so.id, so.name), ) return { 'type': 'ir.actions.act_window', @@ -623,7 +625,7 @@ class FpQuoteConfigurator(models.Model): # Post to chatter so user sees confirmation (only if record is saved) if self.id and not isinstance(self.id, models.NewId): self.sudo().message_post( - body=_('3D model attached: %s — surface area: %.4f %s') % ( + body=Markup(_('3D model attached: %s — surface area: %.4f %s')) % ( fname, self.surface_area, self.surface_area_uom or ''), message_type='notification', subtype_xmlid='mail.mt_note', @@ -666,7 +668,7 @@ class FpQuoteConfigurator(models.Model): # Post to chatter so user sees confirmation (only if record is saved) if self.id and not isinstance(self.id, models.NewId): self.sudo().message_post( - body=_('Drawing attached: %s (linked to part %s)') % ( + body=Markup(_('Drawing attached: %s (linked to part %s)')) % ( fname, part.name), message_type='notification', subtype_xmlid='mail.mt_note', @@ -838,7 +840,7 @@ class FpQuoteConfigurator(models.Model): 'complexity': self.complexity, }) self.message_post( - body=_('Geometry and material saved back to part catalog %s.') % self.part_catalog_id.name, + body=Markup(_('Geometry and material saved back to part catalog %s.')) % self.part_catalog_id.name, message_type='notification', subtype_xmlid='mail.mt_note', ) diff --git a/fusion_plating/fusion_plating_portal/__manifest__.py b/fusion_plating/fusion_plating_portal/__manifest__.py index 635fd475..2f340d62 100644 --- a/fusion_plating/fusion_plating_portal/__manifest__.py +++ b/fusion_plating/fusion_plating_portal/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — Customer Portal', - 'version': '19.0.2.0.0', + 'version': '19.0.2.1.0', 'category': 'Manufacturing/Plating', 'summary': 'Customer-facing portal for plating shops: online RFQ, job status, ' 'CoC downloads, invoice access.', diff --git a/fusion_plating/fusion_plating_portal/models/fp_quote_request.py b/fusion_plating/fusion_plating_portal/models/fp_quote_request.py index a4f47c54..18359d35 100644 --- a/fusion_plating/fusion_plating_portal/models/fp_quote_request.py +++ b/fusion_plating/fusion_plating_portal/models/fp_quote_request.py @@ -3,6 +3,8 @@ # License OPL-1 (Odoo Proprietary License v1.0) # Part of the Fusion Plating product family. +from markupsafe import Markup + from odoo import _, api, fields, models @@ -242,11 +244,9 @@ class FpQuoteRequest(models.Model): # Link back self.write({'state': 'accepted'}) - self.message_post(body=_( - 'Sale Order %(so_name)s created.', - so_id=so.id, - so_name=so.name, - )) + self.message_post(body=Markup(_( + 'Sale Order %(so_name)s created.' + )) % {'so_id': so.id, 'so_name': so.name}) return { 'type': 'ir.actions.act_window', diff --git a/fusion_plating/fusion_plating_quality/__manifest__.py b/fusion_plating/fusion_plating_quality/__manifest__.py index 4131eeac..630d728b 100644 --- a/fusion_plating/fusion_plating_quality/__manifest__.py +++ b/fusion_plating/fusion_plating_quality/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — Quality (QMS)', - 'version': '19.0.1.0.0', + 'version': '19.0.1.1.0', 'category': 'Manufacturing/Plating', 'summary': 'Native QMS for plating shops: NCR, CAPA, calibration, AVL, FAIR, ' 'internal audits, customer specs, document control. CE + EE compatible.', diff --git a/fusion_plating/fusion_plating_quality/models/fp_quality_hold.py b/fusion_plating/fusion_plating_quality/models/fp_quality_hold.py index 2670d5e5..ed59c1de 100644 --- a/fusion_plating/fusion_plating_quality/models/fp_quality_hold.py +++ b/fusion_plating/fusion_plating_quality/models/fp_quality_hold.py @@ -3,6 +3,8 @@ # License OPL-1 (Odoo Proprietary License v1.0) # Part of the Fusion Plating product family. +from markupsafe import Markup + from odoo import api, fields, models @@ -178,7 +180,7 @@ class FpQualityHold(models.Model): def _post_state_message(self, label): for rec in self: rec.message_post( - body=f"Hold status changed to {label}.", + body=Markup("Hold status changed to %s.") % label, message_type='comment', subtype_xmlid='mail.mt_note', ) diff --git a/fusion_plating/fusion_plating_shopfloor/__manifest__.py b/fusion_plating/fusion_plating_shopfloor/__manifest__.py index 248a3d0e..bb3dd02f 100644 --- a/fusion_plating/fusion_plating_shopfloor/__manifest__.py +++ b/fusion_plating/fusion_plating_shopfloor/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — Shop Floor', - 'version': '19.0.14.0.0', + 'version': '19.0.14.1.0', 'category': 'Manufacturing/Plating', 'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, ' 'first-piece inspection gates.', diff --git a/fusion_plating/fusion_plating_shopfloor/controllers/manager_controller.py b/fusion_plating/fusion_plating_shopfloor/controllers/manager_controller.py index 264cae76..2ff6ab4d 100644 --- a/fusion_plating/fusion_plating_shopfloor/controllers/manager_controller.py +++ b/fusion_plating/fusion_plating_shopfloor/controllers/manager_controller.py @@ -5,6 +5,9 @@ """JSON-RPC endpoints for the Manager Dashboard (client action).""" import logging + +from markupsafe import Markup + from odoo import http from odoo.addons.fusion_plating.models.fp_tz import fp_format from odoo.http import request @@ -294,7 +297,7 @@ class FpManagerDashboardController(http.Controller): return {'ok': False, 'error': 'Work order not found.'} wo.x_fc_assigned_user_id = int(user_id) if user_id else False wo.message_post( - body=f'Worker assigned: {wo.x_fc_assigned_user_id.name or "Unassigned"}', + body=Markup('Worker assigned: %s') % (wo.x_fc_assigned_user_id.name or 'Unassigned'), ) return {'ok': True, 'user_name': wo.x_fc_assigned_user_id.name or ''} @@ -308,7 +311,7 @@ class FpManagerDashboardController(http.Controller): return {'ok': False, 'error': 'Work order not found.'} wo.x_fc_tank_id = int(tank_id) if tank_id else False wo.message_post( - body=f'Tank assigned: {wo.x_fc_tank_id.name or "Unassigned"}', + body=Markup('Tank assigned: %s') % (wo.x_fc_tank_id.name or 'Unassigned'), ) return {'ok': True, 'tank_name': wo.x_fc_tank_id.name or ''} @@ -324,6 +327,6 @@ class FpManagerDashboardController(http.Controller): previous = wo.x_fc_assigned_user_id.name or '—' wo.x_fc_assigned_user_id = user.id wo.message_post( - body=f'Manager takeover: {user.name} replaces {previous}.', + body=Markup('Manager takeover: %s replaces %s.') % (user.name, previous), ) return {'ok': True, 'user_name': user.name} diff --git a/fusion_plating/scripts/fp_verify_fixes.py b/fusion_plating/scripts/fp_verify_fixes.py new file mode 100644 index 00000000..c891d0b1 --- /dev/null +++ b/fusion_plating/scripts/fp_verify_fixes.py @@ -0,0 +1,32 @@ +env = env # noqa +# Pick the SO we last tested +so = env['sale.order'].search([('name', '=', 'S00038')], limit=1) +if not so: + print('S00038 not found, picking last sale.order') + so = env['sale.order'].search([], order='id desc', limit=1) +print(f'SO: {so.name}') +print(f' state: {so.state}') +print(f' invoice_status: {so.invoice_status}') +print(f' invoice_ids: {[(i.name, i.state, i.payment_state) for i in so.invoice_ids]}') +print(f' workflow_stage: {so.x_fc_workflow_stage}') +print(f' → BANNER VISIBLE? {so.x_fc_workflow_stage not in ("draft","invoicing","paid","complete","cancelled")}') + +# Post a fresh test message that exercises the new Markup path +mo = env['mrp.production'].search([('origin', '=', so.name)], limit=1) +if mo: + from markupsafe import Markup + so.message_post(body=Markup( + 'TEST: Draft Manufacturing Order %s ' + 'should render as a clickable link with bold text.' + ) % (mo.id, mo.name)) + print(f'\\nposted test message on {so.name} referencing {mo.name}') + +# Check the latest 2 messages on the SO +msgs = env['mail.message'].search([ + ('model', '=', 'sale.order'), ('res_id', '=', so.id), +], order='id desc', limit=3) +print(f'\\nLast {len(msgs)} chatter messages on {so.name}:') +for m in msgs: + body = (m.body or '')[:200] + print(f' [{m.id}] {body!r}') +env.cr.commit()