Membuat KPI Triggers untuk Modules sales

This commit is contained in:
2026-03-07 06:04:54 +07:00
parent 2be61b84b8
commit f598da5805
44 changed files with 2578 additions and 23 deletions
+3 -22
View File
@@ -1,5 +1,5 @@
SCADA Failure Reporting
======================
========================
Custom module untuk pencatatan laporan failure equipment SCADA.
@@ -31,32 +31,13 @@ HTTP Routes
POST /api/scada/failure-report
Auth: user session (auth='user')
Auth: user session
Dokumentasi frontend yang lebih detail tersedia di:
- grt_scada_failure_report/FRONTEND_API_DOCUMENTATION.md
Body example::
{
"equipment_code": "PLC01",
"description": "Motor overload saat proses mixing",
"date": "2026-02-15 08:30:00"
}
Response success example::
{
"status": "success",
"message": "Failure report created",
"data": {
"id": 1,
"equipment_code": "PLC01",
"description": "Motor overload saat proses mixing",
"date": "2026-02-15 08:30:00"
}
}
Contoh request dan response tersedia di file dokumentasi frontend.
2. Form Input
~~~~~~~~~~~~~
+1 -1
View File
@@ -1,6 +1,6 @@
{
'name': 'SCADA Failure Reporting',
'version': '14.0.1.0.1',
'version': '14.0.1.0.2',
'category': 'manufacturing',
'license': 'LGPL-3',
'author': 'Custom',
+1149
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,412 @@
# Odoo KPI Customer Behavior Trigger
## Overview
Modul `odoo_kpi_customer_behavior_trigger` digunakan untuk membuat `kpi.value` otomatis berdasarkan hasil segmentasi perilaku pelanggan dari modul customer behavior.
Tujuan utamanya adalah menghubungkan kualitas portofolio pelanggan milik salesperson dengan sistem KPI. Dengan pendekatan ini, salesperson tidak hanya dinilai dari transaksi yang terjadi, tetapi juga dari kondisi pelanggan yang mereka tangani.
Contoh kebijakan yang bisa diterapkan:
- pelanggan `repeat` memberi score tambahan
- pelanggan `reactivated` memberi score positif
- pelanggan `at_risk` mulai memberi pengurangan
- pelanggan `inactive` memberi pengurangan lebih besar
- pelanggan `dormant` memberi pengurangan lebih berat
- pelanggan `lost` memberi penalti paling besar
Modul ini terintegrasi dengan:
- `odoo_kpi`
- `grt_sales_business_category`
- `sale_management`
- `hr`
---
# Objective
Tujuan modul ini:
- memberi KPI berdasarkan kualitas relasi pelanggan
- memberi insentif untuk mempertahankan pelanggan aktif dan repeat order
- memberi penalti saat pelanggan mulai memburuk sampai hilang
- menghubungkan hasil customer behavior ke `kpi.assignment`
- menghasilkan `kpi.value` secara otomatis dari data analisis customer behavior
---
# Business Logic
Setiap kali `customer.behavior.analysis` dibuat atau diperbarui, sistem akan mengevaluasi apakah hasil analisis tersebut perlu dikonversi menjadi nilai KPI.
Sistem akan:
1. membaca hasil analisis perilaku pelanggan
2. mengambil segment customer
3. mengambil business category dari analisis
4. mencari salesperson yang bertanggung jawab atas customer tersebut
5. mencari `KPI Customer Behavior Trigger Rule` yang cocok
6. mencocokkan segment dengan rule line
7. membuat atau meng-update `kpi.value`
---
# Trigger Event
Trigger KPI dijalankan saat record `customer.behavior.analysis`:
- dibuat (`create`)
- diperbarui (`write`) pada field penting
Field yang memicu proses ulang:
- `segment_id`
- `business_category_id`
- `partner_id`
- `analysis_date`
Sumber event biasanya berasal dari:
- cron `Daily Customer Behavior Analysis`
- wizard recompute customer behavior
- pemanggilan manual `compute_customer_behavior()`
---
# Data Flow
Flow modul:
```text
Customer Behavior Analysis Created / Updated
|
v
Read Customer Segment + Business Category
|
v
Find Responsible Salesperson
|
v
Match KPI Behavior Trigger Rule
|
v
Create / Update KPI Value
```
---
# Rule Structure
Model utama:
- `kpi.customer.behavior.trigger.rule`
- `kpi.customer.behavior.trigger.rule.line`
## kpi.customer.behavior.trigger.rule
Header rule digunakan untuk:
- nama rule
- sequence
- status active
- `business_category_id`
Rule dibuat per `Business Category` agar score perilaku pelanggan dapat dipisahkan antar lini bisnis.
## kpi.customer.behavior.trigger.rule.line
Setiap line menentukan score untuk kombinasi:
- segment pelanggan
- employee
- KPI assignment
Field penting:
- `segment_id`
- `employee_id`
- `assignment_id`
- `score_value`
- `source_module`
- `note`
Nilai `score_value` boleh:
- positif untuk reward
- negatif untuk penalti
- nol jika hanya ingin menandai segment tertentu tanpa dampak score
Constraint:
- `employee_id` pada line harus sama dengan `employee_id` pada `assignment_id`
---
# Segment Scoring
Modul ini tidak mengunci formula matematis seperti KPI payment trigger. Score ditentukan langsung per segment.
Contoh konfigurasi:
- `repeat` = `+5`
- `reactivated` = `+3`
- `at_risk` = `-2`
- `inactive` = `-4`
- `dormant` = `-6`
- `lost` = `-10`
Dengan model ini, perusahaan bisa bebas menentukan kebijakan scoring sendiri sesuai strategi customer retention.
---
# Employee Resolution Logic
Salesperson yang akan menerima KPI dicari dengan urutan berikut:
1. `partner.user_id`
2. salesman dari Sales Order terakhir customer pada `business_category_id` yang sama
Penjelasan:
- jika partner sudah memiliki salesperson tetap pada field `user_id`, maka salesperson tersebut dipakai
- jika tidak ada, sistem mencari Sales Order terakhir customer dalam kategori bisnis yang sama
- jika tetap tidak ditemukan employee yang valid, KPI tidak dibuat
Relasi ke employee dilakukan melalui mapping `res.users -> hr.employee`.
---
# KPI Output
Output modul ini adalah record pada model `kpi.value`.
Field yang ditulis:
- `assignment_id`
- `value`
- `source_module`
- `reference_model`
- `reference_id`
Mekanisme yang dipakai adalah upsert:
- jika kombinasi assignment + source + reference sudah ada, nilai di-update
- jika belum ada, record baru dibuat
Ini penting untuk mencegah duplikasi saat analisis customer dijalankan berulang pada hari yang sama.
---
# Reference Model
Untuk membedakan sumber data KPI, modul ini menggunakan:
```text
reference_model = customer.behavior.analysis.rule.line.<line_id>
reference_id = <analysis_id>
```
Dengan pola ini:
- satu hasil analisis customer hanya menghasilkan satu KPI value per line rule
- update analisis yang sama tidak membuat duplikasi
---
# Example Configuration
Contoh kebijakan untuk salesperson A:
- `repeat` = `+5`
- `reactivated` = `+3`
- `at_risk` = `-2`
- `inactive` = `-4`
- `dormant` = `-6`
- `lost` = `-10`
## Scenario 1
Customer masuk segment `repeat`
Hasil:
- KPI value `+5`
## Scenario 2
Customer masuk segment `reactivated`
Hasil:
- KPI value `+3`
## Scenario 3
Customer masuk segment `at_risk`
Hasil:
- KPI value `-2`
## Scenario 4
Customer masuk segment `inactive`
Hasil:
- KPI value `-4`
## Scenario 5
Customer masuk segment `dormant`
Hasil:
- KPI value `-6`
## Scenario 6
Customer masuk segment `lost`
Hasil:
- KPI value `-10`
---
# Setup in Odoo
Langkah konfigurasi:
1. pastikan modul `odoo_kpi`, `grt_sales_business_category`, dan `odoo_kpi_customer_behavior_trigger` sudah terinstall
2. pastikan data segment customer behavior sudah tersedia
3. buat `KPI Definition` untuk KPI customer behavior
4. buat `KPI Period`
5. buat `KPI Assignment` untuk masing-masing salesperson
6. buka menu `Sales > Customer Behavior > KPI Customer Behavior Triggers`
7. buat rule per `Business Category`
8. tambahkan line untuk setiap segment yang ingin diberi score
9. isi `score_value` sesuai kebijakan perusahaan
---
# Recommended Master Data Setup
Agar modul berjalan dengan benar, sebaiknya:
- setiap salesperson memiliki relasi `res.users` ke `hr.employee`
- partner customer memiliki `user_id` jika ada owner tetap
- sales order customer tersimpan dengan benar pada business category yang relevan
- KPI assignment tersedia untuk periode aktif
- cron customer behavior berjalan normal
---
# Integration with Customer Behavior Module
Modul ini bergantung pada hasil dari `grt_sales_business_category`, khususnya model:
- `customer.behavior.analysis`
- `customer.behavior.segment`
- `res.partner.customer_segment_id`
Artinya, kualitas output KPI sepenuhnya bergantung pada kualitas analisis customer behavior yang sudah ada.
Jika segment customer belum terhitung, maka KPI behavior juga belum bisa dibuat.
---
# Assumptions
Asumsi implementasi saat ini:
- satu hasil analisis customer menghasilkan satu KPI value per line rule yang cocok
- score diberikan ke satu salesperson yang dianggap paling relevan
- business category pada analisis menjadi filter utama rule
- score per segment bersifat statis, tidak dihitung dari formula tambahan
---
# Limitation
Batasan implementasi saat ini:
- belum mendukung pembagian score ke lebih dari satu salesperson
- belum mendukung weighting tambahan berdasarkan total amount customer
- belum mendukung scoring berdasarkan perubahan segment dari bulan sebelumnya
- belum ada simulasi dampak segment ke KPI
- belum ada recompute KPI historis terpisah dari recompute customer behavior
Pengembangan lanjutan yang mungkin:
- score berbeda berdasarkan total revenue customer
- score berbeda berdasarkan jumlah repeat order
- penalty tambahan jika customer turun beberapa level sekaligus
- rule berbasis team sales
- snapshot trend segment per period KPI
---
# Technical Notes
Hook utama ada di:
- `models/customer_behavior_analysis.py`
- `models/kpi_customer_behavior_trigger_rule.py`
Logika utama:
- intercept create dan write pada `customer.behavior.analysis`
- cari employee yang relevan
- cari rule yang cocok berdasarkan business category dan segment
- kirim hasil ke `kpi.value`
---
# Security
Akses konfigurasi rule diberikan ke:
- `odoo_kpi.group_kpi_manager`
- `odoo_kpi.group_kpi_admin`
- `sales_team.group_sale_manager`
---
# Upgrade Module
Contoh command upgrade:
```bat
c:\odoo14c\python\python.exe C:\odoo14c\server\odoo-bin -c C:\addon14\odoo.conf -d kanjabung_MRP -u odoo_kpi_customer_behavior_trigger --stop-after-init
```
---
# Testing Checklist
Checklist test manual:
1. buat KPI assignment untuk salesperson
2. buat rule customer behavior pada business category yang sesuai
3. atur score per segment
4. jalankan analisis customer behavior
5. pastikan customer yang `repeat` membuat `kpi.value` positif
6. pastikan customer yang `at_risk` atau lebih buruk membuat `kpi.value` negatif
7. pastikan analisis ulang tidak membuat duplikasi KPI value
8. pastikan employee yang menerima score sesuai owner customer atau sales order terakhir
---
# Summary
`odoo_kpi_customer_behavior_trigger` menambahkan KPI trigger berbasis kualitas perilaku pelanggan.
Dengan modul ini, sistem KPI sales dapat menangkap sinyal penting seperti:
- pelanggan yang loyal dan repeat
- pelanggan yang menurun kualitasnya
- pelanggan yang sudah dorman atau lost
Ini membuat KPI sales lebih dekat ke objective retensi pelanggan, bukan hanya transaksi sesaat.
@@ -0,0 +1 @@
from . import models
@@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
{
"name": "Odoo KPI Customer Behavior Trigger",
"summary": "Customer behavior event rules to send KPI values by assignment",
"version": "14.0.1.0.0",
"category": "Sales/Sales",
"author": "Custom",
"depends": ["sale_management", "hr", "odoo_kpi", "grt_sales_business_category"],
"data": [
"security/ir.model.access.csv",
"views/kpi_customer_behavior_trigger_rule_views.xml",
],
"installable": True,
"application": False,
"license": "LGPL-3",
}
@@ -0,0 +1,2 @@
from . import kpi_customer_behavior_trigger_rule
from . import customer_behavior_analysis
@@ -0,0 +1,18 @@
from odoo import api, models
class CustomerBehaviorAnalysis(models.Model):
_inherit = "customer.behavior.analysis"
@api.model_create_multi
def create(self, vals_list):
records = super().create(vals_list)
self.env["kpi.customer.behavior.trigger.rule"].sudo().process_behavior_analyses(records)
return records
def write(self, vals):
result = super().write(vals)
trigger_fields = {"segment_id", "business_category_id", "partner_id", "analysis_date"}
if trigger_fields.intersection(vals):
self.env["kpi.customer.behavior.trigger.rule"].sudo().process_behavior_analyses(self)
return result
@@ -0,0 +1,133 @@
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
class KpiCustomerBehaviorTriggerRule(models.Model):
_name = "kpi.customer.behavior.trigger.rule"
_description = "KPI Customer Behavior Trigger Rule"
_order = "sequence, id"
name = fields.Char(required=True)
sequence = fields.Integer(default=10)
active = fields.Boolean(default=True)
business_category_id = fields.Many2one("crm.business.category", required=True, ondelete="cascade", index=True)
line_ids = fields.One2many(
"kpi.customer.behavior.trigger.rule.line",
"rule_id",
string="Trigger Lines",
)
def _find_employee_from_user(self, user):
if not user:
return self.env["hr.employee"]
return self.env["hr.employee"].search([("user_id", "=", user.id)], limit=1)
@api.model
def _assignment_matches_date(self, assignment, event_date):
period = assignment.period_id
return bool(
period
and period.date_start
and period.date_end
and period.date_start <= event_date <= period.date_end
)
@api.model
def _upsert_value(self, line, value, ref_model, ref_id):
value_model = self.env["kpi.value"].sudo()
existing = value_model.search(
[
("assignment_id", "=", line.assignment_id.id),
("reference_model", "=", ref_model),
("reference_id", "=", ref_id),
("source_module", "=", line.source_module),
],
limit=1,
)
vals = {
"assignment_id": line.assignment_id.id,
"value": value,
"source_module": line.source_module,
"reference_model": ref_model,
"reference_id": ref_id,
}
if existing:
existing.write({"value": value})
return existing
return value_model.create(vals)
@api.model
def _find_sales_employee_for_analysis(self, analysis):
partner = analysis.partner_id.commercial_partner_id
if partner.user_id:
employee = self._find_employee_from_user(partner.user_id)
if employee:
return employee
domain = [
("partner_id", "child_of", partner.id),
("state", "=", "sale"),
("user_id", "!=", False),
]
if analysis.business_category_id:
domain.append(("business_category_id", "=", analysis.business_category_id.id))
last_order = self.env["sale.order"].sudo().search(domain, order="date_order desc, id desc", limit=1)
return self._find_employee_from_user(last_order.user_id) if last_order else self.env["hr.employee"]
@api.model
def process_behavior_analyses(self, analyses):
if not analyses:
return False
for analysis in analyses:
if not analysis.segment_id or not analysis.business_category_id or not analysis.partner_id:
continue
event_date = analysis.analysis_date or fields.Date.context_today(self)
employee = self._find_sales_employee_for_analysis(analysis)
if not employee:
continue
rules = self.search(
[
("active", "=", True),
("business_category_id", "=", analysis.business_category_id.id),
]
)
for rule in rules:
lines = rule.line_ids.filtered(
lambda l: l.active
and l.employee_id.id == employee.id
and l.segment_id.id == analysis.segment_id.id
and self._assignment_matches_date(l.assignment_id, event_date)
)
for line in lines:
self._upsert_value(
line=line,
value=line.score_value,
ref_model="customer.behavior.analysis.rule.line.%s" % line.id,
ref_id=analysis.id,
)
return True
class KpiCustomerBehaviorTriggerRuleLine(models.Model):
_name = "kpi.customer.behavior.trigger.rule.line"
_description = "KPI Customer Behavior Trigger Rule Line"
_order = "sequence, id"
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)
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)
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):
for rec in self:
if rec.assignment_id.employee_id != rec.employee_id:
raise ValidationError(_("Employee must match KPI Assignment employee."))
@@ -0,0 +1,7 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_kpi_customer_behavior_trigger_rule_kpi_manager,kpi.customer.behavior.trigger.rule.kpi.manager,model_kpi_customer_behavior_trigger_rule,odoo_kpi.group_kpi_manager,1,1,1,1
access_kpi_customer_behavior_trigger_rule_kpi_admin,kpi.customer.behavior.trigger.rule.kpi.admin,model_kpi_customer_behavior_trigger_rule,odoo_kpi.group_kpi_admin,1,1,1,1
access_kpi_customer_behavior_trigger_rule_sale_manager,kpi.customer.behavior.trigger.rule.sale.manager,model_kpi_customer_behavior_trigger_rule,sales_team.group_sale_manager,1,1,1,1
access_kpi_customer_behavior_trigger_rule_line_kpi_manager,kpi.customer.behavior.trigger.rule.line.kpi.manager,model_kpi_customer_behavior_trigger_rule_line,odoo_kpi.group_kpi_manager,1,1,1,1
access_kpi_customer_behavior_trigger_rule_line_kpi_admin,kpi.customer.behavior.trigger.rule.line.kpi.admin,model_kpi_customer_behavior_trigger_rule_line,odoo_kpi.group_kpi_admin,1,1,1,1
access_kpi_customer_behavior_trigger_rule_line_sale_manager,kpi.customer.behavior.trigger.rule.line.sale.manager,model_kpi_customer_behavior_trigger_rule_line,sales_team.group_sale_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_kpi_customer_behavior_trigger_rule_kpi_manager kpi.customer.behavior.trigger.rule.kpi.manager model_kpi_customer_behavior_trigger_rule odoo_kpi.group_kpi_manager 1 1 1 1
3 access_kpi_customer_behavior_trigger_rule_kpi_admin kpi.customer.behavior.trigger.rule.kpi.admin model_kpi_customer_behavior_trigger_rule odoo_kpi.group_kpi_admin 1 1 1 1
4 access_kpi_customer_behavior_trigger_rule_sale_manager kpi.customer.behavior.trigger.rule.sale.manager model_kpi_customer_behavior_trigger_rule sales_team.group_sale_manager 1 1 1 1
5 access_kpi_customer_behavior_trigger_rule_line_kpi_manager kpi.customer.behavior.trigger.rule.line.kpi.manager model_kpi_customer_behavior_trigger_rule_line odoo_kpi.group_kpi_manager 1 1 1 1
6 access_kpi_customer_behavior_trigger_rule_line_kpi_admin kpi.customer.behavior.trigger.rule.line.kpi.admin model_kpi_customer_behavior_trigger_rule_line odoo_kpi.group_kpi_admin 1 1 1 1
7 access_kpi_customer_behavior_trigger_rule_line_sale_manager kpi.customer.behavior.trigger.rule.line.sale.manager model_kpi_customer_behavior_trigger_rule_line sales_team.group_sale_manager 1 1 1 1
@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_kpi_customer_behavior_trigger_rule_tree" model="ir.ui.view">
<field name="name">kpi.customer.behavior.trigger.rule.tree</field>
<field name="model">kpi.customer.behavior.trigger.rule</field>
<field name="arch" type="xml">
<tree>
<field name="sequence"/>
<field name="name"/>
<field name="business_category_id"/>
<field name="active"/>
</tree>
</field>
</record>
<record id="view_kpi_customer_behavior_trigger_rule_form" model="ir.ui.view">
<field name="name">kpi.customer.behavior.trigger.rule.form</field>
<field name="model">kpi.customer.behavior.trigger.rule</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="name"/>
<field name="sequence"/>
<field name="business_category_id"/>
<field name="active"/>
</group>
<notebook>
<page string="Trigger Lines">
<field name="line_ids">
<tree editable="bottom">
<field name="sequence"/>
<field name="active"/>
<field name="segment_id"/>
<field name="employee_id"/>
<field name="assignment_id"/>
<field name="score_value"/>
<field name="source_module"/>
<field name="note"/>
</tree>
<form>
<group>
<field name="sequence"/>
<field name="active"/>
<field name="segment_id"/>
<field name="employee_id"/>
<field name="assignment_id" domain="[('employee_id', '=', employee_id)]"/>
</group>
<group>
<field name="score_value"/>
<field name="source_module"/>
<field name="note"/>
</group>
</form>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="action_kpi_customer_behavior_trigger_rule" model="ir.actions.act_window">
<field name="name">KPI Customer Behavior Triggers</field>
<field name="res_model">kpi.customer.behavior.trigger.rule</field>
<field name="view_mode">tree,form</field>
</record>
<menuitem id="menu_kpi_customer_behavior_trigger_rule"
name="KPI Customer Behavior Triggers"
parent="grt_sales_business_category.menu_customer_behavior_analysis"
action="action_kpi_customer_behavior_trigger_rule"
sequence="4"
groups="odoo_kpi.group_kpi_manager,odoo_kpi.group_kpi_admin,sales_team.group_sale_manager"/>
</odoo>
+404
View File
@@ -0,0 +1,404 @@
# Odoo KPI Sales Trigger
## Overview
Modul `odoo_kpi_sales_trigger` digunakan untuk membuat `KPI Value` otomatis dari transaksi Sales Order berdasarkan perilaku pembayaran customer.
Fokus utama modul ini adalah memberi score KPI kepada salesperson berdasarkan:
- pembayaran tepat waktu
- keterlambatan pembayaran berdasarkan jumlah hari
- nilai transaksi di atas threshold tertentu
Modul ini terintegrasi dengan:
- `odoo_kpi`
- `sale_management`
- `account`
- `grt_sales_business_category`
---
# Objective
Tujuan modul ini:
- mengukur kualitas penjualan, bukan hanya jumlah transaksi
- memberi reward untuk transaksi yang dibayar tepat waktu
- memberi penalti untuk transaksi yang terlambat dibayar
- memberi bonus untuk transaksi dengan nilai besar
- mengirim hasil perhitungan ke `kpi.value` berdasarkan `kpi.assignment`
---
# Business Logic
Setiap Sales Order yang sudah lunas penuh akan dievaluasi.
Sistem akan:
1. mengambil Sales Order
2. mengecek apakah semua invoice customer terkait sudah `paid`
3. mengambil tanggal pelunasan terakhir
4. mengambil tanggal jatuh tempo invoice
5. menghitung `late_days`
6. mencari `KPI Sales Trigger Rule` yang cocok
7. membuat atau meng-update `kpi.value`
---
# Score Formula
Rumus score per `Trigger Line`:
```text
score = on_time_score - (late_days x late_penalty_per_day)
```
Kemudian:
- score tidak boleh lebih kecil dari `minimum_score`
- jika `amount_total >= transaction_amount_threshold`, maka score ditambah `transaction_bonus_score`
Formula final:
```text
score = max(on_time_score - (late_days x late_penalty_per_day), minimum_score)
if amount_total >= transaction_amount_threshold:
score += transaction_bonus_score
```
---
# Trigger Event
KPI tidak dibuat saat quotation dibuat atau saat Sales Order dikonfirmasi.
KPI dibuat saat transaksi sudah benar-benar lunas.
Event yang dipantau:
- `account.payment.action_post()`
- perubahan `account.move.payment_state` menjadi `paid`
Ini dipilih agar KPI merepresentasikan realisasi pembayaran, bukan sekadar penjualan.
---
# Data Flow
Flow modul:
```text
Customer Payment Posted
|
v
Invoice becomes Paid
|
v
Find linked Sale Order
|
v
Check Business Category + Salesperson + Assignment Period
|
v
Calculate KPI Score
|
v
Create / Update KPI Value
```
---
# Rule Structure
Model utama:
- `kpi.sales.trigger.rule`
- `kpi.sales.trigger.rule.line`
## kpi.sales.trigger.rule
Header rule dipakai untuk:
- nama rule
- sequence
- status active
- `business_category_id`
Rule dibuat per business category agar scoring dapat dibedakan untuk tiap kategori bisnis.
## kpi.sales.trigger.rule.line
Setiap line menentukan scoring untuk satu employee dan satu assignment KPI.
Field penting:
- `employee_id`
- `assignment_id`
- `on_time_score`
- `late_penalty_per_day`
- `minimum_score`
- `transaction_amount_threshold`
- `transaction_bonus_score`
- `source_module`
- `note`
Constraint:
- `employee_id` pada line harus sama dengan `employee_id` pada `assignment_id`
---
# Payment and Late Day Calculation
## Fully Paid
Sales Order dianggap selesai untuk evaluasi KPI jika:
- memiliki invoice customer (`out_invoice`) yang sudah `posted`
- semua invoice customer terkait memiliki `payment_state = paid`
## Payment Completion Date
Tanggal pelunasan yang dipakai adalah tanggal pelunasan terakhir dari invoice yang terkait dengan Sales Order.
Jika satu Sales Order memiliki beberapa invoice, maka sistem mengambil tanggal pembayaran paling akhir.
## Due Date
Tanggal jatuh tempo yang dipakai adalah tanggal jatuh tempo paling akhir dari invoice customer terkait.
Jika `invoice_date_due` tidak ada, sistem memakai `invoice_date`.
## Late Days
```text
late_days = payment_completion_date - due_date
```
Jika pembayaran dilakukan sebelum atau sama dengan jatuh tempo, maka `late_days = 0`.
---
# KPI Output
Output modul ini adalah record di model `kpi.value`.
Field yang diisi:
- `assignment_id`
- `value`
- `source_module`
- `reference_model`
- `reference_id`
Modul menggunakan mekanisme upsert:
- jika KPI untuk kombinasi assignment + reference + source sudah ada, maka nilai di-update
- jika belum ada, maka dibuat record baru
Ini mencegah duplikasi KPI saat event pembayaran terpanggil lebih dari sekali.
---
# Example Configuration
Contoh rule untuk salesperson A:
- `on_time_score`: `10`
- `late_penalty_per_day`: `1`
- `minimum_score`: `0`
- `transaction_amount_threshold`: `100000000`
- `transaction_bonus_score`: `5`
## Scenario 1
- nilai transaksi: `80.000.000`
- telat: `0` hari
Perhitungan:
```text
score = max(10 - (0 x 1), 0) = 10
```
Hasil:
- score `10`
## Scenario 2
- nilai transaksi: `80.000.000`
- telat: `3` hari
Perhitungan:
```text
score = max(10 - (3 x 1), 0) = 7
```
Hasil:
- score `7`
## Scenario 3
- nilai transaksi: `120.000.000`
- telat: `0` hari
Perhitungan:
```text
score = max(10 - (0 x 1), 0) + 5 = 15
```
Hasil:
- score `15`
## Scenario 4
- nilai transaksi: `120.000.000`
- telat: `20` hari
Perhitungan:
```text
score = max(10 - (20 x 1), 0) + 5 = 5
```
Hasil:
- score `5`
---
# Setup in Odoo
Langkah konfigurasi:
1. pastikan modul `odoo_kpi`, `grt_sales_business_category`, dan `odoo_kpi_sales_trigger` sudah terinstall
2. buat `KPI Definition` pada modul KPI
3. buat `KPI Period`
4. buat `KPI Assignment` untuk employee sales
5. buka menu `Sales > KPI Sales Triggers`
6. buat rule per `Business Category`
7. tambahkan `Trigger Lines` untuk setiap salesperson
8. isi parameter score sesuai kebijakan perusahaan
---
# Recommended Master Data Setup
Agar modul berjalan dengan benar, sebaiknya:
- setiap salesperson memiliki relasi `res.users` ke `hr.employee`
- Sales Order memiliki `business_category_id`
- invoice berasal dari Sales Order yang sama
- KPI Assignment dibuat untuk periode aktif
- period KPI mencakup tanggal pelunasan transaksi
---
# Assumptions
Asumsi implementasi saat ini:
- score diberikan ke salesperson pada `sale.order.user_id`
- evaluasi dilakukan saat order lunas penuh
- invoice yang dihitung hanya `out_invoice`
- refund belum dijadikan trigger KPI terpisah
- bonus nilai transaksi bersifat tambahan di atas score dasar
---
# Limitation
Batasan implementasi saat ini:
- belum ada pembeda rule berdasarkan team sales
- belum ada pembeda rule berdasarkan produk atau product category
- belum ada tier threshold bertingkat dalam satu line
- belum ada wizard simulasi score dari transaksi
- belum ada scheduled recompute untuk data historis
Jika dibutuhkan, pengembangan berikutnya bisa menambahkan:
- multi-threshold bonus
- penalty maksimum
- rule per sales team
- rule per customer segment
- recompute KPI existing transaction
---
# Technical Notes
Hook utama ada di:
- `models/account_payment.py`
- `models/account_move.py`
- `models/sale_order.py`
- `models/kpi_sales_trigger_rule.py`
Logika inti:
- mendeteksi invoice customer yang telah lunas
- menelusuri Sales Order terkait
- menghitung score berdasarkan konfigurasi
- menulis hasil ke `kpi.value`
---
# Security
Akses konfigurasi rule diberikan ke:
- `odoo_kpi.group_kpi_manager`
- `odoo_kpi.group_kpi_admin`
- `sales_team.group_sale_manager`
---
# Upgrade Module
Contoh command upgrade:
```bat
c:\odoo14c\python\python.exe C:\odoo14c\server\odoo-bin -c C:\addon14\odoo.conf -d kanjabung_MRP -u odoo_kpi_sales_trigger --stop-after-init
```
Atau gunakan script:
```bat
upgrade_odoo_kpi_sales_trigger.bat
```
---
# Testing Checklist
Checklist test manual:
1. buat KPI assignment untuk salesperson
2. buat rule pada business category yang sesuai
3. buat Sales Order dengan salesperson tersebut
4. konfirmasi Sales Order dan generate invoice
5. lakukan pembayaran sebelum due date
6. pastikan `kpi.value` terbentuk dengan score sesuai
7. ulangi dengan pembayaran terlambat
8. ulangi dengan nilai transaksi di atas threshold
9. pastikan tidak ada duplikasi `kpi.value` untuk order yang sama
---
# Summary
`odoo_kpi_sales_trigger` menambahkan mekanisme KPI sales berbasis kualitas pembayaran customer.
Dengan modul ini, KPI sales tidak hanya menghitung volume penjualan, tetapi juga:
- ketepatan pembayaran
- dampak keterlambatan pembayaran
- nilai strategis transaksi
+1
View File
@@ -0,0 +1 @@
from . import models
+16
View File
@@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
{
"name": "Odoo KPI Sales Trigger",
"summary": "Sales event rules to send KPI values by assignment",
"version": "14.0.1.0.2",
"category": "Sales/Sales",
"author": "Custom",
"depends": ["sale_management", "account", "hr", "odoo_kpi", "grt_sales_business_category"],
"data": [
"security/ir.model.access.csv",
"views/kpi_sales_trigger_rule_views.xml",
],
"installable": True,
"application": False,
"license": "LGPL-3",
}
@@ -0,0 +1,4 @@
from . import kpi_sales_trigger_rule
from . import sale_order
from . import account_move
from . import account_payment
@@ -0,0 +1,30 @@
from odoo import models
class AccountMove(models.Model):
_inherit = "account.move"
def _get_kpi_sales_payment_dates(self):
self.ensure_one()
receivable_lines = self.line_ids.filtered(lambda line: line.account_id.internal_type == "receivable")
partials = receivable_lines.mapped("matched_debit_ids") | receivable_lines.mapped("matched_credit_ids")
counterpart_lines = (partials.mapped("debit_move_id") | partials.mapped("credit_move_id")) - receivable_lines
return [move.date for move in counterpart_lines.mapped("move_id") if move.date]
def write(self, vals):
previous_payment_state = {}
if "payment_state" in vals:
previous_payment_state = {move.id: move.payment_state for move in self}
result = super().write(vals)
if "payment_state" in vals:
paid_moves = self.filtered(
lambda move: move.move_type == "out_invoice"
and previous_payment_state.get(move.id) != "paid"
and move.payment_state == "paid"
)
orders = paid_moves.mapped("invoice_line_ids.sale_line_ids.order_id")
if orders:
self.env["kpi.sales.trigger.rule"].sudo().process_paid_orders(orders)
return result
@@ -0,0 +1,18 @@
from odoo import models
class AccountPayment(models.Model):
_inherit = "account.payment"
def action_post(self):
result = super().action_post()
receivable_lines = self.mapped("move_id.line_ids").filtered(lambda line: line.account_id.internal_type == "receivable")
partials = receivable_lines.mapped("matched_debit_ids") | receivable_lines.mapped("matched_credit_ids")
counterpart_lines = (partials.mapped("debit_move_id") | partials.mapped("credit_move_id")) - receivable_lines
invoice_moves = counterpart_lines.mapped("move_id").filtered(
lambda move: move.move_type == "out_invoice" and move.state == "posted" and move.payment_state == "paid"
)
orders = invoice_moves.mapped("invoice_line_ids.sale_line_ids.order_id")
if orders:
self.env["kpi.sales.trigger.rule"].sudo().process_paid_orders(orders)
return result
@@ -0,0 +1,137 @@
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
class KpiSalesTriggerRule(models.Model):
_name = "kpi.sales.trigger.rule"
_description = "KPI Sales Trigger Rule"
_order = "sequence, id"
name = fields.Char(required=True)
sequence = fields.Integer(default=10)
active = fields.Boolean(default=True)
business_category_id = fields.Many2one("crm.business.category", required=True, ondelete="cascade", index=True)
line_ids = fields.One2many("kpi.sales.trigger.rule.line", "rule_id", string="Trigger Lines")
def _find_employee_from_user(self, user):
if not user:
return self.env["hr.employee"]
return self.env["hr.employee"].search([("user_id", "=", user.id)], limit=1)
@api.model
def _assignment_matches_date(self, assignment, event_date):
period = assignment.period_id
return bool(period and period.date_start and period.date_end and period.date_start <= event_date <= period.date_end)
@api.model
def _calculate_line_score(self, line, order, late_days):
score = line.on_time_score
if late_days > 0:
score -= late_days * line.late_penalty_per_day
score = max(score, line.minimum_score)
if line.transaction_amount_threshold and order.amount_total >= line.transaction_amount_threshold:
score += line.transaction_bonus_score
return score
@api.model
def _upsert_value(self, line, value, ref_model, ref_id):
value_model = self.env["kpi.value"].sudo()
existing = value_model.search(
[
("assignment_id", "=", line.assignment_id.id),
("reference_model", "=", ref_model),
("reference_id", "=", ref_id),
("source_module", "=", line.source_module),
],
limit=1,
)
vals = {
"assignment_id": line.assignment_id.id,
"value": value,
"source_module": line.source_module,
"reference_model": ref_model,
"reference_id": ref_id,
}
if existing:
existing.write({"value": value})
return existing
return value_model.create(vals)
@api.model
def process_paid_orders(self, orders):
if not orders:
return False
for order in orders:
if not order.business_category_id or not order.user_id or not order._is_kpi_sales_fully_paid():
continue
payment_date = order._get_kpi_sales_payment_completion_date()
if not payment_date:
continue
employee = self._find_employee_from_user(order.user_id)
if not employee:
continue
rules = self.search(
[
("active", "=", True),
("business_category_id", "=", order.business_category_id.id),
]
)
late_days = order._get_kpi_sales_late_days(payment_date)
for rule in rules:
lines = rule.line_ids.filtered(
lambda l: l.active
and l.employee_id.id == employee.id
and self._assignment_matches_date(l.assignment_id, payment_date)
)
for line in lines:
score = self._calculate_line_score(line, order, late_days)
self._upsert_value(
line=line,
value=score,
ref_model="sale.order.payment.rule.line.%s" % line.id,
ref_id=order.id,
)
return True
class KpiSalesTriggerRuleLine(models.Model):
_name = "kpi.sales.trigger.rule.line"
_description = "KPI Sales Trigger Rule Line"
_order = "sequence, id"
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)
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)
minimum_score = fields.Float(default=0.0)
transaction_amount_threshold = fields.Float(default=0.0)
transaction_bonus_score = fields.Float(default=0.0)
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:
if rec.on_time_score < 0:
raise ValidationError(_("On-time score must be zero or greater."))
if rec.late_penalty_per_day < 0:
raise ValidationError(_("Late penalty per day must be zero or greater."))
if rec.minimum_score < 0:
raise ValidationError(_("Minimum score must be zero or greater."))
if rec.transaction_amount_threshold < 0:
raise ValidationError(_("Transaction amount threshold must be zero or greater."))
if rec.transaction_bonus_score < 0:
raise ValidationError(_("Transaction bonus score must be zero or greater."))
@@ -0,0 +1,37 @@
from odoo import fields, models
class SaleOrder(models.Model):
_inherit = "sale.order"
def _get_kpi_sales_customer_invoices(self):
self.ensure_one()
return self.invoice_ids.filtered(lambda inv: inv.move_type == "out_invoice" and inv.state == "posted")
def _is_kpi_sales_fully_paid(self):
self.ensure_one()
invoices = self._get_kpi_sales_customer_invoices()
return bool(invoices) and all(invoice.payment_state == "paid" for invoice in invoices)
def _get_kpi_sales_payment_completion_date(self):
self.ensure_one()
invoices = self._get_kpi_sales_customer_invoices().filtered(lambda inv: inv.payment_state == "paid")
payment_dates = []
for invoice in invoices:
payment_dates.extend(invoice._get_kpi_sales_payment_dates())
payment_dates = [date_value for date_value in payment_dates if date_value]
return max(payment_dates) if payment_dates else False
def _get_kpi_sales_due_date(self):
self.ensure_one()
invoices = self._get_kpi_sales_customer_invoices()
due_dates = [invoice.invoice_date_due or invoice.invoice_date for invoice in invoices if invoice.invoice_date_due or invoice.invoice_date]
return max(due_dates) if due_dates else False
def _get_kpi_sales_late_days(self, payment_date=False):
self.ensure_one()
payment_date = payment_date or self._get_kpi_sales_payment_completion_date()
due_date = self._get_kpi_sales_due_date()
if not payment_date or not due_date or payment_date <= due_date:
return 0
return (fields.Date.to_date(payment_date) - fields.Date.to_date(due_date)).days
@@ -0,0 +1,7 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_kpi_sales_trigger_rule_kpi_manager,kpi.sales.trigger.rule.kpi.manager,model_kpi_sales_trigger_rule,odoo_kpi.group_kpi_manager,1,1,1,1
access_kpi_sales_trigger_rule_kpi_admin,kpi.sales.trigger.rule.kpi.admin,model_kpi_sales_trigger_rule,odoo_kpi.group_kpi_admin,1,1,1,1
access_kpi_sales_trigger_rule_sale_manager,kpi.sales.trigger.rule.sale.manager,model_kpi_sales_trigger_rule,sales_team.group_sale_manager,1,1,1,1
access_kpi_sales_trigger_rule_line_kpi_manager,kpi.sales.trigger.rule.line.kpi.manager,model_kpi_sales_trigger_rule_line,odoo_kpi.group_kpi_manager,1,1,1,1
access_kpi_sales_trigger_rule_line_kpi_admin,kpi.sales.trigger.rule.line.kpi.admin,model_kpi_sales_trigger_rule_line,odoo_kpi.group_kpi_admin,1,1,1,1
access_kpi_sales_trigger_rule_line_sale_manager,kpi.sales.trigger.rule.line.sale.manager,model_kpi_sales_trigger_rule_line,sales_team.group_sale_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_kpi_sales_trigger_rule_kpi_manager kpi.sales.trigger.rule.kpi.manager model_kpi_sales_trigger_rule odoo_kpi.group_kpi_manager 1 1 1 1
3 access_kpi_sales_trigger_rule_kpi_admin kpi.sales.trigger.rule.kpi.admin model_kpi_sales_trigger_rule odoo_kpi.group_kpi_admin 1 1 1 1
4 access_kpi_sales_trigger_rule_sale_manager kpi.sales.trigger.rule.sale.manager model_kpi_sales_trigger_rule sales_team.group_sale_manager 1 1 1 1
5 access_kpi_sales_trigger_rule_line_kpi_manager kpi.sales.trigger.rule.line.kpi.manager model_kpi_sales_trigger_rule_line odoo_kpi.group_kpi_manager 1 1 1 1
6 access_kpi_sales_trigger_rule_line_kpi_admin kpi.sales.trigger.rule.line.kpi.admin model_kpi_sales_trigger_rule_line odoo_kpi.group_kpi_admin 1 1 1 1
7 access_kpi_sales_trigger_rule_line_sale_manager kpi.sales.trigger.rule.line.sale.manager model_kpi_sales_trigger_rule_line sales_team.group_sale_manager 1 1 1 1
@@ -0,0 +1,81 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_kpi_sales_trigger_rule_tree" model="ir.ui.view">
<field name="name">kpi.sales.trigger.rule.tree</field>
<field name="model">kpi.sales.trigger.rule</field>
<field name="arch" type="xml">
<tree>
<field name="sequence"/>
<field name="name"/>
<field name="business_category_id"/>
<field name="active"/>
</tree>
</field>
</record>
<record id="view_kpi_sales_trigger_rule_form" model="ir.ui.view">
<field name="name">kpi.sales.trigger.rule.form</field>
<field name="model">kpi.sales.trigger.rule</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="name"/>
<field name="sequence"/>
<field name="business_category_id"/>
<field name="active"/>
</group>
<notebook>
<page string="Trigger Lines">
<field name="line_ids">
<tree editable="bottom">
<field name="sequence"/>
<field name="active"/>
<field name="employee_id"/>
<field name="assignment_id"/>
<field name="on_time_score"/>
<field name="late_penalty_per_day"/>
<field name="minimum_score"/>
<field name="transaction_amount_threshold"/>
<field name="transaction_bonus_score"/>
<field name="source_module"/>
<field name="note"/>
</tree>
<form>
<group>
<field name="sequence"/>
<field name="active"/>
<field name="employee_id"/>
<field name="assignment_id" domain="[('employee_id', '=', employee_id)]"/>
<field name="source_module"/>
</group>
<group string="Scoring">
<field name="on_time_score"/>
<field name="late_penalty_per_day"/>
<field name="minimum_score"/>
<field name="transaction_amount_threshold"/>
<field name="transaction_bonus_score"/>
<field name="note"/>
</group>
</form>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="action_kpi_sales_trigger_rule" model="ir.actions.act_window">
<field name="name">KPI Sales Triggers</field>
<field name="res_model">kpi.sales.trigger.rule</field>
<field name="view_mode">tree,form</field>
</record>
<menuitem id="menu_kpi_sales_trigger_rule"
name="KPI Sales Triggers"
parent="sale.sale_order_menu"
action="action_kpi_sales_trigger_rule"
sequence="96"
groups="odoo_kpi.group_kpi_manager,odoo_kpi.group_kpi_admin,sales_team.group_sale_manager"/>
</odoo>
+26
View File
@@ -0,0 +1,26 @@
@echo off
echo ================================================================
echo UPGRADE ODOO KPI SALES TRIGGER
echo ================================================================
echo.
echo Module : odoo_kpi_sales_trigger
echo Database: kanjabung_MRP
echo Port : 8070
echo.
pause
echo.
echo [1/2] Upgrading module...
c:\odoo14c\python\python.exe C:\odoo14c\server\odoo-bin -c C:\addon14\odoo.conf -d kanjabung_MRP -u odoo_kpi_sales_trigger --stop-after-init
if errorlevel 1 (
echo.
echo Upgrade gagal.
pause
exit /b 1
)
echo.
echo [2/2] Selesai.
echo Buka Odoo di http://localhost:8070 lalu cek menu Sales ^> KPI Sales Triggers
echo.
pause