Update CRM, KPI, dan Product Label
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Diagnose barcode label printing problems for garazd_product_label in Odoo 14.
|
||||
|
||||
What this script checks:
|
||||
1. Odoo connectivity and authentication
|
||||
2. Module installation state and version
|
||||
3. Critical report URL parameters used by PDF engine
|
||||
4. Report action and template presence in database
|
||||
5. Direct barcode endpoint availability via HTTP
|
||||
|
||||
Usage example:
|
||||
python check_barcode_label_production.py --url http://127.0.0.1:8070 --db mydb --user admin --password admin
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import xmlrpc.client
|
||||
|
||||
|
||||
def _ok(msg):
|
||||
print(f"[OK] {msg}")
|
||||
|
||||
|
||||
def _warn(msg):
|
||||
print(f"[WARN] {msg}")
|
||||
|
||||
|
||||
def _err(msg):
|
||||
print(f"[ERROR] {msg}")
|
||||
|
||||
|
||||
def _fetch_url(url, timeout=10):
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return resp.status, resp.headers.get("Content-Type", "")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Check production barcode label readiness")
|
||||
parser.add_argument("--url", required=True, help="Odoo base URL, e.g. http://127.0.0.1:8070")
|
||||
parser.add_argument("--db", required=True, help="Database name")
|
||||
parser.add_argument("--user", required=True, help="Odoo username")
|
||||
parser.add_argument("--password", required=True, help="Odoo password")
|
||||
parser.add_argument(
|
||||
"--test-value",
|
||||
default="BATCH20260309",
|
||||
help="Sample barcode value for /report/barcode check",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
base = args.url.rstrip("/")
|
||||
common = xmlrpc.client.ServerProxy(f"{base}/xmlrpc/2/common")
|
||||
models = xmlrpc.client.ServerProxy(f"{base}/xmlrpc/2/object")
|
||||
|
||||
print("=" * 72)
|
||||
print("DIAGNOSIS: BARCODE LABEL PRODUCTION")
|
||||
print("=" * 72)
|
||||
|
||||
try:
|
||||
version = common.version()
|
||||
_ok(f"Connected to Odoo: {version.get('server_version', 'unknown')}")
|
||||
except Exception as exc:
|
||||
_err(f"Cannot connect to Odoo XML-RPC at {base}: {exc}")
|
||||
return 2
|
||||
|
||||
uid = common.authenticate(args.db, args.user, args.password, {})
|
||||
if not uid:
|
||||
_err("Authentication failed. Check --db/--user/--password.")
|
||||
return 2
|
||||
_ok(f"Authenticated as UID {uid}")
|
||||
|
||||
module_ids = models.execute_kw(
|
||||
args.db,
|
||||
uid,
|
||||
args.password,
|
||||
"ir.module.module",
|
||||
"search",
|
||||
[[("name", "=", "garazd_product_label")]],
|
||||
{"limit": 1},
|
||||
)
|
||||
if not module_ids:
|
||||
_err("Module garazd_product_label not found in database.")
|
||||
return 2
|
||||
|
||||
module_data = models.execute_kw(
|
||||
args.db,
|
||||
uid,
|
||||
args.password,
|
||||
"ir.module.module",
|
||||
"read",
|
||||
[module_ids],
|
||||
{"fields": ["name", "state", "installed_version", "latest_version"]},
|
||||
)[0]
|
||||
_ok(
|
||||
"Module state: {state}, installed_version: {iv}, latest_version: {lv}".format(
|
||||
state=module_data.get("state"),
|
||||
iv=module_data.get("installed_version") or "-",
|
||||
lv=module_data.get("latest_version") or "-",
|
||||
)
|
||||
)
|
||||
if module_data.get("state") != "installed":
|
||||
_warn("Module is not installed in production database.")
|
||||
|
||||
params = ["report.url", "web.base.url", "web.base.url.freeze"]
|
||||
print("\nConfig parameters:")
|
||||
for key in params:
|
||||
value = models.execute_kw(
|
||||
args.db,
|
||||
uid,
|
||||
args.password,
|
||||
"ir.config_parameter",
|
||||
"get_param",
|
||||
[key],
|
||||
)
|
||||
if value:
|
||||
_ok(f"{key} = {value}")
|
||||
else:
|
||||
_warn(f"{key} is empty")
|
||||
|
||||
report_names = [
|
||||
"garazd_product_label.report_product_label_57x35_template",
|
||||
"garazd_product_label.report_product_label_50x38_template",
|
||||
"garazd_product_label.report_product_label_a4_90x50_template",
|
||||
"garazd_product_label.report_product_label_thermal_4x6_template",
|
||||
]
|
||||
print("\nReport actions:")
|
||||
for report_name in report_names:
|
||||
report_ids = models.execute_kw(
|
||||
args.db,
|
||||
uid,
|
||||
args.password,
|
||||
"ir.actions.report",
|
||||
"search",
|
||||
[[("report_name", "=", report_name)]],
|
||||
{"limit": 1},
|
||||
)
|
||||
if report_ids:
|
||||
_ok(f"Found report action: {report_name}")
|
||||
else:
|
||||
_warn(f"Missing report action: {report_name}")
|
||||
|
||||
barcode_url = (
|
||||
f"{base}/report/barcode/?type=Code128&value={args.test_value}"
|
||||
"&width=600&height=100&humanreadable=0"
|
||||
)
|
||||
print("\nHTTP barcode test:")
|
||||
try:
|
||||
status, content_type = _fetch_url(barcode_url)
|
||||
if status == 200:
|
||||
_ok(f"Barcode endpoint returns 200 ({content_type})")
|
||||
else:
|
||||
_warn(f"Barcode endpoint returns status {status} ({content_type})")
|
||||
except urllib.error.HTTPError as exc:
|
||||
_err(f"Barcode endpoint HTTP error: {exc.code} {exc.reason}")
|
||||
except Exception as exc:
|
||||
_err(f"Barcode endpoint failed: {exc}")
|
||||
|
||||
print("\nSuggested next actions if print still fails in production:")
|
||||
print("1. Upgrade module: Apps -> Custom Product Labels -> Upgrade")
|
||||
print("2. Ensure report.url points to local Odoo URL reachable by server")
|
||||
print("3. Test /report/barcode URL directly from production host")
|
||||
print("4. Reprint and inspect odoo log for wkhtmltopdf/report errors")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -43,6 +43,16 @@
|
||||
<field name="print_report_name">'Product Labels 57x35mm'</field>
|
||||
</record>
|
||||
|
||||
<record id="action_report_product_label_A4_90x50" model="ir.actions.report">
|
||||
<field name="name">Product Labels 90x50mm (A4, 8 pcs)</field>
|
||||
<field name="model">print.product.label.line</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="paperformat_id" ref="paperformat_label_a4_blank"/>
|
||||
<field name="report_name">garazd_product_label.report_product_label_a4_90x50_template</field>
|
||||
<field name="report_file">garazd_product_label.report_product_label_a4_90x50_template</field>
|
||||
<field name="print_report_name">'Product Labels 90x50mm'</field>
|
||||
</record>
|
||||
|
||||
<record id="action_report_product_label_50x38" model="ir.actions.report">
|
||||
<field name="name">Product Labels 50x38mm</field>
|
||||
<field name="model">print.product.label.line</field>
|
||||
@@ -66,8 +76,8 @@
|
||||
<record id="paperformat_label_thermal_4x6" model="report.paperformat">
|
||||
<field name="name">Thermal Printer 4x6 inch (Xprinter)</field>
|
||||
<field name="format">custom</field>
|
||||
<field name="page_height">152.4</field>
|
||||
<field name="page_width">101.6</field>
|
||||
<field name="page_height">152</field>
|
||||
<field name="page_width">102</field>
|
||||
<field name="orientation">Portrait</field>
|
||||
<field name="margin_top">0</field>
|
||||
<field name="margin_bottom">0</field>
|
||||
|
||||
@@ -24,23 +24,11 @@
|
||||
<strong>LOT:</strong> <span t-field="label.lot_id.name"/>
|
||||
</div>
|
||||
<div t-if="label.barcode" class="text-center align-middle" style="width: 100%; height: 13px; padding: 0 8px;">
|
||||
<t t-if="label.wizard_id.humanreadable" t-set="show_code" t-value="1"/>
|
||||
<t t-else="" t-set="show_code" t-value="0" />
|
||||
<img alt="Barcode" t-if="label.lot_id"
|
||||
t-att-src="'/report/barcode/?type=%s&value=%s&width=%s&height=%s&humanreadable=%s' % ('Code128', label.barcode, 600, 100, show_code)"
|
||||
style="overflow: hidden; width: 100%; height: 1.4rem;"
|
||||
/>
|
||||
<img alt="Barcode" t-elif="len(label.barcode) == 13"
|
||||
t-att-src="'/report/barcode/?type=%s&value=%s&width=%s&height=%s&humanreadable=%s' % ('EAN13', label.barcode, 600, 100, show_code)"
|
||||
style="overflow: hidden; width: 100%; height: 1.4rem;"
|
||||
/>
|
||||
<img alt="Barcode" t-elif="len(label.barcode) == 8"
|
||||
t-att-src="'/report/barcode/?type=%s&value=%s&width=%s&height=%s&humanreadable=%s' % ('EAN8', label.barcode, 600, 100, show_code)"
|
||||
<img alt="Barcode"
|
||||
t-att-src="label.get_barcode_url(600, 100)"
|
||||
t-att-data-alt-src="label.get_barcode_url_alt(600, 100)"
|
||||
onerror="if (this.dataset.altSrc && this.src !== this.dataset.altSrc) { this.src = this.dataset.altSrc; }"
|
||||
style="overflow: hidden; width: 100%; height: 1.4rem;"/>
|
||||
<img alt="Barcode" t-else=""
|
||||
t-att-src="'/report/barcode/?type=%s&value=%s&width=%s&height=%s&humanreadable=%s' % ('Code128', label.barcode, 600, 100, show_code)"
|
||||
style="overflow: hidden; width: 100%; height: 1.4rem;"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -72,6 +60,69 @@
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<template id="label_a4_90x50">
|
||||
<table class="table" style="margin: 0; padding: 0; width: 100%; height: 100%;">
|
||||
<tr style="border: 0; margin: 0; padding: 0; height: 100%;">
|
||||
<td class="text-center" style="border: 0; margin: 0; padding: 4px 6px; vertical-align: top;">
|
||||
<div style="overflow: hidden; height: 38px; font-size: 13px; font-weight: bold; line-height: 1.2; margin-bottom: 2px;">
|
||||
<span t-field="label.product_id.name"/>
|
||||
<span t-if="label.product_id.product_template_attribute_value_ids"
|
||||
t-esc="u', '.join(map(lambda x: x.attribute_line_id.attribute_id.name + u': ' + x.name, label.product_id.product_template_attribute_value_ids))"
|
||||
style="font-size: 10px; display: block; font-weight: normal;"/>
|
||||
</div>
|
||||
<div style="width: 100%; font-size: 18px; font-weight: bold; line-height: 1.0; margin: 0 0 2px 0;">
|
||||
<span t-if="label.product_id.currency_id.position == 'before'" t-field="label.product_id.currency_id.symbol"/>
|
||||
<span t-field="label.product_id.lst_price"/>
|
||||
<span t-if="label.product_id.currency_id.position == 'after'" t-field="label.product_id.currency_id.symbol"/>
|
||||
</div>
|
||||
<div style="width: 100%; font-size: 10px; line-height: 1.1; margin-bottom: 2px;">
|
||||
<span t-if="label.product_id.default_code">
|
||||
<strong>Code:</strong> <span t-field="label.product_id.default_code"/>
|
||||
</span>
|
||||
</div>
|
||||
<div t-if="label.lot_id" style="width: 100%; font-size: 10px; line-height: 1.1; margin-bottom: 2px;">
|
||||
<strong>LOT:</strong> <span t-field="label.lot_id.name"/>
|
||||
</div>
|
||||
<div t-elif="label.wizard_id.batch" style="width: 100%; font-size: 10px; line-height: 1.1; margin-bottom: 2px;">
|
||||
<strong>BATCH:</strong> <span t-field="label.wizard_id.batch"/>
|
||||
</div>
|
||||
<div t-if="label.barcode" class="text-center align-middle" style="width: 100%; height: 32px; padding: 0 2px; margin-top: 2px;">
|
||||
<img alt="Barcode"
|
||||
t-att-src="label.get_barcode_url(650, 130)"
|
||||
t-att-data-alt-src="label.get_barcode_url_alt(650, 130)"
|
||||
onerror="if (this.dataset.altSrc && this.src !== this.dataset.altSrc) { this.src = this.dataset.altSrc; }"
|
||||
style="overflow: hidden; width: 100%; height: 2.1rem;"/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<template id="report_product_label_a4_90x50_template">
|
||||
<t t-call="web.html_container">
|
||||
<t t-call="web.basic_layout">
|
||||
<t t-set="count" t-value="0"/>
|
||||
<div class="page">
|
||||
<div class="oe_structure"/>
|
||||
<t t-foreach="docs" t-as="label">
|
||||
<t t-set="qty" t-value="1"/>
|
||||
<t t-if="label.qty">
|
||||
<t t-set="qty" t-value="label.qty"/>
|
||||
</t>
|
||||
<t t-foreach="list(range(qty))" t-as="index_qty">
|
||||
<div t-if="count and count % 8 == 0" style="page-break-after: always;"/>
|
||||
<div t-if="count % 2 == 0" style="clear: both;"/>
|
||||
<div t-att-style="'width: 346px; float: left; height: 188px; margin: 0 10px 10px; border: {};'.format('%dpx solid #777;' % label.wizard_id.border_width if label.wizard_id.border_width else '0')">
|
||||
<t t-call="garazd_product_label.label_a4_90x50"/>
|
||||
</div>
|
||||
<t t-set="count" t-value="count + 1"/>
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<template id="report_product_label_50x38_template">
|
||||
<t t-call="web.html_container">
|
||||
<t t-call="web.basic_layout">
|
||||
@@ -104,19 +155,10 @@
|
||||
<strong>LOT:</strong> <span t-field="label.lot_id.name"/>
|
||||
</div>
|
||||
<div t-if="label.barcode" class="text-center align-middle" style="margin: 2px; height: 32px; clear: both;">
|
||||
<t t-if="label.wizard_id.humanreadable" t-set="show_code" t-value="1"/>
|
||||
<t t-else="" t-set="show_code" t-value="0" />
|
||||
<img alt="Barcode" t-if="label.lot_id"
|
||||
t-att-src="'/report/barcode/?type=%s&value=%s&width=%s&height=%s&humanreadable=%s' % ('Code128', label.barcode, 600, 100, show_code)"
|
||||
style="overflow: hidden; width: 100%; height: 2rem;"/>
|
||||
<img alt="Barcode" t-elif="len(label.barcode) == 13"
|
||||
t-att-src="'/report/barcode/?type=%s&value=%s&width=%s&height=%s&humanreadable=%s' % ('EAN13', label.barcode, 600, 100, show_code)"
|
||||
style="overflow: hidden; width: 100%; height: 2rem;"/>
|
||||
<img alt="Barcode" t-elif="len(label.barcode) == 8"
|
||||
t-att-src="'/report/barcode/?type=%s&value=%s&width=%s&height=%s&humanreadable=%s' % ('EAN8', label.barcode, 600, 100, show_code)"
|
||||
style="overflow: hidden; width: 100%; height: 2rem;"/>
|
||||
<img alt="Barcode" t-else=""
|
||||
t-att-src="'/report/barcode/?type=%s&value=%s&width=%s&height=%s&humanreadable=%s' % ('Code128', label.barcode, 600, 100, show_code)"
|
||||
<img alt="Barcode"
|
||||
t-att-src="label.get_barcode_url(600, 100)"
|
||||
t-att-data-alt-src="label.get_barcode_url_alt(600, 100)"
|
||||
onerror="if (this.dataset.altSrc && this.src !== this.dataset.altSrc) { this.src = this.dataset.altSrc; }"
|
||||
style="overflow: hidden; width: 100%; height: 2rem;"/>
|
||||
</div>
|
||||
</td>
|
||||
@@ -166,19 +208,10 @@
|
||||
|
||||
<!-- Barcode -->
|
||||
<div t-if="label.barcode" class="text-center align-middle" style="width: 100%; height: 30px; padding: 2px 0; margin: 2px 0;">
|
||||
<t t-if="label.wizard_id.humanreadable" t-set="show_code" t-value="1"/>
|
||||
<t t-else="" t-set="show_code" t-value="0" />
|
||||
<img alt="Barcode" t-if="label.lot_id"
|
||||
t-att-src="'/report/barcode/?type=%s&value=%s&width=%s&height=%s&humanreadable=%s' % ('Code128', label.barcode, 500, 80, show_code)"
|
||||
style="overflow: hidden; width: 95%; height: 1.8rem;"/>
|
||||
<img alt="Barcode" t-elif="len(label.barcode) == 13"
|
||||
t-att-src="'/report/barcode/?type=%s&value=%s&width=%s&height=%s&humanreadable=%s' % ('EAN13', label.barcode, 500, 80, show_code)"
|
||||
style="overflow: hidden; width: 95%; height: 1.8rem;"/>
|
||||
<img alt="Barcode" t-elif="len(label.barcode) == 8"
|
||||
t-att-src="'/report/barcode/?type=%s&value=%s&width=%s&height=%s&humanreadable=%s' % ('EAN8', label.barcode, 500, 80, show_code)"
|
||||
style="overflow: hidden; width: 95%; height: 1.8rem;"/>
|
||||
<img alt="Barcode" t-else=""
|
||||
t-att-src="'/report/barcode/?type=%s&value=%s&width=%s&height=%s&humanreadable=%s' % ('Code128', label.barcode, 500, 80, show_code)"
|
||||
<img alt="Barcode"
|
||||
t-att-src="label.get_barcode_url(500, 80)"
|
||||
t-att-data-alt-src="label.get_barcode_url_alt(500, 80)"
|
||||
onerror="if (this.dataset.altSrc && this.src !== this.dataset.altSrc) { this.src = this.dataset.altSrc; }"
|
||||
style="overflow: hidden; width: 95%; height: 1.8rem;"/>
|
||||
</div>
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -3,7 +3,10 @@
|
||||
# @author: Iryna Razumovska (<support@garazd.biz>)
|
||||
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html).
|
||||
|
||||
from urllib.parse import quote
|
||||
|
||||
from odoo import api, fields, models
|
||||
from reportlab.graphics.barcode import createBarcodeDrawing
|
||||
|
||||
|
||||
class PrintProductLabelLine(models.TransientModel):
|
||||
@@ -39,6 +42,49 @@ class PrintProductLabelLine(models.TransientModel):
|
||||
for label in self:
|
||||
label.barcode = label.lot_id.name or label.product_id.barcode
|
||||
|
||||
def _get_barcode_type(self):
|
||||
self.ensure_one()
|
||||
barcode = (self.barcode or '').strip()
|
||||
if self.lot_id:
|
||||
return 'Code128'
|
||||
if barcode.isdigit() and len(barcode) == 13:
|
||||
return 'EAN13'
|
||||
if barcode.isdigit() and len(barcode) == 8:
|
||||
return 'EAN8'
|
||||
return 'Code128'
|
||||
|
||||
def get_barcode_url(self, width=600, height=100):
|
||||
self.ensure_one()
|
||||
if not self.barcode:
|
||||
return False
|
||||
barcode_value = quote(self.barcode, safe='')
|
||||
humanreadable = 1 if self.wizard_id.humanreadable else 0
|
||||
# Default Odoo barcode route with trailing slash is broadly compatible.
|
||||
return '/report/barcode/?type=%s&value=%s&width=%s&height=%s&humanreadable=%s' % (
|
||||
self._get_barcode_type(),
|
||||
barcode_value,
|
||||
width,
|
||||
height,
|
||||
humanreadable,
|
||||
)
|
||||
|
||||
def get_barcode_url_alt(self, width=600, height=100):
|
||||
self.ensure_one()
|
||||
if not self.barcode:
|
||||
return False
|
||||
barcode_value = quote(self.barcode, safe='')
|
||||
humanreadable = 1 if self.wizard_id.humanreadable else 0
|
||||
# Fallback variant without trailing slash for strict proxies.
|
||||
return '/report/barcode?type=%s&value=%s&width=%s&height=%s&humanreadable=%s' % (
|
||||
self._get_barcode_type(),
|
||||
barcode_value,
|
||||
width,
|
||||
height,
|
||||
humanreadable,
|
||||
)
|
||||
|
||||
|
||||
|
||||
def action_plus_qty(self):
|
||||
self.ensure_one()
|
||||
if not self.qty:
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -35,6 +35,7 @@ class CrmActivityHistory(models.Model):
|
||||
created_by_id = fields.Many2one("res.users", string="Created By", readonly=True)
|
||||
summary = fields.Char(string="Summary", readonly=True)
|
||||
note = fields.Html(string="Scheduled Note", readonly=True)
|
||||
kilometer = fields.Float(string="Kilometer (KM)", digits=(16, 2), readonly=True)
|
||||
date_deadline = fields.Date(string="Deadline", readonly=True)
|
||||
scheduled_at = fields.Datetime(string="Scheduled At", readonly=True)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from datetime import timedelta
|
||||
class MailActivity(models.Model):
|
||||
_inherit = "mail.activity"
|
||||
|
||||
kilometer = fields.Float(string="Kilometer (KM)", digits=(16, 2))
|
||||
gps_captured = fields.Boolean(string="GPS Captured")
|
||||
gps_latitude = fields.Float(string="GPS Latitude", digits=(9, 6))
|
||||
gps_longitude = fields.Float(string="GPS Longitude", digits=(9, 6))
|
||||
@@ -98,7 +99,13 @@ class MailActivity(models.Model):
|
||||
vals["gps_openstreetmap_url"] = self._build_osm_url(vals.get("gps_latitude"), vals.get("gps_longitude"))
|
||||
elif "gps_latitude" in vals or "gps_longitude" in vals:
|
||||
vals["gps_openstreetmap_url"] = False
|
||||
return super().write(vals)
|
||||
result = super().write(vals)
|
||||
|
||||
if "kilometer" in vals:
|
||||
histories = self.env["crm.activity.history"].sudo().search([("activity_id", "in", self.ids)])
|
||||
if histories:
|
||||
histories.write({"kilometer": vals.get("kilometer") or 0.0})
|
||||
return result
|
||||
|
||||
def action_feedback(self, feedback=False, attachment_ids=None):
|
||||
crm_activities = self.filtered(lambda a: a.res_model == "crm.lead")
|
||||
@@ -161,6 +168,7 @@ class MailActivity(models.Model):
|
||||
"created_by_id": self.env.user.id,
|
||||
"summary": activity.summary,
|
||||
"note": activity.note,
|
||||
"kilometer": activity.kilometer,
|
||||
"date_deadline": activity.date_deadline,
|
||||
"scheduled_at": fields.Datetime.now(),
|
||||
"state": "scheduled",
|
||||
@@ -215,6 +223,7 @@ class MailActivity(models.Model):
|
||||
"created_by_id": self.env.user.id,
|
||||
"summary": activity.summary,
|
||||
"note": activity.note,
|
||||
"kilometer": activity.kilometer,
|
||||
"date_deadline": activity.date_deadline,
|
||||
"scheduled_at": fields.Datetime.now(),
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
<field name="assigned_user_id"/>
|
||||
<field name="business_category_id"/>
|
||||
<field name="team_id"/>
|
||||
<field name="kilometer"/>
|
||||
<field name="date_deadline"/>
|
||||
<field name="schedule_gps_url" widget="url" string="Schedule GPS"/>
|
||||
<field name="done_gps_url" widget="url" string="Done GPS"/>
|
||||
@@ -65,6 +66,7 @@
|
||||
<group>
|
||||
<field name="business_category_id"/>
|
||||
<field name="team_id"/>
|
||||
<field name="kilometer"/>
|
||||
<field name="date_deadline"/>
|
||||
<field name="scheduled_at"/>
|
||||
<field name="done_at"/>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='date_deadline']" position="after">
|
||||
<group string="GPS Location" name="gps_group" col="2" colspan="2">
|
||||
<field name="kilometer" placeholder="Contoh: 12.5" colspan="2"/>
|
||||
<field name="gps_captured" invisible="1" />
|
||||
<field name="gps_latitude" colspan="2" nolabel="1" style="width: 98% !important; min-width: 300px !important;" />
|
||||
<field name="gps_longitude" colspan="2" nolabel="1" style="width: 98% !important; min-width: 300px !important;" />
|
||||
@@ -23,6 +24,7 @@
|
||||
<field name="inherit_id" ref="mail.mail_activity_view_tree" />
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='date_deadline']" position="after">
|
||||
<field name="kilometer" readonly="1" />
|
||||
<field name="gps_latitude" readonly="1" />
|
||||
<field name="gps_longitude" readonly="1" />
|
||||
<field name="gps_openstreetmap_url" widget="url" string="OpenStreetMap" readonly="1" />
|
||||
|
||||
@@ -21,7 +21,6 @@ plus a two-step approval flow: Sales Team Leader then Accounting Manager.
|
||||
"security/security.xml",
|
||||
"security/ir.model.access.csv",
|
||||
"security/ir.rule.csv",
|
||||
"data/customer_behavior_segment_data.xml",
|
||||
"views/crm_business_category_views.xml",
|
||||
"views/customer_behavior_dashboard_views.xml",
|
||||
"views/customer_behavior_recompute_wizard_views.xml",
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -28,15 +28,20 @@ class CustomerBehaviorAnalysis(models.Model):
|
||||
|
||||
_sql_constraints = [
|
||||
(
|
||||
"customer_behavior_analysis_partner_date_uniq",
|
||||
"unique(partner_id, analysis_date)",
|
||||
"Only one analysis per customer per date is allowed.",
|
||||
"customer_behavior_analysis_partner_date_category_uniq",
|
||||
"unique(partner_id, analysis_date, business_category_id)",
|
||||
"Only one analysis per customer, business category, and date is allowed.",
|
||||
),
|
||||
]
|
||||
|
||||
@api.model
|
||||
def _get_segment_by_code(self, code):
|
||||
return self.env["customer.behavior.segment"].search([("code", "=", code)], limit=1)
|
||||
def _get_segment_by_code(self, code, config):
|
||||
if not config:
|
||||
return self.env["customer.behavior.segment"]
|
||||
return self.env["customer.behavior.segment"].search(
|
||||
[("code", "=", code), ("config_id", "=", config.id)],
|
||||
limit=1,
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _determine_segment_code(self, days_since_last_purchase, previous_gap_days, total_orders, config):
|
||||
@@ -55,30 +60,23 @@ class CustomerBehaviorAnalysis(models.Model):
|
||||
return "repeat"
|
||||
|
||||
@api.model
|
||||
def _reset_partner_behavior(self, partners, analysis_date):
|
||||
def _reset_partner_behavior(self, partners, analysis_date, business_category=None):
|
||||
partners = partners.commercial_partner_id
|
||||
if not partners:
|
||||
return
|
||||
self.sudo().search(
|
||||
[
|
||||
("partner_id", "in", partners.ids),
|
||||
("analysis_date", "=", analysis_date),
|
||||
]
|
||||
).unlink()
|
||||
partners.write(
|
||||
{
|
||||
"customer_segment_id": False,
|
||||
"behavior_business_category_id": False,
|
||||
"last_sale_date": False,
|
||||
"total_sales_amount": 0.0,
|
||||
"sales_frequency": 0,
|
||||
"days_since_last_order": 0,
|
||||
}
|
||||
)
|
||||
domain = [
|
||||
("partner_id", "in", partners.ids),
|
||||
("analysis_date", "=", analysis_date),
|
||||
]
|
||||
if business_category:
|
||||
domain.append(("business_category_id", "=", business_category.id))
|
||||
self.sudo().search(domain).unlink()
|
||||
|
||||
@api.model
|
||||
def compute_customer_behavior(self, config=None, partners=None):
|
||||
config = config or self.env["customer.behavior.config"].sudo().get_active_config()
|
||||
def _compute_customer_behavior_for_config(self, config, partners=None):
|
||||
if not config or not config.business_category_id:
|
||||
return True
|
||||
|
||||
today = fields.Date.context_today(self)
|
||||
partner_filter_ids = partners.commercial_partner_id.ids if partners else []
|
||||
|
||||
@@ -86,6 +84,7 @@ class CustomerBehaviorAnalysis(models.Model):
|
||||
("state", "=", "sale"),
|
||||
("partner_id", "!=", False),
|
||||
("amount_total", ">=", config.min_transaction),
|
||||
("business_category_id", "=", config.business_category_id.id),
|
||||
]
|
||||
if partner_filter_ids:
|
||||
domain.append(("partner_id", "child_of", partner_filter_ids))
|
||||
@@ -98,13 +97,13 @@ class CustomerBehaviorAnalysis(models.Model):
|
||||
|
||||
if partners:
|
||||
commercial_partners = partners.commercial_partner_id
|
||||
self._reset_partner_behavior(commercial_partners, today)
|
||||
partners = commercial_partners.filtered(lambda p: p.id in orders_by_partner)
|
||||
self._reset_partner_behavior(commercial_partners, today, business_category=config.business_category_id)
|
||||
partners_to_process = commercial_partners.filtered(lambda p: p.id in orders_by_partner)
|
||||
else:
|
||||
partners = self.env["res.partner"].sudo().browse(list(orders_by_partner.keys()))
|
||||
partners_to_process = self.env["res.partner"].sudo().browse(list(orders_by_partner.keys()))
|
||||
analysis_model = self.sudo()
|
||||
|
||||
for partner in partners:
|
||||
for partner in partners_to_process:
|
||||
partner_orders = orders_by_partner.get(partner.id, [])
|
||||
if not partner_orders:
|
||||
continue
|
||||
@@ -127,17 +126,21 @@ class CustomerBehaviorAnalysis(models.Model):
|
||||
total_orders=total_orders,
|
||||
config=config,
|
||||
)
|
||||
segment = self._get_segment_by_code(segment_code) if segment_code else False
|
||||
business_category = last_order.business_category_id
|
||||
segment = self._get_segment_by_code(segment_code, config) if segment_code else False
|
||||
business_category = config.business_category_id
|
||||
|
||||
existing = analysis_model.search(
|
||||
[("partner_id", "=", partner.id), ("analysis_date", "=", today)],
|
||||
[
|
||||
("partner_id", "=", partner.id),
|
||||
("analysis_date", "=", today),
|
||||
("business_category_id", "=", business_category.id),
|
||||
],
|
||||
limit=1,
|
||||
)
|
||||
vals = {
|
||||
"partner_id": partner.id,
|
||||
"segment_id": segment.id if segment else False,
|
||||
"business_category_id": business_category.id if business_category else False,
|
||||
"business_category_id": business_category.id,
|
||||
"last_purchase_date": last_purchase_date,
|
||||
"previous_purchase_date": previous_purchase_date,
|
||||
"days_since_last_purchase": days_since_last_purchase,
|
||||
@@ -151,15 +154,18 @@ class CustomerBehaviorAnalysis(models.Model):
|
||||
else:
|
||||
analysis_model.create(vals)
|
||||
|
||||
partner.write(
|
||||
{
|
||||
"customer_segment_id": segment.id if segment else False,
|
||||
"behavior_business_category_id": business_category.id if business_category else False,
|
||||
"last_sale_date": last_purchase_date,
|
||||
"total_sales_amount": total_amount,
|
||||
"sales_frequency": total_orders,
|
||||
"days_since_last_order": days_since_last_purchase,
|
||||
}
|
||||
)
|
||||
|
||||
if not partner.behavior_business_category_id:
|
||||
partner.behavior_business_category_id = business_category.id
|
||||
return True
|
||||
|
||||
@api.model
|
||||
def compute_customer_behavior(self, config=None, partners=None):
|
||||
if config:
|
||||
return self._compute_customer_behavior_for_config(config=config, partners=partners)
|
||||
|
||||
configs = self.env["customer.behavior.config"].sudo().search(
|
||||
[("active", "=", True), ("business_category_id", "!=", False)]
|
||||
)
|
||||
for rec in configs:
|
||||
self._compute_customer_behavior_for_config(config=rec, partners=partners)
|
||||
return True
|
||||
|
||||
@@ -5,9 +5,16 @@ from odoo.exceptions import ValidationError
|
||||
class CustomerBehaviorConfig(models.Model):
|
||||
_name = "customer.behavior.config"
|
||||
_description = "Customer Behavior Configuration"
|
||||
_order = "id desc"
|
||||
_order = "business_category_id, id desc"
|
||||
|
||||
name = fields.Char(default="Default Configuration", required=True)
|
||||
business_category_id = fields.Many2one(
|
||||
"crm.business.category",
|
||||
string="Business Category",
|
||||
required=True,
|
||||
ondelete="restrict",
|
||||
index=True,
|
||||
)
|
||||
repeat_days = fields.Integer(default=30, required=True)
|
||||
at_risk_days = fields.Integer(default=60, required=True)
|
||||
inactive_days = fields.Integer(default=90, required=True)
|
||||
@@ -16,6 +23,14 @@ class CustomerBehaviorConfig(models.Model):
|
||||
min_transaction = fields.Float(default=0.0, required=True)
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
_sql_constraints = [
|
||||
(
|
||||
"customer_behavior_config_name_category_uniq",
|
||||
"unique(name, business_category_id)",
|
||||
"Configuration name must be unique per business category.",
|
||||
)
|
||||
]
|
||||
|
||||
@api.constrains("repeat_days", "at_risk_days", "inactive_days", "dormant_days", "lost_days")
|
||||
def _check_day_thresholds(self):
|
||||
for rec in self:
|
||||
@@ -28,9 +43,35 @@ class CustomerBehaviorConfig(models.Model):
|
||||
)
|
||||
)
|
||||
|
||||
@api.constrains("active", "business_category_id")
|
||||
def _check_active_config_per_business_category(self):
|
||||
for rec in self.filtered(lambda r: r.active and r.business_category_id):
|
||||
duplicates = self.search_count(
|
||||
[
|
||||
("id", "!=", rec.id),
|
||||
("active", "=", True),
|
||||
("business_category_id", "=", rec.business_category_id.id),
|
||||
]
|
||||
)
|
||||
if duplicates:
|
||||
raise ValidationError(
|
||||
_("Only one active customer behavior config is allowed per business category.")
|
||||
)
|
||||
|
||||
@api.model
|
||||
def get_active_config(self):
|
||||
return self.search([("active", "=", True)], order="id desc", limit=1) or self.create({})
|
||||
def name_get(self):
|
||||
result = []
|
||||
for rec in self:
|
||||
category_name = rec.business_category_id.name or "-"
|
||||
result.append((rec.id, "%s - %s" % (category_name, rec.name)))
|
||||
return result
|
||||
|
||||
@api.model
|
||||
def get_active_config(self, business_category=None):
|
||||
domain = [("active", "=", True)]
|
||||
if business_category:
|
||||
domain.append(("business_category_id", "=", business_category.id))
|
||||
return self.search(domain, order="id desc", limit=1)
|
||||
|
||||
def action_run_analysis(self):
|
||||
self.ensure_one()
|
||||
|
||||
@@ -6,66 +6,72 @@ class CustomerBehaviorDashboard(models.TransientModel):
|
||||
_description = "Customer Behavior Dashboard"
|
||||
|
||||
analysis_date = fields.Date(default=fields.Date.context_today, readonly=True)
|
||||
total_customers = fields.Integer(readonly=True)
|
||||
repeat_customers = fields.Integer(readonly=True)
|
||||
reactivated_customers = fields.Integer(readonly=True)
|
||||
at_risk_customers = fields.Integer(readonly=True)
|
||||
inactive_customers = fields.Integer(readonly=True)
|
||||
dormant_customers = fields.Integer(readonly=True)
|
||||
lost_customers = fields.Integer(readonly=True)
|
||||
total_revenue = fields.Monetary(currency_field="currency_id", readonly=True)
|
||||
retention_rate = fields.Float(readonly=True, digits=(16, 2))
|
||||
churn_rate = fields.Float(readonly=True, digits=(16, 2))
|
||||
at_risk_rate = fields.Float(readonly=True, digits=(16, 2))
|
||||
business_category_id = fields.Many2one(
|
||||
"crm.business.category",
|
||||
string="Business Category",
|
||||
required=True,
|
||||
default=lambda self: self.env["customer.behavior.config"].sudo().get_active_config().business_category_id.id,
|
||||
)
|
||||
total_customers = fields.Integer(readonly=True, compute="_compute_metrics")
|
||||
repeat_customers = fields.Integer(readonly=True, compute="_compute_metrics")
|
||||
reactivated_customers = fields.Integer(readonly=True, compute="_compute_metrics")
|
||||
at_risk_customers = fields.Integer(readonly=True, compute="_compute_metrics")
|
||||
inactive_customers = fields.Integer(readonly=True, compute="_compute_metrics")
|
||||
dormant_customers = fields.Integer(readonly=True, compute="_compute_metrics")
|
||||
lost_customers = fields.Integer(readonly=True, compute="_compute_metrics")
|
||||
total_revenue = fields.Monetary(currency_field="currency_id", readonly=True, compute="_compute_metrics")
|
||||
retention_rate = fields.Float(readonly=True, digits=(16, 2), compute="_compute_metrics")
|
||||
churn_rate = fields.Float(readonly=True, digits=(16, 2), compute="_compute_metrics")
|
||||
at_risk_rate = fields.Float(readonly=True, digits=(16, 2), compute="_compute_metrics")
|
||||
currency_id = fields.Many2one(
|
||||
"res.currency",
|
||||
default=lambda self: self.env.company.currency_id.id,
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
vals = super().default_get(fields_list)
|
||||
analysis_date = vals.get("analysis_date") or fields.Date.context_today(self)
|
||||
analyses = self.env["customer.behavior.analysis"].search([("analysis_date", "=", analysis_date)])
|
||||
@api.depends("analysis_date", "business_category_id")
|
||||
def _compute_metrics(self):
|
||||
for rec in self:
|
||||
domain = [("analysis_date", "=", rec.analysis_date)]
|
||||
if rec.business_category_id:
|
||||
domain.append(("business_category_id", "=", rec.business_category_id.id))
|
||||
analyses = self.env["customer.behavior.analysis"].search(domain)
|
||||
|
||||
total_customers = len(analyses)
|
||||
segment_count = {
|
||||
"repeat": 0,
|
||||
"reactivated": 0,
|
||||
"at_risk": 0,
|
||||
"inactive": 0,
|
||||
"dormant": 0,
|
||||
"lost": 0,
|
||||
}
|
||||
for rec in analyses:
|
||||
if rec.segment_id.code in segment_count:
|
||||
segment_count[rec.segment_id.code] += 1
|
||||
|
||||
retained = segment_count["repeat"] + segment_count["reactivated"]
|
||||
lost = segment_count["lost"]
|
||||
at_risk = segment_count["at_risk"]
|
||||
vals.update(
|
||||
{
|
||||
"total_customers": total_customers,
|
||||
"repeat_customers": segment_count["repeat"],
|
||||
"reactivated_customers": segment_count["reactivated"],
|
||||
"at_risk_customers": segment_count["at_risk"],
|
||||
"inactive_customers": segment_count["inactive"],
|
||||
"dormant_customers": segment_count["dormant"],
|
||||
"lost_customers": segment_count["lost"],
|
||||
"total_revenue": sum(analyses.mapped("total_amount")),
|
||||
"retention_rate": (retained / total_customers * 100.0) if total_customers else 0.0,
|
||||
"churn_rate": (lost / total_customers * 100.0) if total_customers else 0.0,
|
||||
"at_risk_rate": (at_risk / total_customers * 100.0) if total_customers else 0.0,
|
||||
segment_count = {
|
||||
"repeat": 0,
|
||||
"reactivated": 0,
|
||||
"at_risk": 0,
|
||||
"inactive": 0,
|
||||
"dormant": 0,
|
||||
"lost": 0,
|
||||
}
|
||||
)
|
||||
return vals
|
||||
for analysis in analyses:
|
||||
if analysis.segment_id.code in segment_count:
|
||||
segment_count[analysis.segment_id.code] += 1
|
||||
|
||||
total_customers = len(analyses.mapped("partner_id"))
|
||||
retained = segment_count["repeat"] + segment_count["reactivated"]
|
||||
lost = segment_count["lost"]
|
||||
at_risk = segment_count["at_risk"] + segment_count["inactive"] + segment_count["dormant"]
|
||||
|
||||
rec.total_customers = total_customers
|
||||
rec.repeat_customers = segment_count["repeat"]
|
||||
rec.reactivated_customers = segment_count["reactivated"]
|
||||
rec.at_risk_customers = segment_count["at_risk"]
|
||||
rec.inactive_customers = segment_count["inactive"]
|
||||
rec.dormant_customers = segment_count["dormant"]
|
||||
rec.lost_customers = segment_count["lost"]
|
||||
rec.total_revenue = sum(analyses.mapped("total_amount"))
|
||||
rec.retention_rate = (retained / total_customers * 100.0) if total_customers else 0.0
|
||||
rec.churn_rate = (lost / total_customers * 100.0) if total_customers else 0.0
|
||||
rec.at_risk_rate = (at_risk / total_customers * 100.0) if total_customers else 0.0
|
||||
|
||||
def _open_analysis(self, segment_codes=None):
|
||||
self.ensure_one()
|
||||
action = self.env.ref("grt_sales_business_category.action_customer_behavior_analysis").read()[0]
|
||||
domain = [("analysis_date", "=", self.analysis_date)]
|
||||
if self.business_category_id:
|
||||
domain.append(("business_category_id", "=", self.business_category_id.id))
|
||||
if segment_codes:
|
||||
domain.append(("segment_id.code", "in", segment_codes))
|
||||
action["domain"] = domain
|
||||
@@ -82,4 +88,3 @@ class CustomerBehaviorDashboard(models.TransientModel):
|
||||
|
||||
def action_open_at_risk(self):
|
||||
return self._open_analysis(["at_risk", "inactive", "dormant"])
|
||||
|
||||
|
||||
@@ -13,11 +13,18 @@ class CustomerBehaviorRecomputeWizard(models.TransientModel):
|
||||
default="selected",
|
||||
required=True,
|
||||
)
|
||||
business_category_id = fields.Many2one(
|
||||
"crm.business.category",
|
||||
string="Business Category",
|
||||
related="config_id.business_category_id",
|
||||
readonly=True,
|
||||
)
|
||||
partner_ids = fields.Many2many("res.partner", string="Customers")
|
||||
config_id = fields.Many2one(
|
||||
"customer.behavior.config",
|
||||
string="Configuration",
|
||||
default=lambda self: self.env["customer.behavior.config"].sudo().get_active_config().id,
|
||||
domain=[("active", "=", True)],
|
||||
required=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from odoo import fields, models
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class CustomerBehaviorSegment(models.Model):
|
||||
@@ -7,6 +7,20 @@ class CustomerBehaviorSegment(models.Model):
|
||||
_order = "sequence, id"
|
||||
|
||||
name = fields.Char(required=True)
|
||||
config_id = fields.Many2one(
|
||||
"customer.behavior.config",
|
||||
string="Behavior Config",
|
||||
required=True,
|
||||
ondelete="cascade",
|
||||
index=True,
|
||||
)
|
||||
business_category_id = fields.Many2one(
|
||||
"crm.business.category",
|
||||
related="config_id.business_category_id",
|
||||
store=True,
|
||||
readonly=True,
|
||||
index=True,
|
||||
)
|
||||
code = fields.Selection(
|
||||
[
|
||||
("repeat", "Repeat"),
|
||||
@@ -23,5 +37,26 @@ class CustomerBehaviorSegment(models.Model):
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
_sql_constraints = [
|
||||
("customer_behavior_segment_code_uniq", "unique(code)", "Segment code must be unique."),
|
||||
(
|
||||
"customer_behavior_segment_code_config_uniq",
|
||||
"unique(code, config_id)",
|
||||
"Segment code must be unique per customer behavior config.",
|
||||
),
|
||||
]
|
||||
|
||||
def name_get(self):
|
||||
result = []
|
||||
for rec in self:
|
||||
category_name = rec.business_category_id.name or "-"
|
||||
result.append((rec.id, "%s - %s" % (category_name, rec.name)))
|
||||
return result
|
||||
|
||||
@api.model
|
||||
def name_search(self, name="", args=None, operator="ilike", limit=100):
|
||||
args = list(args or [])
|
||||
if name:
|
||||
domain = ["|", ("name", operator, name), ("business_category_id.name", operator, name)]
|
||||
records = self.search(domain + args, limit=limit)
|
||||
if records:
|
||||
return records.name_get()
|
||||
return super().name_search(name=name, args=args, operator=operator, limit=limit)
|
||||
|
||||
@@ -9,6 +9,7 @@ class ResPartner(models.Model):
|
||||
string="Customer Segment",
|
||||
ondelete="restrict",
|
||||
index=True,
|
||||
compute="_compute_behavior_summary",
|
||||
)
|
||||
behavior_business_category_id = fields.Many2one(
|
||||
"crm.business.category",
|
||||
@@ -16,23 +17,71 @@ class ResPartner(models.Model):
|
||||
ondelete="restrict",
|
||||
index=True,
|
||||
)
|
||||
last_sale_date = fields.Date(string="Last Sale Date")
|
||||
last_sale_date = fields.Date(string="Last Sale Date", compute="_compute_behavior_summary")
|
||||
behavior_currency_id = fields.Many2one(
|
||||
"res.currency",
|
||||
string="Behavior Currency",
|
||||
default=lambda self: self.env.company.currency_id.id,
|
||||
readonly=True,
|
||||
)
|
||||
total_sales_amount = fields.Monetary(string="Total Sales Amount", currency_field="behavior_currency_id")
|
||||
sales_frequency = fields.Integer(string="Sales Frequency")
|
||||
days_since_last_order = fields.Integer(string="Days Since Last Order")
|
||||
total_sales_amount = fields.Monetary(
|
||||
string="Total Sales Amount",
|
||||
currency_field="behavior_currency_id",
|
||||
compute="_compute_behavior_summary",
|
||||
)
|
||||
sales_frequency = fields.Integer(string="Sales Frequency", compute="_compute_behavior_summary")
|
||||
days_since_last_order = fields.Integer(string="Days Since Last Order", compute="_compute_behavior_summary")
|
||||
customer_behavior_analysis_ids = fields.One2many(
|
||||
"customer.behavior.analysis",
|
||||
"partner_id",
|
||||
string="Customer Behavior Analysis",
|
||||
)
|
||||
|
||||
def _get_behavior_reference_analysis(self):
|
||||
self.ensure_one()
|
||||
analyses = self.customer_behavior_analysis_ids
|
||||
if self.behavior_business_category_id:
|
||||
analyses = analyses.filtered(
|
||||
lambda rec: rec.business_category_id == self.behavior_business_category_id
|
||||
)
|
||||
return analyses.sorted(
|
||||
key=lambda rec: (
|
||||
rec.analysis_date or fields.Date.from_string("1900-01-01"),
|
||||
rec.id,
|
||||
),
|
||||
reverse=True,
|
||||
)[:1]
|
||||
|
||||
def _compute_behavior_summary(self):
|
||||
for partner in self:
|
||||
analysis = partner._get_behavior_reference_analysis()
|
||||
partner.customer_segment_id = analysis.segment_id if analysis else False
|
||||
partner.last_sale_date = analysis.last_purchase_date if analysis else False
|
||||
partner.total_sales_amount = analysis.total_amount if analysis else 0.0
|
||||
partner.sales_frequency = analysis.total_orders if analysis else 0
|
||||
partner.days_since_last_order = analysis.days_since_last_purchase if analysis else 0
|
||||
|
||||
def _ensure_behavior_business_category(self):
|
||||
for partner in self.filtered(lambda p: not p.behavior_business_category_id and p.customer_behavior_analysis_ids):
|
||||
latest = partner.customer_behavior_analysis_ids.sorted(
|
||||
key=lambda rec: (
|
||||
rec.analysis_date or fields.Date.from_string("1900-01-01"),
|
||||
rec.id,
|
||||
),
|
||||
reverse=True,
|
||||
)[:1]
|
||||
if latest:
|
||||
partner.behavior_business_category_id = latest.business_category_id
|
||||
|
||||
def write(self, vals):
|
||||
result = super().write(vals)
|
||||
if "customer_behavior_analysis_ids" in vals or "behavior_business_category_id" not in vals:
|
||||
self._ensure_behavior_business_category()
|
||||
return result
|
||||
|
||||
def action_recompute_customer_behavior(self):
|
||||
self._ensure_behavior_business_category()
|
||||
config = self.env["customer.behavior.config"].sudo().get_active_config(self.behavior_business_category_id)
|
||||
return {
|
||||
"type": "ir.actions.act_window",
|
||||
"name": "Recompute Customer Behavior",
|
||||
@@ -42,5 +91,6 @@ class ResPartner(models.Model):
|
||||
"context": {
|
||||
"default_mode": "selected",
|
||||
"default_partner_ids": [(6, 0, self.commercial_partner_id.ids)],
|
||||
"default_config_id": config.id,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<field name="arch" type="xml">
|
||||
<tree>
|
||||
<field name="name"/>
|
||||
<field name="business_category_id"/>
|
||||
<field name="repeat_days"/>
|
||||
<field name="at_risk_days"/>
|
||||
<field name="inactive_days"/>
|
||||
@@ -29,6 +30,7 @@
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="name"/>
|
||||
<field name="business_category_id"/>
|
||||
<field name="active"/>
|
||||
</group>
|
||||
<group string="Threshold Days">
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="analysis_date"/>
|
||||
<field name="business_category_id"/>
|
||||
<field name="total_revenue"/>
|
||||
<field name="total_customers"/>
|
||||
</group>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<group>
|
||||
<field name="mode"/>
|
||||
<field name="config_id"/>
|
||||
<field name="business_category_id"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="partner_ids" attrs="{'required': [('mode', '=', 'selected')], 'invisible': [('mode', '=', 'all')]}"/>
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
<tree>
|
||||
<field name="sequence"/>
|
||||
<field name="name"/>
|
||||
<field name="config_id"/>
|
||||
<field name="business_category_id"/>
|
||||
<field name="code"/>
|
||||
<field name="active"/>
|
||||
</tree>
|
||||
@@ -21,6 +23,8 @@
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="name"/>
|
||||
<field name="config_id"/>
|
||||
<field name="business_category_id" readonly="1"/>
|
||||
<field name="code"/>
|
||||
<field name="sequence"/>
|
||||
<field name="active"/>
|
||||
|
||||
+3563
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
@@ -36,3 +36,28 @@ class KpiAssignment(models.Model):
|
||||
target = rec.kpi_definition_id.target_ids[:1]
|
||||
rec.effective_target = rec.target_override if rec.target_override else (target.target_value or 0.0)
|
||||
rec.effective_weight = rec.weight_override if rec.weight_override else (target.weight or 0.0)
|
||||
|
||||
def name_get(self):
|
||||
result = []
|
||||
for rec in self:
|
||||
employee_name = rec.employee_id.name or "-"
|
||||
kpi_name = rec.kpi_definition_id.name or "-"
|
||||
period_name = rec.period_id.name or "-"
|
||||
result.append((rec.id, "%s - %s - %s" % (employee_name, kpi_name, period_name)))
|
||||
return result
|
||||
|
||||
@api.model
|
||||
def name_search(self, name="", args=None, operator="ilike", limit=100):
|
||||
args = list(args or [])
|
||||
if name:
|
||||
domain = [
|
||||
"|",
|
||||
"|",
|
||||
("employee_id.name", operator, name),
|
||||
("kpi_definition_id.name", operator, name),
|
||||
("period_id.name", operator, name),
|
||||
]
|
||||
records = self.search(domain + args, limit=limit)
|
||||
if records:
|
||||
return records.name_get()
|
||||
return super().name_search(name=name, args=args, operator=operator, limit=limit)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -142,7 +142,13 @@ class KpiCrmTriggerRuleLine(models.Model):
|
||||
active = fields.Boolean(default=True)
|
||||
stage_id = fields.Many2one("crm.stage", string="Stage")
|
||||
activity_type_id = fields.Many2one("mail.activity.type", string="Activity Type")
|
||||
employee_id = fields.Many2one("hr.employee", required=True, ondelete="cascade", index=True)
|
||||
employee_id = fields.Many2one(
|
||||
"hr.employee",
|
||||
related="assignment_id.employee_id",
|
||||
store=True,
|
||||
readonly=True,
|
||||
index=True,
|
||||
)
|
||||
assignment_id = fields.Many2one("kpi.assignment", required=True, ondelete="cascade", index=True)
|
||||
value = fields.Float(required=True, default=1.0)
|
||||
source_module = fields.Char(default="crm", required=True)
|
||||
@@ -155,12 +161,6 @@ class KpiCrmTriggerRuleLine(models.Model):
|
||||
continue
|
||||
raise ValidationError(_("Either Stage or Activity Type must be set."))
|
||||
|
||||
@api.constrains("employee_id", "assignment_id")
|
||||
def _check_assignment_employee(self):
|
||||
for rec in self:
|
||||
if rec.assignment_id.employee_id != rec.employee_id:
|
||||
raise ValidationError(_("Employee must match KPI Assignment employee."))
|
||||
|
||||
@api.constrains("rule_id", "stage_id")
|
||||
def _check_stage_business_category(self):
|
||||
for rec in self:
|
||||
|
||||
@@ -33,8 +33,8 @@
|
||||
<field name="active"/>
|
||||
<field name="stage_id"/>
|
||||
<field name="activity_type_id"/>
|
||||
<field name="employee_id"/>
|
||||
<field name="assignment_id"/>
|
||||
<field name="employee_id" readonly="1"/>
|
||||
<field name="value"/>
|
||||
<field name="source_module"/>
|
||||
<field name="note"/>
|
||||
@@ -45,8 +45,8 @@
|
||||
<field name="active"/>
|
||||
<field name="stage_id"/>
|
||||
<field name="activity_type_id"/>
|
||||
<field name="employee_id"/>
|
||||
<field name="assignment_id" domain="[('employee_id', '=', employee_id)]"/>
|
||||
<field name="assignment_id"/>
|
||||
<field name="employee_id" readonly="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="value"/>
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -1,5 +1,4 @@
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class KpiCustomerBehaviorTriggerRule(models.Model):
|
||||
@@ -119,15 +118,28 @@ class KpiCustomerBehaviorTriggerRuleLine(models.Model):
|
||||
rule_id = fields.Many2one("kpi.customer.behavior.trigger.rule", required=True, ondelete="cascade", index=True)
|
||||
sequence = fields.Integer(default=10)
|
||||
active = fields.Boolean(default=True)
|
||||
business_category_id = fields.Many2one(
|
||||
"crm.business.category",
|
||||
related="rule_id.business_category_id",
|
||||
store=True,
|
||||
readonly=True,
|
||||
index=True,
|
||||
)
|
||||
segment_id = fields.Many2one("customer.behavior.segment", required=True, ondelete="restrict", index=True)
|
||||
employee_id = fields.Many2one("hr.employee", required=True, ondelete="cascade", index=True)
|
||||
employee_id = fields.Many2one(
|
||||
"hr.employee",
|
||||
related="assignment_id.employee_id",
|
||||
store=True,
|
||||
readonly=True,
|
||||
index=True,
|
||||
)
|
||||
assignment_id = fields.Many2one("kpi.assignment", required=True, ondelete="cascade", index=True)
|
||||
score_value = fields.Float(required=True, default=0.0)
|
||||
source_module = fields.Char(default="sale.customer_behavior", required=True)
|
||||
note = fields.Char()
|
||||
|
||||
@api.constrains("employee_id", "assignment_id")
|
||||
def _check_assignment_employee(self):
|
||||
@api.constrains("business_category_id", "segment_id")
|
||||
def _check_segment_business_category(self):
|
||||
for rec in self:
|
||||
if rec.assignment_id.employee_id != rec.employee_id:
|
||||
raise ValidationError(_("Employee must match KPI Assignment employee."))
|
||||
if rec.segment_id and rec.segment_id.business_category_id != rec.business_category_id:
|
||||
raise ValidationError(_("Segment business category must match rule business category."))
|
||||
|
||||
+6
-5
@@ -31,9 +31,9 @@
|
||||
<tree editable="bottom">
|
||||
<field name="sequence"/>
|
||||
<field name="active"/>
|
||||
<field name="segment_id"/>
|
||||
<field name="employee_id"/>
|
||||
<field name="segment_id" domain="[('business_category_id', '=', parent.business_category_id)]"/>
|
||||
<field name="assignment_id"/>
|
||||
<field name="employee_id" readonly="1"/>
|
||||
<field name="score_value"/>
|
||||
<field name="source_module"/>
|
||||
<field name="note"/>
|
||||
@@ -42,9 +42,10 @@
|
||||
<group>
|
||||
<field name="sequence"/>
|
||||
<field name="active"/>
|
||||
<field name="segment_id"/>
|
||||
<field name="employee_id"/>
|
||||
<field name="assignment_id" domain="[('employee_id', '=', employee_id)]"/>
|
||||
<field name="segment_id" domain="[('business_category_id', '=', business_category_id)]"/>
|
||||
<field name="business_category_id" readonly="1" invisible="1"/>
|
||||
<field name="assignment_id"/>
|
||||
<field name="employee_id" readonly="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="score_value"/>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -106,7 +106,13 @@ class KpiSalesTriggerRuleLine(models.Model):
|
||||
rule_id = fields.Many2one("kpi.sales.trigger.rule", required=True, ondelete="cascade", index=True)
|
||||
sequence = fields.Integer(default=10)
|
||||
active = fields.Boolean(default=True)
|
||||
employee_id = fields.Many2one("hr.employee", required=True, ondelete="cascade", index=True)
|
||||
employee_id = fields.Many2one(
|
||||
"hr.employee",
|
||||
related="assignment_id.employee_id",
|
||||
store=True,
|
||||
readonly=True,
|
||||
index=True,
|
||||
)
|
||||
assignment_id = fields.Many2one("kpi.assignment", required=True, ondelete="cascade", index=True)
|
||||
on_time_score = fields.Float(required=True, default=1.0)
|
||||
late_penalty_per_day = fields.Float(default=0.0)
|
||||
@@ -116,12 +122,6 @@ class KpiSalesTriggerRuleLine(models.Model):
|
||||
source_module = fields.Char(default="sale", required=True)
|
||||
note = fields.Char()
|
||||
|
||||
@api.constrains("employee_id", "assignment_id")
|
||||
def _check_assignment_employee(self):
|
||||
for rec in self:
|
||||
if rec.assignment_id.employee_id != rec.employee_id:
|
||||
raise ValidationError(_("Employee must match KPI Assignment employee."))
|
||||
|
||||
@api.constrains("on_time_score", "late_penalty_per_day", "minimum_score", "transaction_amount_threshold", "transaction_bonus_score")
|
||||
def _check_score_values(self):
|
||||
for rec in self:
|
||||
|
||||
@@ -31,8 +31,8 @@
|
||||
<tree editable="bottom">
|
||||
<field name="sequence"/>
|
||||
<field name="active"/>
|
||||
<field name="employee_id"/>
|
||||
<field name="assignment_id"/>
|
||||
<field name="employee_id" readonly="1"/>
|
||||
<field name="on_time_score"/>
|
||||
<field name="late_penalty_per_day"/>
|
||||
<field name="minimum_score"/>
|
||||
@@ -45,8 +45,8 @@
|
||||
<group>
|
||||
<field name="sequence"/>
|
||||
<field name="active"/>
|
||||
<field name="employee_id"/>
|
||||
<field name="assignment_id" domain="[('employee_id', '=', employee_id)]"/>
|
||||
<field name="assignment_id"/>
|
||||
<field name="employee_id" readonly="1"/>
|
||||
<field name="source_module"/>
|
||||
</group>
|
||||
<group string="Scoring">
|
||||
|
||||
Reference in New Issue
Block a user