Compare commits
3 Commits
c9ac4c64fb
...
6cbb5f85fe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6cbb5f85fe | ||
|
|
596ecb9e03 | ||
|
|
99e27cc566 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
'name': 'Fusion Accounting — Bank Reconciliation',
|
||||
'version': '19.0.1.0.15',
|
||||
'version': '19.0.1.0.18',
|
||||
'category': 'Accounting/Accounting',
|
||||
'sequence': 28,
|
||||
'summary': 'Native V19 bank reconciliation widget with AI confidence scoring + behavioural learning.',
|
||||
@@ -80,6 +80,23 @@ Built by Nexa Systems Inc.
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/quick_create/quick_create.xml',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/chatter/chatter.js',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/file_uploader/file_uploader.js',
|
||||
# Fusion-only (Task 34) — AI suggestion UI
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/ai_suggestion/ai_suggestion_strip.js',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/ai_suggestion/ai_suggestion_strip.xml',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/ai_suggestion/ai_alternatives_panel.js',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/ai_suggestion/ai_alternatives_panel.xml',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/ai_suggestion/ai_reasoning_tooltip.js',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/ai_suggestion/ai_reasoning_tooltip.xml',
|
||||
# Fusion-only (Task 35) — batch action bar + reconcile model picker
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/batch_action_bar/batch_action_bar.js',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/batch_action_bar/batch_action_bar.xml',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/reconcile_model_picker/reconcile_model_picker.js',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/reconcile_model_picker/reconcile_model_picker.xml',
|
||||
# Fusion-only (Task 36) — attachment strip + partner history panel
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/attachment_strip/attachment_strip.js',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/attachment_strip/attachment_strip.xml',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/partner_history_panel/partner_history_panel.js',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/partner_history_panel/partner_history_panel.xml',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
export class AiAlternativesPanel extends Component {
|
||||
static template = "fusion_accounting_bank_rec.AiAlternativesPanel";
|
||||
static props = {
|
||||
suggestions: { type: Array },
|
||||
onClose: { type: Function, optional: true },
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.bankRec = useService("fusion_bank_reconciliation");
|
||||
}
|
||||
|
||||
bandFor(c) {
|
||||
if (c >= 0.85) return "high";
|
||||
if (c >= 0.6) return "medium";
|
||||
if (c > 0) return "low";
|
||||
return "none";
|
||||
}
|
||||
|
||||
pctFor(c) {
|
||||
return Math.round(c * 100);
|
||||
}
|
||||
|
||||
async onAccept(suggestionId) {
|
||||
await this.bankRec.acceptSuggestion(suggestionId);
|
||||
if (this.props.onClose) {
|
||||
this.props.onClose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="fusion_accounting_bank_rec.AiAlternativesPanel">
|
||||
<div class="o_fusion_alternatives_panel">
|
||||
<h6>Other AI suggestions</h6>
|
||||
<div t-foreach="props.suggestions" t-as="sug" t-key="sug.id"
|
||||
class="o_fusion_alternative">
|
||||
<div>
|
||||
<span class="alt_confidence" t-att-class="'band-' + bandFor(sug.confidence)">
|
||||
<t t-esc="pctFor(sug.confidence)"/>%
|
||||
</span>
|
||||
<t t-esc="sug.reasoning"/>
|
||||
</div>
|
||||
<button class="btn_fusion" t-on-click="() => onAccept(sug.id)">
|
||||
Use this
|
||||
</button>
|
||||
</div>
|
||||
<div t-if="props.onClose" class="text-end mt-2">
|
||||
<button class="btn_fusion" t-on-click="props.onClose">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,18 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
export class AiReasoningTooltip extends Component {
|
||||
static template = "fusion_accounting_bank_rec.AiReasoningTooltip";
|
||||
static props = {
|
||||
scores: { type: Object },
|
||||
reasoning: { type: String, optional: true },
|
||||
};
|
||||
|
||||
pctFor(value) {
|
||||
if (value === undefined || value === null) {
|
||||
return "0";
|
||||
}
|
||||
return (value * 100).toFixed(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="fusion_accounting_bank_rec.AiReasoningTooltip">
|
||||
<div class="o_fusion_reasoning_tooltip" style="font-size: 0.85em; padding: 0.5rem;">
|
||||
<div t-if="props.reasoning" class="mb-2">
|
||||
<em><t t-esc="props.reasoning"/></em>
|
||||
</div>
|
||||
<div class="text-muted">
|
||||
<div>Amount match: <t t-esc="pctFor(props.scores.amount_match)"/>%</div>
|
||||
<div>Partner pattern: <t t-esc="pctFor(props.scores.partner_pattern)"/>%</div>
|
||||
<div>Precedent similarity: <t t-esc="pctFor(props.scores.precedent_similarity)"/>%</div>
|
||||
<div t-if="props.scores.ai_rerank">
|
||||
AI re-rank: <t t-esc="pctFor(props.scores.ai_rerank)"/>%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,38 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
export class AiSuggestionStrip extends Component {
|
||||
static template = "fusion_accounting_bank_rec.AiSuggestionStrip";
|
||||
static props = {
|
||||
suggestion: { type: Object },
|
||||
showAlternatives: { type: Function, optional: true },
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.bankRec = useService("fusion_bank_reconciliation");
|
||||
}
|
||||
|
||||
get band() {
|
||||
const c = this.props.suggestion.confidence;
|
||||
if (c >= 0.85) return "high";
|
||||
if (c >= 0.6) return "medium";
|
||||
if (c > 0) return "low";
|
||||
return "none";
|
||||
}
|
||||
|
||||
get confidencePct() {
|
||||
return Math.round(this.props.suggestion.confidence * 100);
|
||||
}
|
||||
|
||||
async onAccept() {
|
||||
await this.bankRec.acceptSuggestion(this.props.suggestion.id);
|
||||
}
|
||||
|
||||
onShowAlternatives() {
|
||||
if (this.props.showAlternatives) {
|
||||
this.props.showAlternatives();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="fusion_accounting_bank_rec.AiSuggestionStrip">
|
||||
<div class="o_fusion_ai_suggestion" t-att-data-band="band">
|
||||
<div class="o_fusion_confidence_badge">
|
||||
<t t-esc="confidencePct"/>%
|
||||
</div>
|
||||
<div class="o_fusion_suggestion_text">
|
||||
<div class="o_fusion_reasoning">
|
||||
<t t-esc="props.suggestion.reasoning || 'AI suggested match'"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_fusion_suggestion_actions">
|
||||
<button class="btn_fusion btn_fusion_primary" t-on-click="onAccept">
|
||||
Accept
|
||||
</button>
|
||||
<button t-if="props.showAlternatives" class="btn_fusion"
|
||||
t-on-click="onShowAlternatives">
|
||||
Other options
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,27 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
export class AttachmentStrip extends Component {
|
||||
static template = "fusion_accounting_bank_rec.AttachmentStrip";
|
||||
static props = {
|
||||
attachments: { type: Array },
|
||||
};
|
||||
|
||||
iconFor(mimetype) {
|
||||
if (!mimetype) {
|
||||
return "fa-file";
|
||||
}
|
||||
if (mimetype.startsWith("image/")) {
|
||||
return "fa-file-image-o";
|
||||
}
|
||||
if (mimetype === "application/pdf") {
|
||||
return "fa-file-pdf-o";
|
||||
}
|
||||
return "fa-file-o";
|
||||
}
|
||||
|
||||
urlFor(att) {
|
||||
return `/web/content/${att.id}?download=true`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="fusion_accounting_bank_rec.AttachmentStrip">
|
||||
<div class="o_fusion_attachment_strip d-flex flex-wrap"
|
||||
style="gap: 0.5rem; padding: 0.5rem;">
|
||||
<div t-if="props.attachments.length === 0" class="text-muted small">
|
||||
No attachments
|
||||
</div>
|
||||
<a t-foreach="props.attachments" t-as="att" t-key="att.id"
|
||||
t-att-href="urlFor(att)" target="_blank"
|
||||
class="o_fusion_attachment_chip"
|
||||
style="display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.25rem 0.5rem; background: #f3f4f6; border-radius: 0.25rem; text-decoration: none; color: inherit; font-size: 0.85em;">
|
||||
<i class="fa" t-att-class="iconFor(att.mimetype)"/>
|
||||
<span><t t-esc="att.name"/></span>
|
||||
</a>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,37 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
export class BatchActionBar extends Component {
|
||||
static template = "fusion_accounting_bank_rec.BatchActionBar";
|
||||
static props = {
|
||||
selectedIds: { type: Array, optional: true },
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.bankRec = useService("fusion_bank_reconciliation");
|
||||
}
|
||||
|
||||
get hasSelection() {
|
||||
return this.props.selectedIds && this.props.selectedIds.length > 0;
|
||||
}
|
||||
|
||||
get selectionCount() {
|
||||
return this.props.selectedIds ? this.props.selectedIds.length : 0;
|
||||
}
|
||||
|
||||
async onAutoReconcile() {
|
||||
if (!this.hasSelection) {
|
||||
return;
|
||||
}
|
||||
await this.bankRec.bulkReconcile(this.props.selectedIds, "auto");
|
||||
}
|
||||
|
||||
async onSuggestForSelected() {
|
||||
if (!this.hasSelection) {
|
||||
return;
|
||||
}
|
||||
await this.bankRec.suggestMatches(this.props.selectedIds, 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="fusion_accounting_bank_rec.BatchActionBar">
|
||||
<div class="o_fusion_batch_action_bar d-flex"
|
||||
style="gap: 0.5rem; padding: 0.75rem; background: #f3f4f6; border-radius: 0.375rem;">
|
||||
<span class="text-muted">
|
||||
<t t-esc="selectionCount"/> selected
|
||||
</span>
|
||||
<button class="btn_fusion" t-att-disabled="!hasSelection" t-on-click="onSuggestForSelected">
|
||||
Suggest for selected
|
||||
</button>
|
||||
<button class="btn_fusion btn_fusion_primary" t-att-disabled="!hasSelection" t-on-click="onAutoReconcile">
|
||||
Auto-reconcile selected
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,34 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component, onWillStart, useState } from "@odoo/owl";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
export class PartnerHistoryPanel extends Component {
|
||||
static template = "fusion_accounting_bank_rec.PartnerHistoryPanel";
|
||||
static props = {
|
||||
partnerId: { type: Number },
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.bankRec = useService("fusion_bank_reconciliation");
|
||||
this.state = useState({ history: null, loading: true });
|
||||
|
||||
onWillStart(async () => {
|
||||
try {
|
||||
this.state.history = await this.bankRec.getPartnerHistory(
|
||||
this.props.partnerId,
|
||||
20,
|
||||
);
|
||||
} finally {
|
||||
this.state.loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
formatAmount(value) {
|
||||
if (value === undefined || value === null) {
|
||||
return "0.00";
|
||||
}
|
||||
return Number(value).toFixed(2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="fusion_accounting_bank_rec.PartnerHistoryPanel">
|
||||
<div class="o_fusion_partner_history_panel" style="padding: 1rem; border-left: 1px solid #e5e7eb;">
|
||||
<h5 t-if="state.history">
|
||||
<t t-esc="state.history.partner.name"/> — History
|
||||
</h5>
|
||||
<div t-if="state.loading" class="text-muted">Loading…</div>
|
||||
<div t-elif="state.history">
|
||||
<div t-if="state.history.pattern" class="mb-3 p-2"
|
||||
style="background: #eff6ff; border-radius: 0.25rem; font-size: 0.85em;">
|
||||
<strong>Learned pattern:</strong>
|
||||
<div>Reconciles: <t t-esc="state.history.pattern.reconcile_count"/></div>
|
||||
<div t-if="state.history.pattern.pref_strategy">
|
||||
Preferred strategy: <t t-esc="state.history.pattern.pref_strategy"/>
|
||||
</div>
|
||||
<div t-if="state.history.pattern.typical_cadence_days">
|
||||
Typical cadence: ~<t t-esc="state.history.pattern.typical_cadence_days"/> days
|
||||
</div>
|
||||
</div>
|
||||
<h6>Recent reconciles</h6>
|
||||
<div t-foreach="state.history.recent_reconciles" t-as="rec" t-key="rec.precedent_id"
|
||||
style="padding: 0.5rem 0; border-bottom: 1px solid #e5e7eb; font-size: 0.85em;">
|
||||
<div class="d-flex justify-content-between">
|
||||
<span><t t-esc="rec.date"/></span>
|
||||
<span><strong>$<t t-esc="formatAmount(rec.amount)"/></strong></span>
|
||||
</div>
|
||||
<div class="text-muted">
|
||||
<t t-if="rec.memo_tokens"><t t-esc="rec.memo_tokens"/></t>
|
||||
<span class="ms-2">(<t t-esc="rec.matched_count"/> line<t t-if="rec.matched_count !== 1">s</t>)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div t-if="state.history.recent_reconciles.length === 0" class="text-muted">
|
||||
No history yet
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,39 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component, onWillStart, useState } from "@odoo/owl";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
export class ReconcileModelPicker extends Component {
|
||||
static template = "fusion_accounting_bank_rec.ReconcileModelPicker";
|
||||
static props = {
|
||||
statementLineId: { type: Number, optional: true },
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.orm = useService("orm");
|
||||
this.bankRec = useService("fusion_bank_reconciliation");
|
||||
this.state = useState({ models: [], selected: null });
|
||||
|
||||
onWillStart(async () => {
|
||||
const models = await this.orm.searchRead(
|
||||
"account.reconcile.model",
|
||||
[["rule_type", "=", "writeoff_button"]],
|
||||
["id", "name", "fusion_ai_confidence_threshold"],
|
||||
{ limit: 20 }
|
||||
);
|
||||
this.state.models = models;
|
||||
});
|
||||
}
|
||||
|
||||
onChange(ev) {
|
||||
const value = parseInt(ev.target.value, 10);
|
||||
if (Number.isFinite(value)) {
|
||||
this.onApplyModel(value);
|
||||
}
|
||||
}
|
||||
|
||||
async onApplyModel(modelId) {
|
||||
// Phase 1 placeholder: TODO route through dedicated endpoint when Task 38 lands
|
||||
this.state.selected = modelId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="fusion_accounting_bank_rec.ReconcileModelPicker">
|
||||
<div class="o_fusion_reconcile_model_picker">
|
||||
<select class="form-select" style="max-width: 240px;"
|
||||
t-on-change="onChange">
|
||||
<option value="">— Apply reconcile model —</option>
|
||||
<option t-foreach="state.models" t-as="m" t-key="m.id" t-att-value="m.id">
|
||||
<t t-esc="m.name"/>
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
Reference in New Issue
Block a user