Replace em-dashes and en-dashes with hyphens across 789 shipped source files (py/xml/js/scss) so the delivered module reads as human-written; em-dashes had become a recognizable AI-generated tell. Internal .md dev notes are excluded. The WO-sticker mojibake strippers keep their dash search targets (now written — / –). No logic changes: comments and display strings only; validated with py_compile + lxml parse. Rewrite the 7 customer notification emails to be intake-neutral (ship-in / drop-off / pickup) and repair-aware, and fix the Shipped email documents line (packing slip vs bill of lading; certificate only when issued). Subjects use a hyphen separator. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
112 lines
4.7 KiB
Python
112 lines
4.7 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Copyright 2026 Nexa Systems Inc.
|
|
"""Per-box registry for box-level tracking.
|
|
|
|
One `fp.box` per physical box received against a `fp.receiving`. Auto-created
|
|
when the receiver enters `box_count_in` and marks the receiving Counted
|
|
(see `fp.receiving._fp_sync_boxes`). Each box carries a sequence number
|
|
(n of N), a status that advances through the shop, and a scannable identity
|
|
(`/fp/box/<id>`) printed on the External Job Sticker - one label per box.
|
|
|
|
Box-level tracking (not box CONTENTS): we track WHICH box and WHERE it is,
|
|
not the per-box part breakdown. The same boxes go back to the customer
|
|
(Sub 8), so reconciliation flags any box that never reaches `shipped`.
|
|
"""
|
|
from odoo import api, fields, models, _
|
|
|
|
STATE_ORDER = ['received', 'racked', 'in_process', 'packed', 'shipped']
|
|
|
|
|
|
class FpBox(models.Model):
|
|
_name = 'fp.box'
|
|
_description = 'Fusion Plating - Tracked Box'
|
|
_inherit = ['mail.thread']
|
|
_order = 'receiving_id, box_number'
|
|
|
|
name = fields.Char(string='Box', compute='_compute_name', store=True)
|
|
box_number = fields.Integer(string='Box #', required=True, default=1, tracking=True)
|
|
box_count = fields.Integer(string='Of', tracking=True,
|
|
help='Total boxes in this receiving (N in "n of N").')
|
|
|
|
receiving_id = fields.Many2one('fp.receiving', string='Receiving', required=True,
|
|
ondelete='cascade', index=True)
|
|
sale_order_id = fields.Many2one('sale.order', string='Sale Order',
|
|
related='receiving_id.sale_order_id', store=True, index=True)
|
|
partner_id = fields.Many2one('res.partner', string='Customer',
|
|
related='receiving_id.partner_id', store=True)
|
|
job_id = fields.Many2one('fp.job', string='Work Order', index=True,
|
|
help='Resolved job for this box (single-job SO). '
|
|
'The sticker resolves boxes via the SO when blank.')
|
|
company_id = fields.Many2one('res.company', string='Company',
|
|
default=lambda self: self.env.company, index=True)
|
|
|
|
state = fields.Selection([
|
|
('received', 'Received'),
|
|
('racked', 'Racked'),
|
|
('in_process', 'In Process'),
|
|
('packed', 'Packed'),
|
|
('shipped', 'Shipped'),
|
|
('lost', 'Lost'),
|
|
('cancelled', 'Cancelled'),
|
|
], string='Status', default='received', required=True, tracking=True, index=True)
|
|
|
|
location_note = fields.Char(string='Location / Note', tracking=True,
|
|
help='Free text - where is this box now (rack, bay, shelf).')
|
|
scan_url = fields.Char(string='Scan URL', compute='_compute_scan_url')
|
|
|
|
_box_uniq = models.Constraint(
|
|
'UNIQUE(receiving_id, box_number)',
|
|
'Box number must be unique within a receiving.')
|
|
|
|
# ------------------------------------------------------------------ computes
|
|
@api.depends('box_number', 'box_count', 'receiving_id.name', 'sale_order_id.name')
|
|
def _compute_name(self):
|
|
for rec in self:
|
|
base = rec.receiving_id.name or (rec.sale_order_id.name if rec.sale_order_id else '') or 'BOX'
|
|
rec.name = '%s · Box %d/%d' % (base, rec.box_number or 1, rec.box_count or 1)
|
|
|
|
def _compute_scan_url(self):
|
|
base = self.env['ir.config_parameter'].sudo().get_param('web.base.url', '')
|
|
for rec in self:
|
|
rec.scan_url = ('%s/fp/box/%s' % (base, rec.id)) if rec.id else ''
|
|
|
|
# ------------------------------------------------------------------ workflow
|
|
def _set_state(self, new_state):
|
|
for rec in self:
|
|
old = dict(rec._fields['state'].selection).get(rec.state, rec.state)
|
|
new = dict(rec._fields['state'].selection).get(new_state, new_state)
|
|
rec.state = new_state
|
|
rec.message_post(body=_(
|
|
'Box %(n)s/%(N)s: %(old)s → %(new)s by %(u)s'
|
|
) % {'n': rec.box_number, 'N': rec.box_count,
|
|
'old': old, 'new': new, 'u': self.env.user.name})
|
|
|
|
def action_set_racked(self):
|
|
self._set_state('racked')
|
|
|
|
def action_set_in_process(self):
|
|
self._set_state('in_process')
|
|
|
|
def action_set_packed(self):
|
|
self._set_state('packed')
|
|
|
|
def action_set_shipped(self):
|
|
self._set_state('shipped')
|
|
|
|
def action_set_lost(self):
|
|
self._set_state('lost')
|
|
|
|
def action_reset_received(self):
|
|
self._set_state('received')
|
|
|
|
def action_open_record(self):
|
|
"""Used by the /fp/box/<id> scan endpoint to land on the box form."""
|
|
self.ensure_one()
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'fp.box',
|
|
'res_id': self.id,
|
|
'view_mode': 'form',
|
|
'target': 'current',
|
|
}
|