Add polygon FSM zones and auto zone detection by coordinates
This commit is contained in:
@@ -3,6 +3,9 @@
|
|||||||
<record id="repair_fsm_zone_atlanta" model="repair.fsm.zone">
|
<record id="repair_fsm_zone_atlanta" model="repair.fsm.zone">
|
||||||
<field name="name">Atlanta Metro</field>
|
<field name="name">Atlanta Metro</field>
|
||||||
<field name="code">ATL</field>
|
<field name="code">ATL</field>
|
||||||
|
<field name="polygon_geojson"><![CDATA[
|
||||||
|
{"type":"Polygon","coordinates":[[[-84.55,33.60],[-84.20,33.60],[-84.20,33.90],[-84.55,33.90],[-84.55,33.60]]]}
|
||||||
|
]]></field>
|
||||||
<field name="state">active</field>
|
<field name="state">active</field>
|
||||||
<field name="active">True</field>
|
<field name="active">True</field>
|
||||||
</record>
|
</record>
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
from odoo import fields, models
|
import json
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from odoo import api, fields, models
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
|
|
||||||
|
|
||||||
class RepairFsmZone(models.Model):
|
class RepairFsmZone(models.Model):
|
||||||
@@ -9,6 +13,12 @@ class RepairFsmZone(models.Model):
|
|||||||
|
|
||||||
name = fields.Char(required=True, tracking=True)
|
name = fields.Char(required=True, tracking=True)
|
||||||
code = fields.Char(tracking=True)
|
code = fields.Char(tracking=True)
|
||||||
|
polygon_geojson = fields.Text(
|
||||||
|
string="Polygon (GeoJSON)",
|
||||||
|
required=True,
|
||||||
|
tracking=True,
|
||||||
|
help="GeoJSON Polygon geometry. Coordinates order: [longitude, latitude].",
|
||||||
|
)
|
||||||
state = fields.Selection(
|
state = fields.Selection(
|
||||||
selection=[
|
selection=[
|
||||||
("draft", "Draft"),
|
("draft", "Draft"),
|
||||||
@@ -32,3 +42,79 @@ class RepairFsmZone(models.Model):
|
|||||||
|
|
||||||
def action_reset_draft(self):
|
def action_reset_draft(self):
|
||||||
self.write({"state": "draft", "active": True})
|
self.write({"state": "draft", "active": True})
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _point_in_polygon(longitude, latitude, points):
|
||||||
|
inside = False
|
||||||
|
j = len(points) - 1
|
||||||
|
for i, (xi, yi) in enumerate(points):
|
||||||
|
xj, yj = points[j]
|
||||||
|
intersects = ((yi > latitude) != (yj > latitude)) and (
|
||||||
|
longitude < (xj - xi) * (latitude - yi) / ((yj - yi) or 1e-12) + xi
|
||||||
|
)
|
||||||
|
if intersects:
|
||||||
|
inside = not inside
|
||||||
|
j = i
|
||||||
|
return inside
|
||||||
|
|
||||||
|
def _extract_polygon_points(self):
|
||||||
|
self.ensure_one()
|
||||||
|
try:
|
||||||
|
data = json.loads(self.polygon_geojson or "")
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ValidationError(f"Invalid GeoJSON: {exc.msg}") from exc
|
||||||
|
|
||||||
|
ring = []
|
||||||
|
if isinstance(data, dict):
|
||||||
|
if data.get("type") == "Feature":
|
||||||
|
data = data.get("geometry") or {}
|
||||||
|
if data.get("type") != "Polygon":
|
||||||
|
raise ValidationError("GeoJSON must be of type Polygon.")
|
||||||
|
coords = data.get("coordinates") or []
|
||||||
|
if not coords or not isinstance(coords[0], list):
|
||||||
|
raise ValidationError("Polygon coordinates are missing.")
|
||||||
|
ring = coords[0]
|
||||||
|
elif isinstance(data, list):
|
||||||
|
ring = data
|
||||||
|
else:
|
||||||
|
raise ValidationError("Polygon must be a GeoJSON object or an array of points.")
|
||||||
|
|
||||||
|
points = []
|
||||||
|
for pair in ring:
|
||||||
|
if not isinstance(pair, (list, tuple)) or len(pair) < 2:
|
||||||
|
raise ValidationError("Each polygon point must be [longitude, latitude].")
|
||||||
|
points.append((float(pair[0]), float(pair[1])))
|
||||||
|
|
||||||
|
if len(points) >= 2 and points[0] == points[-1]:
|
||||||
|
points = points[:-1]
|
||||||
|
if len(points) < 3:
|
||||||
|
raise ValidationError("Polygon must contain at least 3 points.")
|
||||||
|
return points
|
||||||
|
|
||||||
|
def contains_point(self, latitude, longitude):
|
||||||
|
self.ensure_one()
|
||||||
|
if latitude is None or longitude is None:
|
||||||
|
return False
|
||||||
|
points = self._extract_polygon_points()
|
||||||
|
return self._point_in_polygon(float(longitude), float(latitude), points)
|
||||||
|
|
||||||
|
def action_open_polygon_in_map(self):
|
||||||
|
self.ensure_one()
|
||||||
|
encoded = quote(self.polygon_geojson or '{"type":"Polygon","coordinates":[[]]}')
|
||||||
|
return {
|
||||||
|
"type": "ir.actions.act_url",
|
||||||
|
"url": f"https://geojson.io/#data=data:application/json,{encoded}",
|
||||||
|
"target": "new",
|
||||||
|
}
|
||||||
|
|
||||||
|
def action_open_point_picker_map(self):
|
||||||
|
return {
|
||||||
|
"type": "ir.actions.act_url",
|
||||||
|
"url": "https://www.openstreetmap.org",
|
||||||
|
"target": "new",
|
||||||
|
}
|
||||||
|
|
||||||
|
@api.constrains("polygon_geojson")
|
||||||
|
def _check_polygon_geojson(self):
|
||||||
|
for rec in self:
|
||||||
|
rec._extract_polygon_points()
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
<button name="action_set_active" type="object" string="Set Active" class="btn-primary" invisible="state == 'active'"/>
|
<button name="action_set_active" type="object" string="Set Active" class="btn-primary" invisible="state == 'active'"/>
|
||||||
<button name="action_archive" type="object" string="Archive" invisible="state == 'archived'"/>
|
<button name="action_archive" type="object" string="Archive" invisible="state == 'archived'"/>
|
||||||
<button name="action_reset_draft" type="object" string="Reset to Draft" invisible="state == 'draft'"/>
|
<button name="action_reset_draft" type="object" string="Reset to Draft" invisible="state == 'draft'"/>
|
||||||
|
<button name="action_open_polygon_in_map" type="object" string="Open Polygon Map" invisible="not polygon_geojson"/>
|
||||||
|
<button name="action_open_point_picker_map" type="object" string="Open OSM"/>
|
||||||
<field name="state" widget="statusbar" statusbar_visible="draft,active,archived" options="{'clickable': '1'}"/>
|
<field name="state" widget="statusbar" statusbar_visible="draft,active,archived" options="{'clickable': '1'}"/>
|
||||||
</header>
|
</header>
|
||||||
<sheet>
|
<sheet>
|
||||||
@@ -17,6 +19,9 @@
|
|||||||
<field name="code"/>
|
<field name="code"/>
|
||||||
<field name="active"/>
|
<field name="active"/>
|
||||||
</group>
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="polygon_geojson" widget="text" placeholder='{"type":"Polygon","coordinates":[[[-84.55,33.60],[-84.20,33.60],[-84.20,33.90],[-84.55,33.90],[-84.55,33.60]]]}'/>
|
||||||
|
</group>
|
||||||
</sheet>
|
</sheet>
|
||||||
<chatter/>
|
<chatter/>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<field name="name"/>
|
<field name="name"/>
|
||||||
<field name="code" optional="show"/>
|
<field name="code" optional="show"/>
|
||||||
<field name="state" widget="badge" optional="show"/>
|
<field name="state" widget="badge" optional="show"/>
|
||||||
|
<field name="polygon_geojson" optional="hide"/>
|
||||||
<field name="active" optional="hide"/>
|
<field name="active" optional="hide"/>
|
||||||
</list>
|
</list>
|
||||||
</field>
|
</field>
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ class RepairWorkOrder(models.Model):
|
|||||||
|
|
||||||
name = fields.Char(default="New", copy=False, readonly=True, tracking=True)
|
name = fields.Char(default="New", copy=False, readonly=True, tracking=True)
|
||||||
contact_id = fields.Many2one("dsrpt.contact", required=True, tracking=True)
|
contact_id = fields.Many2one("dsrpt.contact", required=True, tracking=True)
|
||||||
|
service_address = fields.Char(tracking=True)
|
||||||
|
service_latitude = fields.Float(digits=(10, 6), tracking=True)
|
||||||
|
service_longitude = fields.Float(digits=(10, 6), tracking=True)
|
||||||
zone_id = fields.Many2one("repair.fsm.zone", string="FSM Zone", tracking=True)
|
zone_id = fields.Many2one("repair.fsm.zone", string="FSM Zone", tracking=True)
|
||||||
description = fields.Text(tracking=True)
|
description = fields.Text(tracking=True)
|
||||||
requested_datetime = fields.Datetime(default=fields.Datetime.now, tracking=True)
|
requested_datetime = fields.Datetime(default=fields.Datetime.now, tracking=True)
|
||||||
@@ -39,7 +42,15 @@ class RepairWorkOrder(models.Model):
|
|||||||
for vals in vals_list:
|
for vals in vals_list:
|
||||||
if vals.get("name", "New") == "New":
|
if vals.get("name", "New") == "New":
|
||||||
vals["name"] = self.env["ir.sequence"].next_by_code("repair.work.order") or "New"
|
vals["name"] = self.env["ir.sequence"].next_by_code("repair.work.order") or "New"
|
||||||
return super().create(vals_list)
|
records = super().create(vals_list)
|
||||||
|
for record, vals in zip(records, vals_list):
|
||||||
|
if vals.get("zone_id"):
|
||||||
|
continue
|
||||||
|
if record._has_service_point():
|
||||||
|
zone = record._find_zone_for_point(record.service_latitude, record.service_longitude)
|
||||||
|
if zone and record.zone_id != zone:
|
||||||
|
record.zone_id = zone.id
|
||||||
|
return records
|
||||||
|
|
||||||
def _group_expand_states(self, states, domain, order):
|
def _group_expand_states(self, states, domain, order):
|
||||||
return [key for key, _label in self._fields["state"].selection]
|
return [key for key, _label in self._fields["state"].selection]
|
||||||
@@ -50,6 +61,45 @@ class RepairWorkOrder(models.Model):
|
|||||||
rec.total_time_hours = sum(rec.time_line_ids.mapped("hours"))
|
rec.total_time_hours = sum(rec.time_line_ids.mapped("hours"))
|
||||||
rec.total_material_cost = sum(rec.material_line_ids.mapped("subtotal"))
|
rec.total_material_cost = sum(rec.material_line_ids.mapped("subtotal"))
|
||||||
|
|
||||||
|
def _has_service_point(self):
|
||||||
|
self.ensure_one()
|
||||||
|
return self.service_latitude is not False and self.service_longitude is not False
|
||||||
|
|
||||||
|
def _find_zone_for_point(self, latitude, longitude):
|
||||||
|
zones = self.env["repair.fsm.zone"].search([("active", "=", True), ("state", "=", "active")])
|
||||||
|
for zone in zones:
|
||||||
|
if zone.contains_point(latitude, longitude):
|
||||||
|
return zone
|
||||||
|
return self.env["repair.fsm.zone"]
|
||||||
|
|
||||||
|
@api.onchange("service_latitude", "service_longitude")
|
||||||
|
def _onchange_service_coordinates(self):
|
||||||
|
for rec in self:
|
||||||
|
if rec._has_service_point():
|
||||||
|
zone = rec._find_zone_for_point(rec.service_latitude, rec.service_longitude)
|
||||||
|
if zone:
|
||||||
|
rec.zone_id = zone
|
||||||
|
|
||||||
|
def action_detect_zone(self):
|
||||||
|
for rec in self:
|
||||||
|
if not rec._has_service_point():
|
||||||
|
raise ValidationError("Service coordinates are required to detect FSM Zone.")
|
||||||
|
zone = rec._find_zone_for_point(rec.service_latitude, rec.service_longitude)
|
||||||
|
if not zone:
|
||||||
|
raise ValidationError("No active FSM Zone contains this point.")
|
||||||
|
rec.zone_id = zone.id
|
||||||
|
return True
|
||||||
|
|
||||||
|
def write(self, vals):
|
||||||
|
result = super().write(vals)
|
||||||
|
if ("service_latitude" in vals or "service_longitude" in vals) and "zone_id" not in vals:
|
||||||
|
for rec in self:
|
||||||
|
if rec._has_service_point():
|
||||||
|
zone = rec._find_zone_for_point(rec.service_latitude, rec.service_longitude)
|
||||||
|
if zone and rec.zone_id != zone:
|
||||||
|
super(RepairWorkOrder, rec).write({"zone_id": zone.id})
|
||||||
|
return result
|
||||||
|
|
||||||
def action_confirm(self):
|
def action_confirm(self):
|
||||||
self.write({"state": "confirmed"})
|
self.write({"state": "confirmed"})
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<header>
|
<header>
|
||||||
<button name="action_confirm" type="object" string="Confirm" class="btn-primary" invisible="state != 'draft'"/>
|
<button name="action_confirm" type="object" string="Confirm" class="btn-primary" invisible="state != 'draft'"/>
|
||||||
<button name="action_assign_to_me" type="object" string="Assign to me" class="btn-primary" invisible="state not in ('confirmed','assigned')"/>
|
<button name="action_assign_to_me" type="object" string="Assign to me" class="btn-primary" invisible="state not in ('confirmed','assigned')"/>
|
||||||
|
<button name="action_detect_zone" type="object" string="Detect Zone" invisible="not service_latitude or not service_longitude"/>
|
||||||
<button name="action_start" type="object" string="Start" invisible="state != 'assigned'"/>
|
<button name="action_start" type="object" string="Start" invisible="state != 'assigned'"/>
|
||||||
<button name="action_done" type="object" string="Done" class="btn-primary" invisible="state not in ('in_progress','assigned')"/>
|
<button name="action_done" type="object" string="Done" class="btn-primary" invisible="state not in ('in_progress','assigned')"/>
|
||||||
<button name="action_cancel" type="object" string="Cancel" invisible="state in ('done','cancelled')"/>
|
<button name="action_cancel" type="object" string="Cancel" invisible="state in ('done','cancelled')"/>
|
||||||
@@ -19,6 +20,9 @@
|
|||||||
<group>
|
<group>
|
||||||
<field name="name" readonly="1"/>
|
<field name="name" readonly="1"/>
|
||||||
<field name="contact_id"/>
|
<field name="contact_id"/>
|
||||||
|
<field name="service_address"/>
|
||||||
|
<field name="service_latitude"/>
|
||||||
|
<field name="service_longitude"/>
|
||||||
<field name="zone_id"/>
|
<field name="zone_id"/>
|
||||||
</group>
|
</group>
|
||||||
<group>
|
<group>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<kanban default_group_by="state">
|
<kanban default_group_by="state">
|
||||||
<field name="name"/>
|
<field name="name"/>
|
||||||
<field name="contact_id"/>
|
<field name="contact_id"/>
|
||||||
|
<field name="service_address"/>
|
||||||
<field name="scheduled_datetime"/>
|
<field name="scheduled_datetime"/>
|
||||||
<field name="technician_id"/>
|
<field name="technician_id"/>
|
||||||
<field name="state"/>
|
<field name="state"/>
|
||||||
@@ -19,6 +20,9 @@
|
|||||||
<div>
|
<div>
|
||||||
<field name="contact_id"/>
|
<field name="contact_id"/>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<field name="service_address"/>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<field name="scheduled_datetime"/>
|
<field name="scheduled_datetime"/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,12 +7,15 @@
|
|||||||
<list>
|
<list>
|
||||||
<field name="name"/>
|
<field name="name"/>
|
||||||
<field name="contact_id" optional="show"/>
|
<field name="contact_id" optional="show"/>
|
||||||
|
<field name="service_address" optional="show"/>
|
||||||
<field name="zone_id" optional="show"/>
|
<field name="zone_id" optional="show"/>
|
||||||
<field name="scheduled_datetime" optional="show"/>
|
<field name="scheduled_datetime" optional="show"/>
|
||||||
<field name="technician_id" optional="show"/>
|
<field name="technician_id" optional="show"/>
|
||||||
<field name="state" widget="badge" optional="show"/>
|
<field name="state" widget="badge" optional="show"/>
|
||||||
<field name="total_time_hours" optional="show"/>
|
<field name="total_time_hours" optional="show"/>
|
||||||
<field name="total_material_cost" optional="show"/>
|
<field name="total_material_cost" optional="show"/>
|
||||||
|
<field name="service_latitude" optional="hide"/>
|
||||||
|
<field name="service_longitude" optional="hide"/>
|
||||||
<field name="assigned_user_id" optional="hide"/>
|
<field name="assigned_user_id" optional="hide"/>
|
||||||
<field name="requested_datetime" optional="hide"/>
|
<field name="requested_datetime" optional="hide"/>
|
||||||
</list>
|
</list>
|
||||||
|
|||||||
Reference in New Issue
Block a user