feat(shopfloor): Phase 3 — plant_kanban endpoint + dispatch
PV-Phase3 of the plant-view redesign.
- /fp/landing/plant_kanban JSONRPC endpoint returns {kpis, columns,
cards} in one payload. One card per fp.job; cards denormalized so
the OWL component doesn't fan out RPCs. Server-side filter handling
for All / Mine / Running / Blocked / Overdue / FAIR. Within-column
sort by (overdue, _SORT_PRIORITY[card_state], due_date).
- fusion_plating_shopfloor.action_fp_plant_kanban client action
registered alongside the existing fp_shopfloor_landing action.
- fp_landing_data.xml resolver extended to read the layout flag and
dispatch to v2 when x_fc_shopfloor_layout='v2' (default still legacy).
Card payload (23 fields): WO, customer, PN+rev, qty, PO, recipe, spec,
tags, current step + work centre, state chip, mini_timeline, operator,
icons (signoff / bake / tracking / etc.), progress.
State-chip mapping per spec §6.1 — one map keyed by card_state with
running-time elapsed, idle-hours, and operator-name interpolation.
Verified live — card payload sample on WO-30036 (contract_review state)
produces all expected keys + 9-element mini_timeline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,15 +24,37 @@
|
|||||||
<field name="model_id" ref="base.model_res_users"/>
|
<field name="model_id" ref="base.model_res_users"/>
|
||||||
<field name="state">code</field>
|
<field name="state">code</field>
|
||||||
<field name="code"><![CDATA[
|
<field name="code"><![CDATA[
|
||||||
# Resolve in priority order: user pref → company default → Sale Orders fallback.
|
# Resolve in priority order:
|
||||||
|
# 1. user.x_fc_plating_landing_action_id (per-user override)
|
||||||
|
# 2. company.x_fc_default_landing_action_id (company default)
|
||||||
|
# 3. Shop Floor plant-view kanban (when x_fc_shopfloor_layout='v2')
|
||||||
|
# 4. Sale Orders (when v2 flag unset / legacy)
|
||||||
|
# 5. Process recipes (configurator absent)
|
||||||
user = env.user
|
user = env.user
|
||||||
target = False
|
target = False
|
||||||
if 'x_fc_plating_landing_action_id' in user._fields and user.x_fc_plating_landing_action_id:
|
if 'x_fc_plating_landing_action_id' in user._fields and user.x_fc_plating_landing_action_id:
|
||||||
target = user.x_fc_plating_landing_action_id.sudo()
|
target = user.x_fc_plating_landing_action_id.sudo()
|
||||||
elif 'x_fc_default_landing_action_id' in env.company._fields and env.company.x_fc_default_landing_action_id:
|
elif 'x_fc_default_landing_action_id' in env.company._fields and env.company.x_fc_default_landing_action_id:
|
||||||
target = env.company.x_fc_default_landing_action_id.sudo()
|
target = env.company.x_fc_default_landing_action_id.sudo()
|
||||||
|
|
||||||
if not target:
|
if not target:
|
||||||
target = env.ref('fusion_plating_configurator.action_fp_sale_orders', raise_if_not_found=False)
|
# 2026-05-23 — plant-view dispatch. Read the layout flag and pick the
|
||||||
|
# appropriate Shop Floor action. Falls through to Sale Orders if no
|
||||||
|
# client action is registered (e.g. shopfloor module not installed).
|
||||||
|
layout = env['ir.config_parameter'].sudo().get_param(
|
||||||
|
'fusion_plating_shopfloor.layout', default='legacy',
|
||||||
|
)
|
||||||
|
if layout == 'v2':
|
||||||
|
target = env.ref(
|
||||||
|
'fusion_plating_shopfloor.action_fp_plant_kanban',
|
||||||
|
raise_if_not_found=False,
|
||||||
|
)
|
||||||
|
# Legacy or v2-missing → fall through to Sale Orders
|
||||||
|
if not target:
|
||||||
|
target = env.ref(
|
||||||
|
'fusion_plating_configurator.action_fp_sale_orders',
|
||||||
|
raise_if_not_found=False,
|
||||||
|
)
|
||||||
|
|
||||||
if target:
|
if target:
|
||||||
action = target.sudo().read()[0]
|
action = target.sudo().read()[0]
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ from . import move_controller
|
|||||||
from . import workspace_controller
|
from . import workspace_controller
|
||||||
from . import landing_controller
|
from . import landing_controller
|
||||||
from . import tablet_controller
|
from . import tablet_controller
|
||||||
|
from . import plant_kanban
|
||||||
|
|||||||
@@ -0,0 +1,378 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
"""Plant-view Shop Floor kanban endpoint.
|
||||||
|
|
||||||
|
Returns {kpis, columns, cards} in one JSONRPC payload so the OWL
|
||||||
|
FpPlantKanban component doesn't fan out per-card RPCs. One card per
|
||||||
|
fp.job; cards grouped into the 9 fixed Shop Floor columns. See spec at
|
||||||
|
docs/superpowers/specs/2026-05-23-shopfloor-plant-view-design.md.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
|
||||||
|
from odoo import _, http
|
||||||
|
from odoo.http import request
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Mirrors fusion_plating_jobs.models.fp_job._COLUMN_SEQUENCE exactly.
|
||||||
|
# Keep these two in sync — the column order on the board IS the sequence.
|
||||||
|
_COLUMN_LABELS = [
|
||||||
|
('receiving', _('Receiving')),
|
||||||
|
('masking', _('Masking')),
|
||||||
|
('blasting', _('Blasting')),
|
||||||
|
('racking', _('Racking')),
|
||||||
|
('plating', _('Plating')),
|
||||||
|
('baking', _('Baking')),
|
||||||
|
('de_racking', _('De-Racking')),
|
||||||
|
('inspection', _('Final inspection')),
|
||||||
|
('shipping', _('Shipping')),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Sort priority within a column (overdue → bake_due → mine → ready/run
|
||||||
|
# → idle → locked → done). Lower number wins (sorted ascending).
|
||||||
|
_SORT_PRIORITY = {
|
||||||
|
'on_hold': 0,
|
||||||
|
'no_parts': 1,
|
||||||
|
'bake_due': 2,
|
||||||
|
'awaiting_signoff': 3,
|
||||||
|
'awaiting_qc': 4,
|
||||||
|
'ready_mine': 5,
|
||||||
|
'running_mine': 6,
|
||||||
|
'ready': 7,
|
||||||
|
'running': 8,
|
||||||
|
'idle_warning': 9,
|
||||||
|
'predecessor_locked': 10,
|
||||||
|
'contract_review': 11,
|
||||||
|
'done': 12,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PlantKanbanController(http.Controller):
|
||||||
|
|
||||||
|
@http.route('/fp/landing/plant_kanban', type='jsonrpc', auth='user')
|
||||||
|
def plant_kanban(self, mode='station', filters=None):
|
||||||
|
"""Returns the assembled board payload. See spec §9.2."""
|
||||||
|
env = request.env
|
||||||
|
user = env.user
|
||||||
|
Job = env['fp.job']
|
||||||
|
|
||||||
|
# Resolve paired station (first row of M2M for MVP)
|
||||||
|
paired = (user.paired_work_centre_ids[:1]
|
||||||
|
if 'paired_work_centre_ids' in user._fields
|
||||||
|
else env['fp.work.centre'])
|
||||||
|
paired_area = paired.area_kind if paired else None
|
||||||
|
|
||||||
|
# Base domain — every job with active recipe steps
|
||||||
|
domain = [
|
||||||
|
('state', 'in', ('confirmed', 'in_progress', 'done')),
|
||||||
|
]
|
||||||
|
filters = filters or {}
|
||||||
|
if filters.get('overdue'):
|
||||||
|
domain.append(('date_deadline', '<', fields_today_ts()))
|
||||||
|
domain.append(('state', '!=', 'done'))
|
||||||
|
if filters.get('on_hold'):
|
||||||
|
domain.append(('card_state', '=', 'on_hold'))
|
||||||
|
if filters.get('running'):
|
||||||
|
domain.append(('card_state', 'in', ('running', 'running_mine')))
|
||||||
|
if filters.get('blocked'):
|
||||||
|
domain.append(('card_state', 'in', (
|
||||||
|
'on_hold', 'predecessor_locked', 'awaiting_signoff',
|
||||||
|
'awaiting_qc', 'no_parts',
|
||||||
|
)))
|
||||||
|
if filters.get('mine'):
|
||||||
|
domain.append(('card_state', 'in', ('ready_mine', 'running_mine')))
|
||||||
|
if filters.get('fair'):
|
||||||
|
# Match either part-catalog or partner level requires_first_article
|
||||||
|
domain.append('|')
|
||||||
|
domain.append(('customer_spec_id.x_fc_requires_first_article', '=', True))
|
||||||
|
domain.append(('part_catalog_id.certificate_requirement', 'in', ('coc', 'coc_thickness')))
|
||||||
|
|
||||||
|
jobs = Job.search(domain, limit=500)
|
||||||
|
|
||||||
|
# Bucket by area_kind of the active step (or 'receiving' when no
|
||||||
|
# active step yet — matches the contract_review / no_parts states
|
||||||
|
# that live in Receiving column per spec §3 D5).
|
||||||
|
cards = {}
|
||||||
|
cards_by_area = {area: [] for area, _label in _COLUMN_LABELS}
|
||||||
|
for job in jobs:
|
||||||
|
area = _resolve_card_area(job)
|
||||||
|
cards_by_area.setdefault(area, []).append(job.id)
|
||||||
|
cards[str(job.id)] = _render_card(job, paired)
|
||||||
|
|
||||||
|
# Sort within each column by priority then due date
|
||||||
|
for area in cards_by_area:
|
||||||
|
cards_by_area[area].sort(key=lambda jid: _sort_key(cards[str(jid)]))
|
||||||
|
|
||||||
|
columns = [
|
||||||
|
{
|
||||||
|
'area_kind': area,
|
||||||
|
'label': label,
|
||||||
|
'is_mine': (area == paired_area),
|
||||||
|
'card_ids': cards_by_area.get(area, []),
|
||||||
|
}
|
||||||
|
for area, label in _COLUMN_LABELS
|
||||||
|
]
|
||||||
|
|
||||||
|
# KPI strip
|
||||||
|
kpis = {
|
||||||
|
'active_jobs': sum(1 for j in jobs if j.state != 'done'),
|
||||||
|
'at_my_station': sum(
|
||||||
|
1 for j in jobs
|
||||||
|
if j.card_state in ('ready_mine', 'running_mine')
|
||||||
|
),
|
||||||
|
'bakes_due_soon': sum(
|
||||||
|
1 for j in jobs if j.card_state == 'bake_due'
|
||||||
|
),
|
||||||
|
'on_hold': sum(
|
||||||
|
1 for j in jobs if j.card_state == 'on_hold'
|
||||||
|
),
|
||||||
|
'overdue': sum(
|
||||||
|
1 for j in jobs
|
||||||
|
if j.date_deadline and j.date_deadline.date() < date.today()
|
||||||
|
and j.state != 'done'
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'ok': True,
|
||||||
|
'mode': mode,
|
||||||
|
'paired_station': ({
|
||||||
|
'id': paired.id,
|
||||||
|
'name': paired.name,
|
||||||
|
'area_kind': paired_area,
|
||||||
|
} if paired else None),
|
||||||
|
'kpis': kpis,
|
||||||
|
'columns': columns,
|
||||||
|
'cards': cards,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ===== helpers ==========================================================
|
||||||
|
|
||||||
|
def fields_today_ts():
|
||||||
|
"""Return today as the start-of-day datetime string for date_deadline
|
||||||
|
comparisons (date_deadline is a Datetime in the schema)."""
|
||||||
|
return datetime.combine(date.today(), datetime.min.time())
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_card_area(job):
|
||||||
|
"""Pick the column a card lives in.
|
||||||
|
|
||||||
|
Active-step area_kind wins. When there's no active step the card
|
||||||
|
lives in Receiving (covers contract_review + no_parts edge cases).
|
||||||
|
"""
|
||||||
|
if job.active_step_id and job.active_step_id.area_kind:
|
||||||
|
return job.active_step_id.area_kind
|
||||||
|
# Fallback: receiving column
|
||||||
|
return 'receiving'
|
||||||
|
|
||||||
|
|
||||||
|
def _render_card(job, paired):
|
||||||
|
"""Build the full card payload for one fp.job."""
|
||||||
|
step = job.active_step_id
|
||||||
|
try:
|
||||||
|
timeline = json.loads(job.mini_timeline_json or '[]')
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
timeline = []
|
||||||
|
|
||||||
|
# Cross-module field probes
|
||||||
|
part = job.part_catalog_id if 'part_catalog_id' in job._fields else None
|
||||||
|
spec = job.customer_spec_id if 'customer_spec_id' in job._fields else None
|
||||||
|
so = job.sale_order_id
|
||||||
|
|
||||||
|
po_number = ''
|
||||||
|
if so and 'x_fc_po_number' in so._fields:
|
||||||
|
po_number = so.x_fc_po_number or ''
|
||||||
|
|
||||||
|
# Tag chips (Rush / FAIR / VIP / AS9100 — only render when applicable)
|
||||||
|
tags = _compute_tags(job, part, spec)
|
||||||
|
|
||||||
|
# Step + tank labels
|
||||||
|
step_name = step.name if step else _('—')
|
||||||
|
step_seq = step.sequence if step else 0
|
||||||
|
step_total = len(job.step_ids)
|
||||||
|
tank_label = ''
|
||||||
|
if step and step.work_centre_id:
|
||||||
|
tank_label = step.work_centre_id.name or step.work_centre_id.code or ''
|
||||||
|
|
||||||
|
# State chip
|
||||||
|
state_chip = _state_chip(job.card_state, step)
|
||||||
|
|
||||||
|
# Operator pill (only when step has an assigned user)
|
||||||
|
operator = None
|
||||||
|
if step and step.assigned_user_id:
|
||||||
|
u = step.assigned_user_id
|
||||||
|
operator = {
|
||||||
|
'id': u.id,
|
||||||
|
'name': u.name,
|
||||||
|
'initials': _initials_for(u),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Icon row
|
||||||
|
icons = _icons(job, step)
|
||||||
|
|
||||||
|
# Due label
|
||||||
|
due_label = _due_label(job.date_deadline) if job.date_deadline else ''
|
||||||
|
is_overdue = (
|
||||||
|
bool(job.date_deadline)
|
||||||
|
and job.date_deadline.date() < date.today()
|
||||||
|
and job.state != 'done'
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'job_id': job.id,
|
||||||
|
'wo_name': job.display_wo_name or job.name or '',
|
||||||
|
'is_mine': job.card_state in ('ready_mine', 'running_mine'),
|
||||||
|
'card_state': job.card_state or '',
|
||||||
|
'due_date': (job.date_deadline.strftime('%Y-%m-%d')
|
||||||
|
if job.date_deadline else None),
|
||||||
|
'due_label': due_label,
|
||||||
|
'is_overdue': is_overdue,
|
||||||
|
'customer': job.partner_id.name if job.partner_id else '',
|
||||||
|
'part_number': (part.part_number if part else '') or '',
|
||||||
|
'part_revision': (part.revision if part and 'revision' in part._fields else '') or '',
|
||||||
|
'qty': job.qty or 0,
|
||||||
|
'po_number': po_number,
|
||||||
|
'recipe_name': job.recipe_id.name if job.recipe_id else '',
|
||||||
|
'spec_code': (spec.code if spec and 'code' in spec._fields else '') or '',
|
||||||
|
'tags': tags,
|
||||||
|
'step_name': step_name,
|
||||||
|
'step_seq': step_seq,
|
||||||
|
'step_total': step_total,
|
||||||
|
'tank_label': tank_label,
|
||||||
|
'state_chip': state_chip,
|
||||||
|
'operator': operator,
|
||||||
|
'icons': icons,
|
||||||
|
'mini_timeline': timeline,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_tags(job, part, spec):
|
||||||
|
tags = []
|
||||||
|
partner = job.partner_id
|
||||||
|
if partner:
|
||||||
|
if 'x_fc_rush' in partner._fields and partner.x_fc_rush:
|
||||||
|
tags.append('rush')
|
||||||
|
if 'x_fc_vip' in partner._fields and partner.x_fc_vip:
|
||||||
|
tags.append('vip')
|
||||||
|
if spec and 'x_fc_requires_first_article' in spec._fields \
|
||||||
|
and spec.x_fc_requires_first_article:
|
||||||
|
tags.append('fair')
|
||||||
|
if part and 'aerospace' in (part.name or '').lower():
|
||||||
|
tags.append('as9100')
|
||||||
|
return tags
|
||||||
|
|
||||||
|
|
||||||
|
def _state_chip(card_state, step):
|
||||||
|
"""Map card_state → {label, kind} for the chip on the card."""
|
||||||
|
if card_state == 'ready':
|
||||||
|
return {'label': _('● Ready'), 'kind': 'ready'}
|
||||||
|
if card_state == 'ready_mine':
|
||||||
|
return {'label': _('● Ready to start'), 'kind': 'ready'}
|
||||||
|
if card_state == 'running':
|
||||||
|
return {'label': _('▶ %s') % _running_elapsed(step), 'kind': 'running'}
|
||||||
|
if card_state == 'running_mine':
|
||||||
|
return {'label': _('▶ %s') % _running_elapsed(step), 'kind': 'running'}
|
||||||
|
if card_state == 'on_hold':
|
||||||
|
return {'label': _('🔴 Quality Hold'), 'kind': 'hold'}
|
||||||
|
if card_state == 'awaiting_signoff':
|
||||||
|
return {'label': _('🔏 Awaiting QA sign-off'), 'kind': 'signoff'}
|
||||||
|
if card_state == 'awaiting_qc':
|
||||||
|
return {'label': _('🔬 QC pending'), 'kind': 'qc'}
|
||||||
|
if card_state == 'bake_due':
|
||||||
|
return {'label': _('⏰ Bake window soon'), 'kind': 'due'}
|
||||||
|
if card_state == 'predecessor_locked':
|
||||||
|
return {'label': _('🔒 Waiting on predecessor'), 'kind': 'locked'}
|
||||||
|
if card_state == 'idle_warning':
|
||||||
|
op = (step.assigned_user_id.name.split()[0]
|
||||||
|
if step and step.assigned_user_id else _('operator'))
|
||||||
|
hrs = _idle_hours(step)
|
||||||
|
return {'label': _('⏸ Idle %dh · %s') % (hrs, op), 'kind': 'idle'}
|
||||||
|
if card_state == 'no_parts':
|
||||||
|
return {'label': _('📦 Parts in transit'), 'kind': 'no_parts'}
|
||||||
|
if card_state == 'contract_review':
|
||||||
|
return {'label': _('📋 QA-005 review'), 'kind': 'paperwork'}
|
||||||
|
if card_state == 'done':
|
||||||
|
return {'label': _('✓ Ready for pickup'), 'kind': 'done'}
|
||||||
|
return {'label': '', 'kind': ''}
|
||||||
|
|
||||||
|
|
||||||
|
def _running_elapsed(step):
|
||||||
|
"""Compact 'Running 8m' / 'Running 1h:45' label."""
|
||||||
|
if not step or not step.date_started:
|
||||||
|
return _('Running')
|
||||||
|
delta = datetime.now() - step.date_started
|
||||||
|
minutes = int(delta.total_seconds() / 60)
|
||||||
|
if minutes < 60:
|
||||||
|
return _('Running %dm') % minutes
|
||||||
|
hours = minutes // 60
|
||||||
|
rem = minutes % 60
|
||||||
|
return _('Running %dh:%02d') % (hours, rem)
|
||||||
|
|
||||||
|
|
||||||
|
def _idle_hours(step):
|
||||||
|
if not step or not step.last_activity_at:
|
||||||
|
return 0
|
||||||
|
delta = datetime.now() - step.last_activity_at
|
||||||
|
return int(delta.total_seconds() / 3600)
|
||||||
|
|
||||||
|
|
||||||
|
def _due_label(deadline):
|
||||||
|
"""'Due May 16 · 3d' style label."""
|
||||||
|
if not deadline:
|
||||||
|
return ''
|
||||||
|
d = deadline.date() if hasattr(deadline, 'date') else deadline
|
||||||
|
today = date.today()
|
||||||
|
days = (d - today).days
|
||||||
|
base = d.strftime('%b %d')
|
||||||
|
if days == 0:
|
||||||
|
return _('Due %s · today') % base
|
||||||
|
if days == 1:
|
||||||
|
return _('Due %s · tomorrow') % base
|
||||||
|
if days > 1:
|
||||||
|
return _('Due %s · %dd') % (base, days)
|
||||||
|
return _('Due %s · %dd late') % (base, -days)
|
||||||
|
|
||||||
|
|
||||||
|
def _icons(job, step):
|
||||||
|
"""Compact icon row at the card footer."""
|
||||||
|
icons = []
|
||||||
|
if step:
|
||||||
|
if step.requires_signoff and not step.signoff_user_id:
|
||||||
|
icons.append('🔏')
|
||||||
|
if step.recipe_node_id \
|
||||||
|
and step.recipe_node_id.default_kind == 'bake':
|
||||||
|
icons.append('🔥')
|
||||||
|
if job.card_state == 'bake_due':
|
||||||
|
icons.append('⏰')
|
||||||
|
if job.card_state == 'no_parts':
|
||||||
|
icons.append('🚚')
|
||||||
|
if job.card_state == 'on_hold':
|
||||||
|
icons.append('💬')
|
||||||
|
if job.card_state == 'predecessor_locked':
|
||||||
|
icons.append('🔒')
|
||||||
|
if job.card_state == 'done':
|
||||||
|
icons.append('📜')
|
||||||
|
return icons
|
||||||
|
|
||||||
|
|
||||||
|
def _initials_for(user):
|
||||||
|
if not user or not user.name:
|
||||||
|
return ''
|
||||||
|
parts = user.name.strip().split()
|
||||||
|
if len(parts) == 1:
|
||||||
|
return parts[0][:2].upper()
|
||||||
|
return (parts[0][0] + parts[-1][0]).upper()
|
||||||
|
|
||||||
|
|
||||||
|
def _sort_key(card):
|
||||||
|
"""Sort within a column: overdue first, then by state priority,
|
||||||
|
then by due date (earlier = higher priority)."""
|
||||||
|
return (
|
||||||
|
0 if card['is_overdue'] else 1,
|
||||||
|
_SORT_PRIORITY.get(card['card_state'], 99),
|
||||||
|
card['due_date'] or '9999-12-31',
|
||||||
|
)
|
||||||
@@ -28,4 +28,18 @@
|
|||||||
<field name="tag">fp_process_tree</field>
|
<field name="tag">fp_process_tree</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<!-- Plant-view kanban (2026-05-23 redesign). -->
|
||||||
|
<!-- One card per fp.job grouped into 9 fixed columns by area_kind. -->
|
||||||
|
<!-- Replaces fp_shopfloor_landing when x_fc_shopfloor_layout='v2'. -->
|
||||||
|
<!-- The landing-action resolver in fusion_plating/data/fp_landing_data -->
|
||||||
|
<!-- .xml dispatches between this and the legacy action based on the -->
|
||||||
|
<!-- ir.config_parameter set by the new feature-flag setting. -->
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<record id="action_fp_plant_kanban" model="ir.actions.client">
|
||||||
|
<field name="name">Shop Floor</field>
|
||||||
|
<field name="tag">fp_plant_kanban</field>
|
||||||
|
<field name="target">main</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|||||||
Reference in New Issue
Block a user