changes
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Configurator',
|
||||
'version': '19.0.17.0.0',
|
||||
'version': '19.0.17.13.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
|
||||
'description': """
|
||||
|
||||
@@ -167,23 +167,37 @@ class FpPartCatalog(models.Model):
|
||||
'Compose button to edit. When an order does not pick a '
|
||||
'specific variant, this one is used.',
|
||||
)
|
||||
process_variant_ids = fields.One2many(
|
||||
# Computed instead of plain One2many because the One2many `domain=`
|
||||
# was silently NOT being applied — `part.process_variant_ids` was
|
||||
# returning every node (root + children) for the part instead of
|
||||
# only the root recipe variants. Computing explicitly via search
|
||||
# is bulletproof and survives the Odoo 19 ORM rewrites. The store
|
||||
# is False because the underlying recipe-tree topology can change
|
||||
# outside this model (composer, drag/drop in editor, etc.) and we
|
||||
# want fresh reads.
|
||||
process_variant_ids = fields.Many2many(
|
||||
'fusion.plating.process.node',
|
||||
'part_catalog_id',
|
||||
compute='_compute_process_variant_ids',
|
||||
string='Process Variants',
|
||||
domain="[('parent_id', '=', False), ('node_type', '=', 'recipe')]",
|
||||
help='All recipe variants composed for this part. Each order line '
|
||||
'picks one (or falls back to the default).',
|
||||
help='Root recipe variants composed for this part. Each order '
|
||||
'line picks one (or falls back to the default).',
|
||||
)
|
||||
process_variant_count = fields.Integer(
|
||||
string='Variants',
|
||||
compute='_compute_process_variant_count',
|
||||
compute='_compute_process_variant_ids',
|
||||
)
|
||||
|
||||
@api.depends('process_variant_ids')
|
||||
def _compute_process_variant_count(self):
|
||||
@api.depends_context('uid')
|
||||
def _compute_process_variant_ids(self):
|
||||
Node = self.env['fusion.plating.process.node']
|
||||
for rec in self:
|
||||
rec.process_variant_count = len(rec.process_variant_ids)
|
||||
variants = Node.search([
|
||||
('part_catalog_id', '=', rec.id),
|
||||
('parent_id', '=', False),
|
||||
('node_type', '=', 'recipe'),
|
||||
])
|
||||
rec.process_variant_ids = variants
|
||||
rec.process_variant_count = len(variants)
|
||||
|
||||
# ---- Direct-order defaults (Phase C — C4) ----
|
||||
x_fc_default_coating_config_id = fields.Many2one(
|
||||
@@ -360,21 +374,25 @@ class FpPartCatalog(models.Model):
|
||||
[('part_catalog_id', '=', part.id)])
|
||||
|
||||
def _compute_workorder_count(self):
|
||||
SaleOrder = self.env['sale.order']
|
||||
Production = self.env['mrp.production']
|
||||
MrpWO = self.env.get('mrp.workorder')
|
||||
# Sub 11 — MRP gone; count fp.job.step rows scoped to this part's SOs.
|
||||
for part in self:
|
||||
part.workorder_count = 0
|
||||
if 'fp.job' not in self.env or 'fp.job.step' not in self.env:
|
||||
return
|
||||
SaleOrder = self.env['sale.order']
|
||||
Job = self.env['fp.job'].sudo()
|
||||
Step = self.env['fp.job.step'].sudo()
|
||||
for part in self:
|
||||
if MrpWO is None:
|
||||
part.workorder_count = 0
|
||||
continue
|
||||
so_names = SaleOrder.search(
|
||||
[('x_fc_part_catalog_id', '=', part.id)]
|
||||
).mapped('name')
|
||||
if not so_names:
|
||||
part.workorder_count = 0
|
||||
continue
|
||||
mos = Production.search([('origin', 'in', so_names)])
|
||||
part.workorder_count = sum(len(m.workorder_ids) for m in mos)
|
||||
jobs = Job.search([('origin', 'in', so_names)])
|
||||
if not jobs:
|
||||
continue
|
||||
part.workorder_count = Step.search_count(
|
||||
[('job_id', 'in', jobs.ids)])
|
||||
|
||||
def _compute_revision_count(self):
|
||||
for part in self:
|
||||
@@ -460,18 +478,20 @@ class FpPartCatalog(models.Model):
|
||||
}
|
||||
|
||||
def action_view_workorders(self):
|
||||
# Sub 11 — MRP gone; navigate to fp.job.step rows scoped to this part.
|
||||
self.ensure_one()
|
||||
so_names = self.env['sale.order'].search(
|
||||
[('x_fc_part_catalog_id', '=', self.id)]
|
||||
).mapped('name')
|
||||
mos = self.env['mrp.production'].search([('origin', 'in', so_names)])
|
||||
wo_ids = mos.mapped('workorder_ids').ids
|
||||
if 'fp.job' not in self.env or 'fp.job.step' not in self.env:
|
||||
return False
|
||||
jobs = self.env['fp.job'].sudo().search([('origin', 'in', so_names)])
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Work Orders — %s') % (self.part_number or self.name),
|
||||
'res_model': 'mrp.workorder',
|
||||
'domain': [('id', 'in', wo_ids)],
|
||||
'view_mode': 'list,form,kanban',
|
||||
'res_model': 'fp.job.step',
|
||||
'domain': [('job_id', 'in', jobs.ids)],
|
||||
'view_mode': 'list,form',
|
||||
}
|
||||
|
||||
def action_view_revisions(self):
|
||||
|
||||
@@ -605,6 +605,15 @@ class FpQuoteConfigurator(models.Model):
|
||||
'name': '%s — %s (x%d)' % (coating_name, part_name, self.quantity),
|
||||
'product_uom_qty': self.quantity,
|
||||
'price_unit': price / self.quantity if self.quantity else price,
|
||||
# Sub 11 fix — propagate part + coating to the LINE too.
|
||||
# fusion_plating_jobs._fp_auto_create_job filters lines
|
||||
# by x_fc_part_catalog_id; without it, no fp.job spawns.
|
||||
'x_fc_part_catalog_id': (
|
||||
self.part_catalog_id.id if self.part_catalog_id else False
|
||||
),
|
||||
'x_fc_coating_config_id': (
|
||||
self.coating_config_id.id if self.coating_config_id else False
|
||||
),
|
||||
})],
|
||||
}
|
||||
so = self.env['sale.order'].create(so_vals)
|
||||
|
||||
@@ -146,6 +146,37 @@ class SaleOrder(models.Model):
|
||||
# top of this stub during its own load pass.
|
||||
x_fc_workorder_count = fields.Integer(string='Work Orders')
|
||||
|
||||
# Smart-button visibility helpers (post-Sub 11). The BOM Items kanban
|
||||
# is only useful when the SO carries 2+ distinct parts; the By Job
|
||||
# Group kanban is only useful when at least one line is tagged with
|
||||
# x_fc_wo_group_tag. Default-hidden otherwise so the smart-button
|
||||
# row stays clean for the typical single-part SO.
|
||||
x_fc_distinct_part_count = fields.Integer(
|
||||
string='# Distinct Parts',
|
||||
compute='_compute_smart_button_visibility',
|
||||
)
|
||||
x_fc_has_wo_group_tag = fields.Boolean(
|
||||
string='Has Job Group Tag',
|
||||
compute='_compute_smart_button_visibility',
|
||||
)
|
||||
x_fc_wo_group_count = fields.Integer(
|
||||
string='# Job Groups',
|
||||
compute='_compute_smart_button_visibility',
|
||||
help='Distinct x_fc_wo_group_tag values across this SO\'s lines.',
|
||||
)
|
||||
|
||||
@api.depends('order_line.x_fc_part_catalog_id',
|
||||
'order_line.x_fc_wo_group_tag')
|
||||
def _compute_smart_button_visibility(self):
|
||||
for rec in self:
|
||||
parts = rec.order_line.mapped('x_fc_part_catalog_id')
|
||||
rec.x_fc_distinct_part_count = len(parts)
|
||||
tags = {
|
||||
t for t in rec.order_line.mapped('x_fc_wo_group_tag') if t
|
||||
}
|
||||
rec.x_fc_has_wo_group_tag = bool(tags)
|
||||
rec.x_fc_wo_group_count = len(tags)
|
||||
|
||||
# Sub 9 — process variant summary across order lines. Renders one
|
||||
# variant label when all lines share one, otherwise "Mixed (N)".
|
||||
x_fc_process_summary = fields.Char(
|
||||
@@ -192,42 +223,45 @@ class SaleOrder(models.Model):
|
||||
|
||||
@api.depends('name')
|
||||
def _compute_wo_completion(self):
|
||||
"""Batched: one grouped query across all records in self."""
|
||||
"""Batched: one grouped query across all records in self.
|
||||
|
||||
Sub 11 — MRP is gone; we count fp.job.step completion instead of
|
||||
mrp.workorder. The selection is the same shape: completed steps
|
||||
out of total steps across every fp.job for this SO.
|
||||
"""
|
||||
for rec in self:
|
||||
rec.x_fc_wo_completion = '0/0'
|
||||
names = [so.name for so in self if so.name]
|
||||
if not names:
|
||||
return
|
||||
WO = self.env['mrp.workorder'].sudo()
|
||||
rows = WO.read_group(
|
||||
[('production_id.origin', 'in', names)],
|
||||
['production_id.origin', 'state'],
|
||||
['production_id', 'state'],
|
||||
lazy=False,
|
||||
if 'fp.job.step' not in self.env or 'fp.job' not in self.env:
|
||||
return
|
||||
Job = self.env['fp.job'].sudo()
|
||||
Step = self.env['fp.job.step'].sudo()
|
||||
jobs = Job.search([('origin', 'in', names)])
|
||||
if not jobs:
|
||||
return
|
||||
job_to_origin = {j.id: j.origin for j in jobs}
|
||||
# Odoo 19 — use _read_group with aggregates=['__count'].
|
||||
rows = Step._read_group(
|
||||
domain=[('job_id', 'in', jobs.ids)],
|
||||
groupby=['job_id', 'state'],
|
||||
aggregates=['__count'],
|
||||
)
|
||||
# Build {origin: {'done': n, 'total': n}}
|
||||
# read_group returns production_id as (id, name) tuples; we need
|
||||
# to translate back to origin. Do a small lookup.
|
||||
mos = self.env['mrp.production'].sudo().search(
|
||||
[('origin', 'in', names)]
|
||||
)
|
||||
mo_to_origin = {m.id: m.origin for m in mos}
|
||||
totals = {} # {origin: [total, done]}
|
||||
for r in rows:
|
||||
mo_id = r['production_id'][0] if r['production_id'] else False
|
||||
origin = mo_to_origin.get(mo_id)
|
||||
for job_rec, state_val, count in rows:
|
||||
origin = job_to_origin.get(job_rec.id)
|
||||
if not origin:
|
||||
continue
|
||||
cnt = r['__count']
|
||||
bucket = totals.setdefault(origin, [0, 0])
|
||||
bucket[0] += cnt
|
||||
if r['state'] == 'done':
|
||||
bucket[1] += cnt
|
||||
bucket[0] += count
|
||||
if state_val == 'done':
|
||||
bucket[1] += count
|
||||
for rec in self:
|
||||
if not rec.name:
|
||||
continue
|
||||
tot, done = totals.get(rec.name, [0, 0])
|
||||
rec.x_fc_wo_completion = '%d/%d' % (done, tot) if tot else '0/0'
|
||||
rec.x_fc_wo_completion = f'{done}/{tot}' if tot else '0/0'
|
||||
|
||||
# ---- Phase F: quotes list view polish ----
|
||||
x_fc_follow_up_date = fields.Date(
|
||||
|
||||
@@ -56,16 +56,16 @@
|
||||
</button>
|
||||
</xpath>
|
||||
|
||||
<!-- After standard Manufacturing: Active WOs, NCRs, Files, BOM Items, By WO.
|
||||
BOM Items and By WO are last so Odoo's button box overflows them into More. -->
|
||||
<xpath expr="//button[@name='action_view_mrp_production']" position="after">
|
||||
<button name="action_view_workorders"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-cogs">
|
||||
<field name="x_fc_workorder_count" widget="statinfo"
|
||||
string="Work Orders"/>
|
||||
</button>
|
||||
<!-- Sub 11 — MRP gone. The "Work Orders" button used to count
|
||||
mrp.workorder; removed because Plating Jobs (added by
|
||||
fusion_plating_jobs) now counts the canonical fp.job.step
|
||||
rows. NCRs surfaces only when there's at least one open;
|
||||
BOM Items and By Job Group only when the SO is actually
|
||||
multi-part / tagged (otherwise both render one column with
|
||||
one card — pure noise). Anchored after Transfers; the two
|
||||
conditional ones go last so the typical clean SO shows
|
||||
just the meaningful buttons up front. -->
|
||||
<xpath expr="//button[@name='action_view_pickings']" position="after">
|
||||
<button name="action_view_ncrs"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
@@ -74,16 +74,29 @@
|
||||
<field name="x_fc_ncr_count" widget="statinfo"
|
||||
string="NCRs"/>
|
||||
</button>
|
||||
</xpath>
|
||||
<!-- Push BOM Items / By Job Group to the end of the button
|
||||
box (after the Plating Jobs / Holds row added by jobs +
|
||||
quality). They sit hidden by default and only surface
|
||||
when the SO actually has multi-part lines or job-group
|
||||
tags. -->
|
||||
<xpath expr="//div[hasclass('oe_button_box')]" position="inside">
|
||||
<button name="action_view_bom_items"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-list-alt"
|
||||
string="BOM Items"/>
|
||||
invisible="x_fc_distinct_part_count < 2">
|
||||
<field name="x_fc_distinct_part_count" widget="statinfo"
|
||||
string="BOM Items"/>
|
||||
</button>
|
||||
<button name="action_view_wo_perspective"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-th-large"
|
||||
string="By WO"/>
|
||||
invisible="not x_fc_has_wo_group_tag">
|
||||
<field name="x_fc_wo_group_count" widget="statinfo"
|
||||
string="Job Groups"/>
|
||||
</button>
|
||||
</xpath>
|
||||
<xpath expr="//notebook" position="inside">
|
||||
<page string="Plating" name="plating_tab">
|
||||
|
||||
@@ -116,11 +116,61 @@ class FpDirectOrderLine(models.Model):
|
||||
|
||||
@api.onchange('part_catalog_id')
|
||||
def _onchange_part_clears_variant(self):
|
||||
"""Clear variant pick when the part changes (variants are part-scoped)."""
|
||||
"""Clear variant pick when the part changes (variants are part-scoped).
|
||||
|
||||
Pre-fill coating + treatments from the part's saved defaults so
|
||||
Sarah doesn't re-pick the same coating every repeat customer.
|
||||
Defaults only apply when the line currently has no coating set
|
||||
— editing an existing line with a chosen coating doesn't get
|
||||
clobbered.
|
||||
|
||||
For BRAND-NEW parts (no defaults saved yet) auto-tick
|
||||
`push_to_defaults` so Sarah's first coating pick gets persisted
|
||||
back to the part. Without this Sarah has to remember to tick the
|
||||
toggle herself, and the second order doesn't pre-fill.
|
||||
Returns a warning popup explaining what's happening.
|
||||
"""
|
||||
warning = None
|
||||
for rec in self:
|
||||
# Variant clear (original behaviour).
|
||||
if (rec.process_variant_id
|
||||
and rec.process_variant_id.part_catalog_id != rec.part_catalog_id):
|
||||
rec.process_variant_id = False
|
||||
if not rec.part_catalog_id:
|
||||
continue
|
||||
part = rec.part_catalog_id
|
||||
has_default_coating = bool(getattr(
|
||||
part, 'x_fc_default_coating_config_id', False))
|
||||
has_default_treatments = bool(getattr(
|
||||
part, 'x_fc_default_treatment_ids', False))
|
||||
# Pre-fill default coating if the line is empty.
|
||||
if not rec.coating_config_id and has_default_coating:
|
||||
rec.coating_config_id = part.x_fc_default_coating_config_id
|
||||
# Pre-fill default treatments if any are configured.
|
||||
if not rec.treatment_ids and has_default_treatments:
|
||||
rec.treatment_ids = [(6, 0, part.x_fc_default_treatment_ids.ids)]
|
||||
# New-part auto-suggest: if neither default exists, this is
|
||||
# likely a first-time use of the part. Auto-tick the
|
||||
# push_to_defaults toggle so whatever Sarah picks becomes
|
||||
# the saved default — surface a warning popup so she knows.
|
||||
# `is_one_off` always wins (operator opted out of catalog
|
||||
# persistence), so don't auto-tick in that case.
|
||||
if (not has_default_coating
|
||||
and not has_default_treatments
|
||||
and not rec.is_one_off
|
||||
and not rec.push_to_defaults):
|
||||
rec.push_to_defaults = True
|
||||
warning = {
|
||||
'title': _('First-Time Part — Defaults Will Be Saved'),
|
||||
'message': _(
|
||||
'%(part)s has no saved coating / treatments. '
|
||||
'The coating + treatments you pick on this line '
|
||||
'will be saved as the part\'s defaults so the '
|
||||
'next order auto-fills them. Untick "Save as '
|
||||
'Default" on the line if you don\'t want this.'
|
||||
) % {'part': part.display_name or part.part_number or '(part)'},
|
||||
}
|
||||
return {'warning': warning} if warning else None
|
||||
|
||||
# ---- Qty / price ----
|
||||
quantity = fields.Integer(string='Qty', default=1, required=True)
|
||||
|
||||
@@ -209,37 +209,63 @@ class FpDirectOrderWizard(models.Model):
|
||||
# ---- Onchange ----
|
||||
@api.onchange('partner_id')
|
||||
def _onchange_partner_id(self):
|
||||
"""Seed invoice defaults + addresses + payment terms when customer changes."""
|
||||
if self.partner_id and 'x_fc_default_invoice_strategy' in self.partner_id._fields:
|
||||
self.invoice_strategy = self.partner_id.x_fc_default_invoice_strategy or False
|
||||
self.deposit_percent = self.partner_id.x_fc_default_deposit_percent or 0.0
|
||||
if self.partner_id:
|
||||
addrs = self.partner_id.address_get(['invoice', 'delivery'])
|
||||
self.partner_invoice_id = addrs.get('invoice') or self.partner_id.id
|
||||
self.partner_shipping_id = addrs.get('delivery') or self.partner_id.id
|
||||
# Seed payment terms: customer's invoice-strategy default wins;
|
||||
# fallback to partner.property_payment_term_id.
|
||||
term = False
|
||||
isd = self.env['fp.invoice.strategy.default'].search(
|
||||
[('partner_id', '=', self.partner_id.id)], limit=1,
|
||||
)
|
||||
if isd and isd.payment_term_id:
|
||||
term = isd.payment_term_id
|
||||
# Also seed strategy from the same record if not already set.
|
||||
if not self.invoice_strategy:
|
||||
self.invoice_strategy = isd.default_strategy
|
||||
if not self.deposit_percent:
|
||||
self.deposit_percent = isd.default_deposit_percent or 0.0
|
||||
if not term and self.partner_id.property_payment_term_id:
|
||||
term = self.partner_id.property_payment_term_id
|
||||
self.payment_term_id = term or False
|
||||
else:
|
||||
"""Seed invoice defaults + addresses + payment terms when customer
|
||||
changes. Also surface an account-hold warning so Sarah doesn't
|
||||
build a full quote for a customer she can't ship to.
|
||||
"""
|
||||
if not self.partner_id:
|
||||
self.partner_invoice_id = False
|
||||
self.partner_shipping_id = False
|
||||
self.payment_term_id = False
|
||||
self._apply_strategy_payment_term()
|
||||
return
|
||||
|
||||
# Legacy partner-field defaults (pre-Sub-5).
|
||||
if 'x_fc_default_invoice_strategy' in self.partner_id._fields:
|
||||
self.invoice_strategy = self.partner_id.x_fc_default_invoice_strategy or False
|
||||
self.deposit_percent = self.partner_id.x_fc_default_deposit_percent or 0.0
|
||||
|
||||
# Addresses.
|
||||
addrs = self.partner_id.address_get(['invoice', 'delivery'])
|
||||
self.partner_invoice_id = addrs.get('invoice') or self.partner_id.id
|
||||
self.partner_shipping_id = addrs.get('delivery') or self.partner_id.id
|
||||
|
||||
# Per-customer invoice strategy default (fp.invoice.strategy.default).
|
||||
# Pull strategy + deposit even when payment_term_id is empty — the
|
||||
# previous condition `if isd and isd.payment_term_id` silently
|
||||
# skipped the strategy fill for net-terms customers without
|
||||
# explicit terms configured.
|
||||
isd = self.env['fp.invoice.strategy.default'].search(
|
||||
[('partner_id', '=', self.partner_id.id)], limit=1,
|
||||
)
|
||||
term = False
|
||||
if isd:
|
||||
if not self.invoice_strategy:
|
||||
self.invoice_strategy = isd.default_strategy
|
||||
if not self.deposit_percent:
|
||||
self.deposit_percent = isd.default_deposit_percent or 0.0
|
||||
term = isd.payment_term_id
|
||||
if not term and self.partner_id.property_payment_term_id:
|
||||
term = self.partner_id.property_payment_term_id
|
||||
self.payment_term_id = term or False
|
||||
|
||||
# Re-apply strategy → terms mapping after partner switch.
|
||||
self._apply_strategy_payment_term()
|
||||
|
||||
# Account-hold early warning. Hard block lives in action_confirm
|
||||
# but Sarah deserves to know NOW before she builds 5 lines.
|
||||
if getattr(self.partner_id, 'x_fc_account_hold', False):
|
||||
return {
|
||||
'warning': {
|
||||
'title': _('Customer on Account Hold'),
|
||||
'message': _(
|
||||
'%s is currently on account hold. You can still '
|
||||
'build the quotation, but it cannot be confirmed '
|
||||
'until the hold is cleared by accounting.'
|
||||
) % self.partner_id.display_name,
|
||||
}
|
||||
}
|
||||
|
||||
@api.onchange('invoice_strategy')
|
||||
def _onchange_invoice_strategy(self):
|
||||
"""Map the strategy onto sensible payment terms."""
|
||||
@@ -247,12 +273,15 @@ class FpDirectOrderWizard(models.Model):
|
||||
|
||||
def _apply_strategy_payment_term(self):
|
||||
"""Mapping rule:
|
||||
- cod_prepay → Immediate Payment (Odoo's stock term)
|
||||
- deposit / progress / net_terms → keep what the partner default
|
||||
already gave us; if blank, leave it blank so the user can pick.
|
||||
Never overwrites an explicit user choice for non-COD strategies —
|
||||
only fills in when payment_term_id is empty.
|
||||
- cod_prepay → Immediate Payment
|
||||
- net_terms / deposit / progress → fall back to a 30-day
|
||||
term when nothing is set. Without ANY payment term Odoo
|
||||
blocks invoice posting, which silently strands SOs at the
|
||||
invoicing step. Better to default to net-30 and let the
|
||||
estimator override if the customer's terms are different.
|
||||
Never overwrites an explicit user choice — only fills the gap.
|
||||
"""
|
||||
Pt = self.env['account.payment.term']
|
||||
for rec in self:
|
||||
if rec.invoice_strategy == 'cod_prepay':
|
||||
immediate = rec.env.ref(
|
||||
@@ -261,6 +290,20 @@ class FpDirectOrderWizard(models.Model):
|
||||
)
|
||||
if immediate:
|
||||
rec.payment_term_id = immediate.id
|
||||
elif rec.invoice_strategy in ('net_terms', 'deposit', 'progress') \
|
||||
and not rec.payment_term_id:
|
||||
# Try canonical Net-30, then any term named "30 Days",
|
||||
# then any term at all as last-ditch.
|
||||
term = rec.env.ref(
|
||||
'account.account_payment_term_30days',
|
||||
raise_if_not_found=False,
|
||||
)
|
||||
if not term:
|
||||
term = Pt.search([('name', 'ilike', '30 Days')], limit=1)
|
||||
if not term:
|
||||
term = Pt.search([], limit=1)
|
||||
if term:
|
||||
rec.payment_term_id = term.id
|
||||
|
||||
# ---- Actions ----
|
||||
@api.model
|
||||
@@ -351,6 +394,17 @@ class FpDirectOrderWizard(models.Model):
|
||||
raise UserError(_('Pick a customer before confirming.'))
|
||||
if not self.line_ids:
|
||||
raise UserError(_('Add at least one part line before confirming.'))
|
||||
# Account-hold hard block — same policy as sale.order.action_confirm
|
||||
# but enforced earlier so the wizard doesn't waste Sarah's time.
|
||||
# Manager override allowed via context key fp_skip_account_hold=True.
|
||||
if (getattr(self.partner_id, 'x_fc_account_hold', False)
|
||||
and not self.env.context.get('fp_skip_account_hold')
|
||||
and not self.env.user.has_group(
|
||||
'fusion_plating.group_fusion_plating_manager')):
|
||||
raise UserError(_(
|
||||
'Customer %s is on account hold. Have a manager clear the '
|
||||
'hold (or override) before creating the order.'
|
||||
) % self.partner_id.display_name)
|
||||
|
||||
# Accept EITHER a PO (document + number) OR the PO Pending
|
||||
# flag. Customers who haven't sent paperwork yet use Pending;
|
||||
|
||||
@@ -141,9 +141,9 @@
|
||||
<field name="is_missing_info" column_invisible="1"/>
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="part_catalog_id"
|
||||
context="{'default_partner_id': parent.partner_id}"
|
||||
context="{'default_partner_id': parent.partner_id, 'default_revision': 'A'}"
|
||||
domain="[('partner_id', '=', parent.partner_id), ('is_latest_revision', '=', True)]"
|
||||
options="{'no_create_edit': True}"/>
|
||||
options="{'no_quick_create': True}"/>
|
||||
<field name="description_template_id"
|
||||
domain="[('part_catalog_id', '=', part_catalog_id)]"
|
||||
context="{'default_part_catalog_id': part_catalog_id}"
|
||||
|
||||
Reference in New Issue
Block a user