Update CRM, KPI, dan Product Label

This commit is contained in:
2026-03-10 05:09:59 +07:00
parent 68c4a16777
commit 39b770f56d
51 changed files with 4203 additions and 177 deletions
+171
View File
@@ -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&amp;value=%s&amp;width=%s&amp;height=%s&amp;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&amp;value=%s&amp;width=%s&amp;height=%s&amp;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&amp;value=%s&amp;width=%s&amp;height=%s&amp;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 &amp;&amp; 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&amp;value=%s&amp;width=%s&amp;height=%s&amp;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 &amp;&amp; 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&amp;value=%s&amp;width=%s&amp;height=%s&amp;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&amp;value=%s&amp;width=%s&amp;height=%s&amp;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&amp;value=%s&amp;width=%s&amp;height=%s&amp;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&amp;value=%s&amp;width=%s&amp;height=%s&amp;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 &amp;&amp; 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&amp;value=%s&amp;width=%s&amp;height=%s&amp;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&amp;value=%s&amp;width=%s&amp;height=%s&amp;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&amp;value=%s&amp;width=%s&amp;height=%s&amp;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&amp;value=%s&amp;width=%s&amp;height=%s&amp;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 &amp;&amp; this.src !== this.dataset.altSrc) { this.src = this.dataset.altSrc; }"
style="overflow: hidden; width: 95%; height: 1.8rem;"/>
</div>
@@ -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:
@@ -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",
@@ -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
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -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)
@@ -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"/>
@@ -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."))
@@ -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"/>
@@ -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">