changes
This commit is contained in:
626
fusion_clover/CLAUDE.md
Normal file
626
fusion_clover/CLAUDE.md
Normal file
@@ -0,0 +1,626 @@
|
|||||||
|
# fusion_clover — Claude Code Instructions
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Odoo 19 payment provider integration for **Clover** — Westin Healthcare's
|
||||||
|
new processor (running alongside `fusion_poynt` for Poynt which is
|
||||||
|
already deployed on `odoo-westin`).
|
||||||
|
|
||||||
|
Built by **Nexa Systems Inc** on a Clover developer account. Designed
|
||||||
|
for in-store **terminal** payments via Cloud REST Pay Display, plus
|
||||||
|
**ecommerce/portal** card-not-present payments and **manual** card
|
||||||
|
collection from the back office.
|
||||||
|
|
||||||
|
**As of 2026-04-29 v19.0.1.10.0**: end-to-end OAuth flow validated in
|
||||||
|
sandbox with Test Merc 2 (`ASVPRFJ5D5GF1`); $1 + $2 charges processed
|
||||||
|
and refunded successfully; battle-tested for declines, idempotency,
|
||||||
|
HMAC forgery, and recursive 401 prevention; Cloudflare Worker
|
||||||
|
dispatcher hammer-tested at 350 req/s. Ready for production cutover —
|
||||||
|
just needs prod App ID/Secret/RAID swap.
|
||||||
|
|
||||||
|
## Sister module
|
||||||
|
|
||||||
|
`../fusion_poynt/` is the same architecture targeted at Poynt. Use it
|
||||||
|
as the reference implementation for anything ambiguous here (it is
|
||||||
|
already production-tested at Westin). When in doubt about wizard UX,
|
||||||
|
account.move buttons, refund flow, surcharge logic, copy from
|
||||||
|
fusion_poynt — they share the same idiomatic patterns intentionally.
|
||||||
|
|
||||||
|
The Supabase decision log records:
|
||||||
|
|
||||||
|
> "fusion_clover: Full feature parity with fusion_poynt — Implemented
|
||||||
|
> void support, pre-refund verification, transaction age tracking with
|
||||||
|
> 180-day limit, non-referenced credits via POST /v1/credits and
|
||||||
|
> terminal, default terminal, terminal renaming, order ID tracking,
|
||||||
|
> extended webhooks (8 types)."
|
||||||
|
|
||||||
|
## Module layout
|
||||||
|
|
||||||
|
```
|
||||||
|
fusion_clover/
|
||||||
|
├── __manifest__.py # depends: payment, account_payment, sale
|
||||||
|
├── const.py # API URLs (v2 OAuth), status maps
|
||||||
|
├── utils.py # idempotency, payload builders, base64url
|
||||||
|
├── controllers/
|
||||||
|
│ ├── main.py # /payment/clover/{return,webhook,oauth/callback,
|
||||||
|
│ │ # terminals,send_to_terminal,terminal_status,
|
||||||
|
│ │ # terminal/callback,process_card}
|
||||||
|
│ └── portal.py # CustomerPortal override for auto payment_amount
|
||||||
|
├── models/
|
||||||
|
│ ├── payment_provider.py # PaymentProvider — credentials, ecom/platform/
|
||||||
|
│ │ # terminal request helpers w/ auto-refresh,
|
||||||
|
│ │ # charge/refund/credit/capture, OAuth helpers,
|
||||||
|
│ │ # server-side tokenization, brand detection
|
||||||
|
│ ├── payment_transaction.py # PaymentTransaction — token flow, refund/capture/
|
||||||
|
│ │ # void, action_clover_void, _apply_updates
|
||||||
|
│ ├── payment_token.py # clover_source_token char field
|
||||||
|
│ ├── clover_terminal.py # CloverTerminal — ping, send_payment, refund,
|
||||||
|
│ │ # check_status, display_welcome
|
||||||
|
│ ├── account_move.py # invoice/credit-note buttons, refund smart button
|
||||||
|
│ ├── sale_order.py # action_clover_collect_payment from SO
|
||||||
|
│ └── res_config_settings.py # surcharge config + open_clover_provider button
|
||||||
|
├── wizard/
|
||||||
|
│ ├── clover_payment_wizard.py # back-office collection (terminal | manual card)
|
||||||
|
│ └── clover_refund_wizard.py # referenced + non-referenced refund flow
|
||||||
|
├── views/ # XML for all of the above + payment provider form
|
||||||
|
├── data/
|
||||||
|
│ ├── payment_provider_data.xml # disabled-by-default provider record
|
||||||
|
│ ├── clover_surcharge_product.xml # CC processing fee product
|
||||||
|
│ └── clover_receipt_email_template.xml
|
||||||
|
├── report/
|
||||||
|
│ ├── clover_receipt_report.xml # ir.actions.report
|
||||||
|
│ └── clover_receipt_templates.xml # QWeb (incl. 2-page refund+original)
|
||||||
|
├── security/
|
||||||
|
│ ├── security.xml # Fusion Clover privilege + User/Admin groups
|
||||||
|
│ └── ir.model.access.csv
|
||||||
|
└── static/
|
||||||
|
└── src/interactions/payment_form.js # PaymentForm patch w/ Clover.js
|
||||||
|
# iframe SDK tokenization
|
||||||
|
```
|
||||||
|
|
||||||
|
## Clover API surfaces used
|
||||||
|
|
||||||
|
| Surface | Sandbox URL | Production URL | Used for |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **OAuth v2 authorize** | `sandbox.dev.clover.com/oauth/v2/authorize` | `www.clover.com/oauth/v2/authorize` | Merchant authorization (NOT on apisandbox host) |
|
||||||
|
| **OAuth v2 token** | `apisandbox.dev.clover.com/oauth/v2/token` | `api.clover.com/oauth/v2/token` | Code → access_token + refresh_token |
|
||||||
|
| **OAuth v2 refresh** | `apisandbox.dev.clover.com/oauth/v2/refresh` | `api.clover.com/oauth/v2/refresh` | Renew access_token (single-use refresh!) |
|
||||||
|
| **Ecommerce API** | `scl-sandbox.dev.clover.com` | `scl.clover.com` | `/v1/charges`, `/v1/refunds`, `/v1/credits`, `/pakms/apikey` |
|
||||||
|
| **Tokenization Service** | `token-sandbox.dev.clover.com` | `token.clover.com` | `/v1/tokens` (server-side card → `clv_xxx`) |
|
||||||
|
| **Platform API v3** | `apisandbox.dev.clover.com` | `api.clover.com` | `/v3/merchants/{mId}`, `/v3/merchants/{mId}/devices` |
|
||||||
|
| **REST Pay Display Cloud** | `apisandbox.dev.clover.com/connect/v1` | `api.clover.com/connect/v1` | Terminal payments (`/payments`, `/device/ping`) |
|
||||||
|
|
||||||
|
Production base URLs are auto-selected when `payment.provider.state`
|
||||||
|
is `enabled` (vs. `test`). Constants live in `const.py`.
|
||||||
|
|
||||||
|
## God-nodes / cross-cutting components
|
||||||
|
|
||||||
|
(From the graphify report — most-connected abstractions)
|
||||||
|
|
||||||
|
1. `CloverController` — 24 edges (controllers/main.py)
|
||||||
|
2. `PaymentTransaction` — 19 edges
|
||||||
|
3. `PaymentProvider` — 17 edges
|
||||||
|
4. `CloverPaymentWizard` — 15 edges
|
||||||
|
5. `CloverRefundWizard` — 11 edges
|
||||||
|
6. `format_clover_amount()` (utils) — 10 edges
|
||||||
|
7. `AccountMove` — 9 edges
|
||||||
|
8. `CloverTerminal` — 8 edges
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Multi-tenant OAuth — Nexa Dispatcher
|
||||||
|
|
||||||
|
`fusion_clover` is designed to serve **many customer Odoo instances**
|
||||||
|
from a **single Clover developer app** owned by Nexa Systems Inc.
|
||||||
|
This is achieved with a stateless Cloudflare Worker that fans out
|
||||||
|
the OAuth callback AND inbound webhooks.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
| Component | Where | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| Clover Dev App "Fusion Clover Connector" | Nexa's Clover Global Developer Dashboard, App ID `2965A1TH3KG32`, RAID `B2EQP7PKGPYY8.2965A1TH3KG32` | Owns App ID + Secret + RAID. Site URL points at the dispatcher (NOT any one customer) |
|
||||||
|
| `nexa-clover-oauth-dispatcher` | Cloudflare Worker on `oauth.nexasystems.ca/*`, Account `6641e0c28475e4e9ddd32875f61da72e`, zone `067f715006cf8cca09d786513c38affa` | OAuth + webhook + launch fan-out. Stateless. Source at `K:/Github/nexa-oauth-dispatcher/` |
|
||||||
|
| `DISPATCHER_SECRET` | Cloudflare Workers Secret + every customer Odoo's `ir.config_parameter` `fusion_clover.dispatcher_secret` | HMAC-SHA256 key. The trust anchor of the whole flow |
|
||||||
|
| `ALLOWED_REDIRECT_HOSTS` | Worker env var in `wrangler.jsonc` | Belt-and-braces allow-list of customer hostnames the Worker is willing to redirect to |
|
||||||
|
| `MERCHANT_ROUTING_JSON` | Worker env var | `{merchantId: customerWebhookUrl}` map for webhook fan-out |
|
||||||
|
| `MERCHANT_ODOO_BASE_JSON` | Worker env var | `{merchantId: customerOdooBaseUrl}` for `/clover/launch` to build per-customer signed states |
|
||||||
|
| `CLOVER_APP_ID` | Worker env var | Used by `/clover/launch` to build `/oauth/v2/authorize` URL |
|
||||||
|
|
||||||
|
## Worker endpoints
|
||||||
|
|
||||||
|
| Endpoint | Method | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `/clover/callback` | GET | OAuth fan-out: HMAC-verify `state`, 302 to customer's Odoo `/payment/clover/oauth/callback` |
|
||||||
|
| `/clover/webhook` | POST | Webhook fan-out: route by `merchantId` from body (firehose or single-event format) |
|
||||||
|
| `/clover/webhook` | GET | 200 OK for Clover liveness pings |
|
||||||
|
| `/clover/launch` | GET | **Alternate Launch Path**: when Clover hits this with `?merchant_id=X`, build a fresh signed state and 302 to `/oauth/v2/authorize` |
|
||||||
|
| `/healthz` | GET | Liveness probe |
|
||||||
|
|
||||||
|
## Onboarding a new customer Odoo (~5 min, no Clover dashboard touch)
|
||||||
|
|
||||||
|
1. Add hostname to `ALLOWED_REDIRECT_HOSTS` AND merchant to
|
||||||
|
`MERCHANT_ROUTING_JSON` + `MERCHANT_ODOO_BASE_JSON` in
|
||||||
|
`wrangler.jsonc`, then `cd K:/Github/nexa-oauth-dispatcher && npm run deploy`.
|
||||||
|
2. On the customer's Odoo, set:
|
||||||
|
```sql
|
||||||
|
INSERT INTO ir_config_parameter (key, value, create_uid, create_date, write_uid, write_date)
|
||||||
|
VALUES
|
||||||
|
('fusion_clover.dispatcher_secret', '<same value as Cloudflare Worker secret>', 1, NOW(), 1, NOW()),
|
||||||
|
('fusion_clover.dispatcher_url', 'https://oauth.nexasystems.ca/clover/callback', 1, NOW(), 1, NOW())
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
|
||||||
|
```
|
||||||
|
3. On the customer's Clover payment provider record, paste:
|
||||||
|
- **App ID** (Nexa's `clover_app_id`)
|
||||||
|
- **App Secret** (Nexa's `clover_app_secret`)
|
||||||
|
- **Remote App ID** (Nexa's `clover_remote_app_id`)
|
||||||
|
4. Click **Connect to Clover**. Merchant logs in to Clover, authorises,
|
||||||
|
token gets stored in `clover_oauth_access_token`.
|
||||||
|
|
||||||
|
No Clover dev-dashboard changes are ever required for new customers.
|
||||||
|
|
||||||
|
## Signed state format
|
||||||
|
|
||||||
|
```
|
||||||
|
<base64url(payload_json)>.<base64url(hmac_sha256(secret, payload_b64u))>
|
||||||
|
|
||||||
|
payload_json = {
|
||||||
|
"redirect_to": "https://erp.<customer>.ca/payment/clover/oauth/callback",
|
||||||
|
"nonce": "<32 hex chars>",
|
||||||
|
"iat": <unix seconds>,
|
||||||
|
"customer": "<optional human label>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
State expires after 1 hour (`iat` check). The Worker verifies HMAC
|
||||||
|
with constant-time comparison, checks `iat` freshness, validates
|
||||||
|
`redirect_to` is an allow-listed host, and 302-redirects with all
|
||||||
|
original Clover query params forwarded. The customer's Odoo callback
|
||||||
|
(`clover_oauth_callback`) re-verifies the HMAC as defence in depth
|
||||||
|
before exchanging the OAuth code for a token.
|
||||||
|
|
||||||
|
## Webhook fan-out
|
||||||
|
|
||||||
|
The Worker reads `merchantId` from either:
|
||||||
|
- `body.merchants.<mId>` (multi-merchant firehose format, e.g. payment object change events)
|
||||||
|
- `body.merchantId` (Hosted Checkout / Ecommerce single-event format)
|
||||||
|
|
||||||
|
It looks up the routing in `MERCHANT_ROUTING_JSON` and forwards the
|
||||||
|
**raw POST body verbatim** with the original `X-Clover-Auth-Code`
|
||||||
|
header preserved. The customer Odoo re-verifies the HMAC signature
|
||||||
|
against its own copy of the App Secret. **The Worker is a dumb pipe
|
||||||
|
for webhooks — it does NOT need to know the App Secret.**
|
||||||
|
|
||||||
|
## Webhook verification challenge
|
||||||
|
|
||||||
|
When you click "Send Verification Code" in the Clover dev dashboard,
|
||||||
|
Clover POSTs `{"verificationCode": "<uuid>"}` to the webhook URL. You
|
||||||
|
must paste this code back into Clover's UI to activate the webhook.
|
||||||
|
|
||||||
|
The verification code is logged in TWO places:
|
||||||
|
1. `wrangler tail nexa-clover-oauth-dispatcher` — Worker logs it
|
||||||
|
prominently to the Cloudflare console with a banner.
|
||||||
|
2. `docker logs odoo-dev-app | grep -i 'VERIFICATION CODE'` — the
|
||||||
|
Worker also forwards the verification POST to all routed Odoos so
|
||||||
|
the `controllers/main.py::clover_webhook` handler can log it too
|
||||||
|
(handler at `WARNING` level so it's hard to miss).
|
||||||
|
|
||||||
|
Either source works — pick whichever you have terminal access to.
|
||||||
|
|
||||||
|
## Auth precedence on outgoing Clover API calls
|
||||||
|
|
||||||
|
`_clover_get_platform_token()` picks in order:
|
||||||
|
1. `clover_oauth_access_token` (preferred — refreshable, app-scoped, works for Platform API + REST Pay Display + Ecommerce)
|
||||||
|
2. `clover_rest_api_token` (legacy single-merchant fallback from Clover Dashboard > Setup > API Tokens)
|
||||||
|
3. `clover_api_key` (Ecommerce private token — works only for Platform GET `/v3/merchants/{mId}`, will 401 on REST Pay Display)
|
||||||
|
|
||||||
|
`X-POS-Id` on REST Pay Display calls is `clover_remote_app_id` (RAID),
|
||||||
|
falling back to the static string `'FusionCloverOdoo'` for sandbox/dev.
|
||||||
|
|
||||||
|
`_clover_make_ecom_request` also routes through
|
||||||
|
`_clover_get_platform_token()` so the same precedence + auto-refresh
|
||||||
|
applies to Ecommerce API calls.
|
||||||
|
|
||||||
|
## OAuth token lifecycle
|
||||||
|
|
||||||
|
Three layers of token freshness protection, all in
|
||||||
|
`models/payment_provider.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Layer 1 PROACTIVE: refresh if within 60s of expiry
|
||||||
|
def _clover_get_platform_token(self):
|
||||||
|
if self.clover_oauth_access_token:
|
||||||
|
self._clover_refresh_oauth_if_needed()
|
||||||
|
return self.clover_oauth_access_token
|
||||||
|
|
||||||
|
# Layer 2 REACTIVE: retry once on 401 with fresh token
|
||||||
|
def _clover_make_*_request(self, ..., _retry=True):
|
||||||
|
...
|
||||||
|
if response.status_code == 401 and _retry and self.clover_oauth_refresh_token:
|
||||||
|
if self._clover_refresh_oauth_token():
|
||||||
|
return self._clover_make_*_request(..., _retry=False)
|
||||||
|
# _retry=False on the recursive call PREVENTS infinite loops
|
||||||
|
# if the refreshed token also returns 401.
|
||||||
|
|
||||||
|
# Layer 3 ROTATION-SAFE: always store both new tokens together
|
||||||
|
def _clover_refresh_oauth_token(self):
|
||||||
|
vals = {'clover_oauth_access_token': new_token}
|
||||||
|
new_refresh = data.get('refresh_token', '')
|
||||||
|
if new_refresh:
|
||||||
|
vals['clover_oauth_refresh_token'] = new_refresh # CRITICAL
|
||||||
|
```
|
||||||
|
|
||||||
|
**Critical**: Clover sandbox uses **single-use rotating refresh tokens**.
|
||||||
|
Every refresh response includes a NEW refresh_token; the old one is
|
||||||
|
**immediately invalidated**. If you forget to store the new one, the
|
||||||
|
NEXT refresh attempt will fail with `401 Invalid refresh token` and
|
||||||
|
the merchant's connection is dead until they re-OAuth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Clover sandbox quirks (learned the hard way)
|
||||||
|
|
||||||
|
These tripped us up during the sandbox build-out. Documenting so
|
||||||
|
future deploys don't waste hours on them.
|
||||||
|
|
||||||
|
## OAuth + dev app
|
||||||
|
|
||||||
|
1. **OAuth v2 authorize lives on `sandbox.dev.clover.com`**, NOT
|
||||||
|
`apisandbox.dev.clover.com`. The latter is API-only with no login UI.
|
||||||
|
Wrong host → blank login page that rejects every password.
|
||||||
|
2. **Path is `/oauth/v2/authorize`** — Clover deprecated `/oauth/authorize`
|
||||||
|
in October 2023. Old path still resolves but generates v1-only codes
|
||||||
|
that fail at v2 token endpoint with `Failed to validate authentication
|
||||||
|
code` 401.
|
||||||
|
3. **Token endpoint is on `apisandbox.dev.clover.com`** (not the same
|
||||||
|
host as authorize) — Clover splits authorize (UI) from token (API).
|
||||||
|
4. **Refresh tokens are single-use** — see "OAuth token lifecycle" above.
|
||||||
|
5. **Sandbox access_token lifetime is ~30 minutes** — production is
|
||||||
|
typically longer. Auto-refresh is essential for any long-running
|
||||||
|
process.
|
||||||
|
6. **Test merchant accounts have NO password by default** — you have to
|
||||||
|
set one via Test Merchant Dashboard → Profile → Edit your profile,
|
||||||
|
OR reset via dev dashboard. Until you do, OAuth login fails even
|
||||||
|
with the dev account password.
|
||||||
|
|
||||||
|
## App configuration
|
||||||
|
|
||||||
|
7. **Alternate Launch Path is REQUIRED** for App Market Connect to work.
|
||||||
|
Without it, Clover's "Connect" button uses the legacy partial OAuth
|
||||||
|
flow which generates v1-style codes that fail at /v2/token. Set it
|
||||||
|
to `/clover/launch` (path only — Clover prepends the Site URL host).
|
||||||
|
8. **Pricing & Distribution must be configured before any test merchant
|
||||||
|
can install** — even draft apps. Symptom: hung page after merchant
|
||||||
|
selection, browser console shows 404 on `/v3/merchants/{mid}/apps/{appId}?expand=...availableSubscriptions,billing`.
|
||||||
|
Fix: set price to Free (or whatever) before testing.
|
||||||
|
9. **Modules Availability "Register Pre-Auth" + "Orders" require Register
|
||||||
|
service plan** — default test merchants don't have it, install fails
|
||||||
|
with "This app is not available with your service plan". Drop those
|
||||||
|
modules (we don't actually use them — terminal pre-auth uses REST
|
||||||
|
Pay Display, not Register).
|
||||||
|
10. **App Market Connect** uses the legacy "partial OAuth flow" if no
|
||||||
|
Alternate Launch Path is set — bypasses `/oauth/v2/authorize`,
|
||||||
|
generates v1 codes, fails. With Alternate Launch Path set, Clover
|
||||||
|
bounces through it → properly initiates v2 flow.
|
||||||
|
|
||||||
|
## Ecommerce API
|
||||||
|
|
||||||
|
11. **Tokenization endpoint header is `apikey`** (lowercase), NOT
|
||||||
|
`apiAccessKey`. The PAKMS endpoint *returns* a field called
|
||||||
|
`apiAccessKey` but the tokenize endpoint *requires* a header called
|
||||||
|
`apikey`. Wrong header → 401.
|
||||||
|
12. **PAKMS endpoint is on the Ecommerce host** (`scl-sandbox.dev.clover.com/pakms/apikey`),
|
||||||
|
NOT the Platform host (`apisandbox.dev.clover.com/pakms/apikey` returns 404).
|
||||||
|
13. **Tokenize requires real brand value** — `VISA`, `MC`, `AMEX`,
|
||||||
|
`DISCOVER`, `DINERS`, `JCB`. Don't pass `CARD` — Clover rejects.
|
||||||
|
See `_clover_detect_brand_from_pan()` in `payment_provider.py`.
|
||||||
|
14. **`/v1/refunds` accepts ONLY `{"charge": "..."}`** — adding `amount`
|
||||||
|
or `reason` triggers `Invalid JSON format` 400. It is **full-refund
|
||||||
|
only**. For partials use `/v1/payments/{paymentId}/refunds` with
|
||||||
|
`{"amount": cents}` or `{"fullRefund": true}`. See
|
||||||
|
`utils.build_payment_refund_payload()`.
|
||||||
|
15. **Past `exp_year` accepted in sandbox** (e.g. `1970`) — production
|
||||||
|
will reject. Don't rely on the tokenize endpoint to validate this.
|
||||||
|
16. **Per-card velocity limit in sandbox** — after ~6-10 charges on the
|
||||||
|
same test card, sandbox declines with `"Declined as sale count per
|
||||||
|
card is greater than configured amount"`. Production has higher
|
||||||
|
limits. Workaround: use a different test card or different test
|
||||||
|
merchant.
|
||||||
|
|
||||||
|
## Webhook configuration
|
||||||
|
|
||||||
|
17. **Webhook events: PAYMENTS + APP are sufficient** for our use case.
|
||||||
|
Skip Customers / Inventory / Merchants / Cash / Employees — Odoo is
|
||||||
|
source of truth for those.
|
||||||
|
18. **Webhook payload format is the merchant-firehose, NOT Hosted
|
||||||
|
Checkout style** — we get `{"appId": "...", "merchants": {"<mid>":
|
||||||
|
[{"objectId": "P:<mid>/<id>", "type": "CREATE", "ts": ...}]}}`,
|
||||||
|
NOT `{"type": "charge.succeeded", "data": {...}}`. Object IDs are
|
||||||
|
prefixed: `P:` payment, `O:` order, `C:` customer, etc. The handler
|
||||||
|
at `controllers/main.py::_dispatch_clover_webhook` accepts both shapes.
|
||||||
|
19. **Verification challenge is one-shot, no signature** — Clover POSTs
|
||||||
|
`{"verificationCode": "<uuid>"}` once. The Odoo handler
|
||||||
|
short-circuits HMAC verification for this specific body shape.
|
||||||
|
|
||||||
|
## REST Pay Display / Terminal
|
||||||
|
|
||||||
|
20. **`X-POS-Id` header MUST be the Remote App ID (RAID)** in production —
|
||||||
|
not a free-form string. RAID is generated when App Type is set to
|
||||||
|
`Web` → "Is this an integration of an existing POS = Yes".
|
||||||
|
21. **Cloud Pay Display app must be installed AND running on the
|
||||||
|
Clover device** before any `/connect/v1/payments` call works. The
|
||||||
|
merchant has to manually start it after install.
|
||||||
|
22. **REST Pay Display only works with OAuth tokens** — Ecommerce
|
||||||
|
private tokens 401. Documented as: *"Integrators must use OAuth
|
||||||
|
tokens to connect to the Clover device using this app."*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Version history (2026-04-28 / 29 build session)
|
||||||
|
|
||||||
|
| Version | Critical change |
|
||||||
|
|---|---|
|
||||||
|
| `19.0.1.0.0` | Inherited buggy state — broken portal flow, no OAuth, missing icon, raw PAN to Clover |
|
||||||
|
| `19.0.1.1.0` | Initial review-pass: removed broken logo ref, rewrote settings view to MANDATORY layout, **integrated Clover.js iframe SDK** for portal tokenization (PCI-compliant), added HMAC webhook verification, added server-side tokenization for back-office wizard |
|
||||||
|
| `19.0.1.2.0` | Multi-tenant OAuth fields + dispatcher integration: `clover_remote_app_id`, `clover_oauth_access_token`, `clover_oauth_refresh_token`, `clover_oauth_token_expiry` + `action_clover_oauth_connect` button + auth-precedence helper |
|
||||||
|
| `19.0.1.3.0` | Webhook verification challenge handling + dual-format dispatch (legacy `{type, data}` AND new firehose `{merchants}` format) |
|
||||||
|
| `19.0.1.4.0` | Ecommerce API also uses OAuth token (was hardcoded to ecom private key) |
|
||||||
|
| `19.0.1.5.0` | **Fixed wrong OAuth URLs** — was using legacy `/oauth/authorize` on wrong host. Switched to v2 endpoints (`sandbox.dev.clover.com/oauth/v2/authorize`, `apisandbox.dev.clover.com/oauth/v2/token`) per Clover's Oct-2023 mandate |
|
||||||
|
| `19.0.1.6.0` | OAuth token-exchange v1 fallback for legacy partial-flow codes |
|
||||||
|
| `19.0.1.7.0` | Tokenization fixes: `apikey` header (was `apiAccessKey`) + brand auto-detection from PAN BIN |
|
||||||
|
| `19.0.1.8.0` | **Fixed `/v1/refunds` payload** — Clover rejects with 400 on any field other than `{charge}`. Added separate `build_payment_refund_payload` for partials via `/v1/payments/{id}/refunds` |
|
||||||
|
| `19.0.1.9.0` | OAuth auto-refresh: proactive (within 60s of expiry) + reactive (on 401, retry once with `_retry=False` to prevent infinite loops) on Platform/Ecom/Terminal request methods |
|
||||||
|
| **`19.0.1.10.0`** | `_clover_make_ecom_request` now properly routes through `_clover_get_platform_token()` so Ecommerce calls also benefit from auto-refresh |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Battle test results (2026-04-29)
|
||||||
|
|
||||||
|
End-to-end validation against sandbox Test Merc 2 (`ASVPRFJ5D5GF1`):
|
||||||
|
|
||||||
|
## Payment lifecycle PROVEN
|
||||||
|
|
||||||
|
| Operation | Endpoint | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| OAuth v2 handshake | `/oauth/v2/authorize` → `/oauth/v2/token` | ✅ |
|
||||||
|
| PAKMS fetch via OAuth | `/pakms/apikey` | ✅ |
|
||||||
|
| Server-side tokenization | `/v1/tokens` | ✅ |
|
||||||
|
| Charge creation | `POST /v1/charges` | ✅ Charge `BSEQZTNJKQGY6` $1.00 succeeded |
|
||||||
|
| Full refund | `POST /v1/refunds` | ✅ Refund `N97G2QE705Q4T` $2.00 succeeded |
|
||||||
|
| Platform API auth | `/v3/merchants/{mId}` | ✅ Returned "Test Merc 2" |
|
||||||
|
| OAuth refresh | `/oauth/v2/refresh` | ✅ New JWT issued and authenticated |
|
||||||
|
|
||||||
|
## Failure-mode handling PROVEN
|
||||||
|
|
||||||
|
| Test | Result |
|
||||||
|
|---|---|
|
||||||
|
| Visa decline `4264281511117771` | ✅ 402 with `DECLINED` raised cleanly |
|
||||||
|
| Mastercard decline `5424180273333333` | ✅ 402 raised |
|
||||||
|
| Bad-Luhn card `4242424242424241` | ✅ 400 at tokenize: "Please provide valid card number" |
|
||||||
|
| Invalid month=13 | ✅ 400 "Please provide valid expiry month" |
|
||||||
|
| 2-digit CVV | ✅ 400 "Please provide valid cvv value" |
|
||||||
|
| **Idempotency: same key 2x** | ✅ Same charge_id returned, NO double-charge |
|
||||||
|
| **Forged HMAC OAuth state** | ✅ 403 from dispatcher in 1.5ms |
|
||||||
|
| **Disallowed redirect host** | ✅ 403 from dispatcher allow-list |
|
||||||
|
|
||||||
|
## Hammer test results (Cloudflare Worker)
|
||||||
|
|
||||||
|
| Stress scenario | Result | Throughput / latency |
|
||||||
|
|---|---|---|
|
||||||
|
| 100 concurrent webhooks | ✅ | 347 req/s, 100/100 OK, 0 drops |
|
||||||
|
| 9.4MB JSON body (DoS attempt) | ✅ | HTTP 200 in 1.7s, no OOM |
|
||||||
|
| 50 forged HMACs in parallel | ✅ | 50/50 → 403 in 77ms (~650 verifications/sec) |
|
||||||
|
| 1001 merchantIds in single payload | ✅ | 47ms (O(n) routing scales fine) |
|
||||||
|
| 200 concurrent /healthz | ✅ | 500 req/s, 200/200 OK |
|
||||||
|
| 50 webhooks fan-out → Odoo | ✅ | All forwarded; **Odoo `/web/login` stayed at 116ms during flood** |
|
||||||
|
| Odoo HMAC verifier under flood | ✅ | All 50 unsigned webhooks rejected with 403 (correct security behaviour, fast rejection) |
|
||||||
|
| **Recursive 401 prevention** | ✅ | Failed cleanly in **0.77 seconds** when both tokens corrupt — no infinite loop |
|
||||||
|
|
||||||
|
The recursive-401 test is the most important — without `_retry=False`
|
||||||
|
on the inner call, a corrupt-credentials scenario would loop forever
|
||||||
|
hammering Clover's API and getting our IP rate-limited. 0.77s vs
|
||||||
|
hours-of-API-spam.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Westin Healthcare deployment
|
||||||
|
|
||||||
|
## Hosts (don't confuse them)
|
||||||
|
|
||||||
|
| Host | What | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `192.168.1.152` (alias `westin@`) | WordPress site `westinhealthcare.ca` | NOT the ERP. Don't deploy here. |
|
||||||
|
| `192.168.1.40` (alias `odoo-westin`, vmid 101) | **PRODUCTION Odoo 19 ERP** for Westin | DB `westin-v19`, custom addons at `/opt/odoo/custom-addons/`, container `odoo-dev-app` (misleadingly named) |
|
||||||
|
|
||||||
|
**The `odoo-dev-app` container ON `odoo-westin` IS PRODUCTION.** Per
|
||||||
|
`../.cursor/rules/environment-safety.mdc`:
|
||||||
|
|
||||||
|
> ssh alias `odoo-westin` (192.168.1.40, erp.westinhealthcare.ca) is
|
||||||
|
> PRODUCTION. `docker exec odoo-dev-app ...` via this ssh alias touches
|
||||||
|
> PRODUCTION despite the "-dev" in the container name.
|
||||||
|
|
||||||
|
## Backup pattern (used between every deploy)
|
||||||
|
|
||||||
|
Backups live OUTSIDE `/opt/odoo/custom-addons/` because Odoo refuses
|
||||||
|
module folder names with dots — putting them inside causes
|
||||||
|
`FileNotFoundError: Invalid module name: fusion_clover.bak.YYYYMMDD-HHMMSS`
|
||||||
|
on next startup.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh odoo-westin '
|
||||||
|
cd /opt/odoo/custom-addons
|
||||||
|
cp -a fusion_clover /opt/odoo/backups/addons-bak/fusion_clover.bak.$(date +%Y%m%d-%H%M%S)
|
||||||
|
rm -rf fusion_clover
|
||||||
|
# ...rsync new code...
|
||||||
|
'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production deploy (after sandbox sign-off — same pattern as fusion_poynt)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Tar + scp (rsync not available on Windows powershell)
|
||||||
|
cd k:/Github/Odoo-Modules
|
||||||
|
tar --exclude='__pycache__' --exclude='graphify-out' --exclude='CLAUDE.md' \
|
||||||
|
--exclude='.git' --exclude='agent-tools' \
|
||||||
|
-czf /tmp/fusion_clover.tgz fusion_clover
|
||||||
|
scp /tmp/fusion_clover.tgz odoo-westin:/tmp/fc.tgz
|
||||||
|
|
||||||
|
# 2. Backup, replace, upgrade
|
||||||
|
ssh odoo-westin '
|
||||||
|
set -e
|
||||||
|
cd /opt/odoo/custom-addons
|
||||||
|
cp -a fusion_clover /opt/odoo/backups/addons-bak/fusion_clover.bak.$(date +%Y%m%d-%H%M%S)
|
||||||
|
rm -rf fusion_clover
|
||||||
|
mkdir -p /tmp/fc_extract && tar -xzf /tmp/fc.tgz -C /tmp/fc_extract
|
||||||
|
mv /tmp/fc_extract/fusion_clover /opt/odoo/custom-addons/fusion_clover
|
||||||
|
rm -rf /tmp/fc_extract
|
||||||
|
cd /opt/odoo
|
||||||
|
docker compose stop odoo
|
||||||
|
docker compose run --rm --no-deps odoo \
|
||||||
|
-c /etc/odoo/odoo.conf -d westin-v19 -u fusion_clover \
|
||||||
|
--stop-after-init --no-http
|
||||||
|
docker compose up -d odoo
|
||||||
|
'
|
||||||
|
|
||||||
|
# 3. Smoke test
|
||||||
|
ssh odoo-westin 'curl -s -o /dev/null -w "HTTP %{http_code}\n" http://localhost:8069/web/login'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production go-live checklist (when ready to flip from sandbox to prod)
|
||||||
|
|
||||||
|
1. **In Clover dev dashboard**: Pricing & Distribution → Submit for
|
||||||
|
Production → wait for Clover approval (1-3 business days first
|
||||||
|
time). Get production App ID, App Secret, Remote App ID. **Note:
|
||||||
|
production has SEPARATE App ID/Secret/RAID values from sandbox.**
|
||||||
|
2. **In Cloudflare Worker `wrangler.jsonc`**: update `CLOVER_APP_ID`
|
||||||
|
to the production App ID. The `MERCHANT_ROUTING_JSON` and
|
||||||
|
`MERCHANT_ODOO_BASE_JSON` should map Westin's REAL merchant ID
|
||||||
|
(`E2DYXYRBT52K1`) to `https://erp.westinhealthcare.ca/payment/clover/webhook`.
|
||||||
|
3. **In Cloudflare Worker secret**: rotate `DISPATCHER_SECRET` for
|
||||||
|
production launch (`npx wrangler secret put DISPATCHER_SECRET`).
|
||||||
|
Also update Westin's `ir.config_parameter` `fusion_clover.dispatcher_secret`
|
||||||
|
to match.
|
||||||
|
4. **In Westin Odoo** (`payment_provider` row 36):
|
||||||
|
- `clover_app_id` → production App ID
|
||||||
|
- `clover_app_secret` → production App Secret
|
||||||
|
- `clover_remote_app_id` → production RAID
|
||||||
|
- `clover_merchant_id` → `E2DYXYRBT52K1` (Westin's real merchant)
|
||||||
|
- `state` → `enabled` (NOT `test`)
|
||||||
|
- Clear all `clover_oauth_*` fields (force re-OAuth)
|
||||||
|
5. **On Westin's Clover account**: install the Nexa app from the
|
||||||
|
Clover App Market (production now-public version). Authorize.
|
||||||
|
6. **In Westin Odoo**: click **Connect to Clover** — runs production
|
||||||
|
OAuth, stores production access_token + refresh_token.
|
||||||
|
7. **Click Test Connection**: should return "Test Connection
|
||||||
|
successful. Merchant: WESTIN HEALTHCARE".
|
||||||
|
8. **Click Sync Terminals**: pulls Westin's real Clover terminals.
|
||||||
|
9. **Set Default Terminal** under Terminal Settings.
|
||||||
|
10. **Configure surcharge rates** in Settings → Fusion Clover (match
|
||||||
|
fusion_poynt rates so UX is consistent regardless of which
|
||||||
|
processor a clerk picks).
|
||||||
|
11. **Set webhook URL on Clover dashboard**: same dispatcher URL
|
||||||
|
`https://oauth.nexasystems.ca/clover/webhook`. Run verification
|
||||||
|
again (codes appear in `wrangler tail` and Odoo docker logs).
|
||||||
|
12. **Smoke test**: create a small test invoice, click Collect Clover
|
||||||
|
Payment, complete with a real card under $5, refund it. Verify
|
||||||
|
receipt email + PDF.
|
||||||
|
13. **Watch logs for the first few real transactions**:
|
||||||
|
`wrangler tail nexa-clover-oauth-dispatcher` and
|
||||||
|
`docker logs -f odoo-dev-app | grep -i clover`.
|
||||||
|
|
||||||
|
## Coexistence with fusion_poynt
|
||||||
|
|
||||||
|
Both modules can be installed at the same time. They use disjoint
|
||||||
|
prefixes (`clover_*` vs `poynt_*`) and disjoint API URLs. The
|
||||||
|
`account.move` Collect Payment buttons sit side by side. The two
|
||||||
|
surcharge configs are independent — set them to the same rates so end
|
||||||
|
users see consistent fees regardless of which processor the back-office
|
||||||
|
picks for a given invoice.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Key Odoo 19 conventions enforced here
|
||||||
|
|
||||||
|
- `type="jsonrpc"` (not deprecated `type="json"`) — see all routes in `controllers/main.py`
|
||||||
|
- `Interaction` patch via `patch(PaymentForm.prototype, ...)` instead of IIFE / DOMContentLoaded
|
||||||
|
- `models.Constraint('UNIQUE(serial_number, provider_id)', ...)` on `clover.terminal` instead of legacy `_sql_constraints`
|
||||||
|
- `res.groups` use `privilege_id` (no `category_id`, no `users` field)
|
||||||
|
- Settings view uses MANDATORY `<block>` / `<setting id=…>` / `col-lg-5 o_light_label` layout
|
||||||
|
- Currency: `Monetary` fields with `currency_field=` not bare floats
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Outstanding TODOs
|
||||||
|
|
||||||
|
1. **Fix `datetime.utcfromtimestamp` deprecation warning** (Python 3.12
|
||||||
|
removes it). Use `datetime.fromtimestamp(ts, tz=datetime.UTC)` in
|
||||||
|
`_clover_exchange_oauth_code` and `_clover_refresh_oauth_token`.
|
||||||
|
2. **Wire partial refund support** through the wizard UI — backend
|
||||||
|
helper `build_payment_refund_payload` exists but isn't called from
|
||||||
|
the wizard yet. The wizard currently always does full refunds.
|
||||||
|
3. **Add automated tests** — `tests/` folder is empty. Battle test
|
||||||
|
script lives at `C:\Users\gur_p\AppData\Local\Temp\battle_test.py`
|
||||||
|
and `hammer_odoo.py` — should be cleaned up and committed as
|
||||||
|
actual `tests/test_*.py` files.
|
||||||
|
4. **Add OAuth disconnect button** to provider form so admins can
|
||||||
|
force-clear tokens without going to the DB. Useful when a customer
|
||||||
|
wants to revoke and re-authorize.
|
||||||
|
5. **Static module icon** at `static/description/icon.png` (currently
|
||||||
|
shows blank in module browser).
|
||||||
|
6. **Webhook event handlers for firehose format** — `_dispatch_clover_webhook`
|
||||||
|
currently logs `P:`-prefixed payment events but doesn't fetch the
|
||||||
|
payment object from Platform API to update the matching
|
||||||
|
`payment.transaction`. Wire this when actual reconciliation gaps
|
||||||
|
surface in production.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Workflow / commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Local dev (CONFIRM THE LOCAL VM HOST FIRST — odoo-dev-app on
|
||||||
|
# odoo-westin is PRODUCTION)
|
||||||
|
docker exec <local-orbstack-container> odoo -d fusion-dev \
|
||||||
|
-u fusion_clover --stop-after-init
|
||||||
|
|
||||||
|
# Watch live OAuth + webhook flow on production
|
||||||
|
cd K:/Github/nexa-oauth-dispatcher
|
||||||
|
$env:CLOUDFLARE_API_KEY = "<key from fusionapps.ai_memory>"
|
||||||
|
$env:CLOUDFLARE_EMAIL = "gsingh@westinhealthcare.com"
|
||||||
|
npx wrangler tail nexa-clover-oauth-dispatcher --format pretty
|
||||||
|
|
||||||
|
# Generate fresh OAuth URL (1-hour TTL) for a manual login test
|
||||||
|
ssh odoo-westin "echo \"
|
||||||
|
provider = env['payment.provider'].sudo().search([('code', '=', 'clover')], limit=1)
|
||||||
|
print(provider.action_clover_oauth_connect()['url'])
|
||||||
|
\" | docker exec -i odoo-dev-app odoo shell -c /etc/odoo/odoo.conf -d westin-v19 --no-http 2>&1 | grep https"
|
||||||
|
|
||||||
|
# Force-refresh a stale OAuth token via shell
|
||||||
|
ssh odoo-westin "echo \"
|
||||||
|
provider = env['payment.provider'].sudo().search([('code', '=', 'clover')], limit=1)
|
||||||
|
ok = provider._clover_refresh_oauth_token()
|
||||||
|
print('refreshed:', ok, 'new expiry:', provider.clover_oauth_token_expiry)
|
||||||
|
env.cr.commit()
|
||||||
|
\" | docker exec -i odoo-dev-app odoo shell -c /etc/odoo/odoo.conf -d westin-v19 --no-http"
|
||||||
|
|
||||||
|
# Production deploy — see "Westin Healthcare deployment > Production deploy"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# References
|
||||||
|
|
||||||
|
- Workspace conventions: `../CLAUDE.md`
|
||||||
|
- Environment safety rule: `../.cursor/rules/environment-safety.mdc`
|
||||||
|
- Sister implementation: `../fusion_poynt/`
|
||||||
|
- Worker source: `K:/Github/nexa-oauth-dispatcher/`
|
||||||
|
- Production deploy log (fusion_poynt): Supabase
|
||||||
|
`fusionapps.work_sessions` "Deployed fusion_poynt to production
|
||||||
|
odoo-westin" (2026-02-24)
|
||||||
|
- Clover Authenticate v2 OAuth: https://docs.clover.com/docs/use-oauth
|
||||||
|
- Clover High-trust app auth flow: https://docs.clover.com/dev/docs/high-trust-app-auth-flow
|
||||||
|
- Clover Ecommerce API: https://docs.clover.com/reference/ecommerce-api
|
||||||
|
- Clover Hosted iframe (Web SDK): https://docs.clover.com/docs/using-the-clover-hosted-iframe
|
||||||
|
- Clover REST Pay Display Cloud: https://docs.clover.com/docs/rest-pay-overview
|
||||||
|
- Clover Test card numbers: https://docs.clover.com/dev/docs/test-card-numbers
|
||||||
|
- Clover Refund payments: https://docs.clover.com/dev/docs/ecommerce-refunding-payments
|
||||||
@@ -2,11 +2,23 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Payment Provider: Clover',
|
'name': 'Payment Provider: Clover',
|
||||||
'version': '19.0.1.0.0',
|
'version': '19.0.1.12.0',
|
||||||
'category': 'Accounting/Payment Providers',
|
'category': 'Accounting/Payment Providers',
|
||||||
'sequence': 365,
|
'sequence': 365,
|
||||||
'summary': "Clover payment processing for ecommerce, terminal, and manual card payments.",
|
'summary': "Clover payment processing for ecommerce, terminal, and manual card payments.",
|
||||||
'description': " ",
|
'description': """
|
||||||
|
Clover payment provider for Odoo 19.
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
- Online card payments via Clover.js iframe (PCI-compliant, customer
|
||||||
|
card never touches Odoo)
|
||||||
|
- In-person terminal payments via Cloud REST Pay Display
|
||||||
|
- Back-office manual card collection (server-side tokenization)
|
||||||
|
- Referenced + non-referenced refunds, voids, and credits
|
||||||
|
- Multi-card surcharge (Visa/Mastercard/Amex/Debit/Other)
|
||||||
|
- Webhook signature verification (HMAC-SHA256)
|
||||||
|
- Payment receipt PDF generation and email delivery
|
||||||
|
""",
|
||||||
'depends': ['payment', 'account_payment', 'sale'],
|
'depends': ['payment', 'account_payment', 'sale'],
|
||||||
'data': [
|
'data': [
|
||||||
'security/security.xml',
|
'security/security.xml',
|
||||||
|
|||||||
@@ -17,11 +17,18 @@ TOKEN_BASE_URL_TEST = 'https://token-sandbox.dev.clover.com'
|
|||||||
CONNECT_BASE_URL = 'https://api.clover.com/connect/v1'
|
CONNECT_BASE_URL = 'https://api.clover.com/connect/v1'
|
||||||
CONNECT_BASE_URL_TEST = 'https://apisandbox.dev.clover.com/connect/v1'
|
CONNECT_BASE_URL_TEST = 'https://apisandbox.dev.clover.com/connect/v1'
|
||||||
|
|
||||||
# OAuth URLs
|
# OAuth v2 URLs (Clover mandated v2 from October 2023; legacy /oauth/authorize
|
||||||
OAUTH_AUTHORIZE_URL_TEST = 'https://apisandbox.dev.clover.com/oauth/authorize'
|
# is deprecated). Note that the AUTHORIZE endpoint is on the merchant/dev
|
||||||
OAUTH_AUTHORIZE_URL = 'https://api.clover.com/oauth/authorize'
|
# portal host (sandbox.dev.clover.com / www.clover.com) — NOT on the API
|
||||||
OAUTH_TOKEN_URL_TEST = 'https://apisandbox.dev.clover.com/oauth/token'
|
# host — because it has to render a merchant-facing login + consent UI.
|
||||||
OAUTH_TOKEN_URL = 'https://api.clover.com/oauth/token'
|
# The TOKEN/REFRESH endpoints are on the API host.
|
||||||
|
# https://docs.clover.com/docs/use-oauth#sandbox-and-production-environment-urls
|
||||||
|
OAUTH_AUTHORIZE_URL_TEST = 'https://sandbox.dev.clover.com/oauth/v2/authorize'
|
||||||
|
OAUTH_AUTHORIZE_URL = 'https://www.clover.com/oauth/v2/authorize'
|
||||||
|
OAUTH_TOKEN_URL_TEST = 'https://apisandbox.dev.clover.com/oauth/v2/token'
|
||||||
|
OAUTH_TOKEN_URL = 'https://api.clover.com/oauth/v2/token'
|
||||||
|
OAUTH_REFRESH_URL_TEST = 'https://apisandbox.dev.clover.com/oauth/v2/refresh'
|
||||||
|
OAUTH_REFRESH_URL = 'https://api.clover.com/oauth/v2/refresh'
|
||||||
|
|
||||||
DEFAULT_PAYMENT_METHOD_CODES = {
|
DEFAULT_PAYMENT_METHOD_CODES = {
|
||||||
'card',
|
'card',
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import pprint
|
import pprint
|
||||||
@@ -73,24 +75,95 @@ class CloverController(http.Controller):
|
|||||||
|
|
||||||
@http.route(_webhook_url, type='http', methods=['POST'], auth='public', csrf=False)
|
@http.route(_webhook_url, type='http', methods=['POST'], auth='public', csrf=False)
|
||||||
def clover_webhook(self):
|
def clover_webhook(self):
|
||||||
"""Process webhook notifications from Clover."""
|
"""Process webhook notifications from Clover.
|
||||||
|
|
||||||
|
Verifies the HMAC signature (when an app secret is configured)
|
||||||
|
before processing. Clover signs webhooks with HMAC-SHA256 over
|
||||||
|
the raw request body using the app secret as the key, and sends
|
||||||
|
the signature in the ``X-Clover-Auth-Code`` header (hex-encoded).
|
||||||
|
|
||||||
|
Webhooks for the ecom-style providers may also use the legacy
|
||||||
|
``X-Clover-Signature`` header — both are checked.
|
||||||
|
|
||||||
|
Also handles Clover's one-time URL verification challenge:
|
||||||
|
when a developer clicks "Send Verification Code" in the Clover
|
||||||
|
dashboard, Clover POSTs ``{"verificationCode": "<uuid>"}`` to
|
||||||
|
the URL. We log it loudly so the developer can grab it from the
|
||||||
|
Odoo log instead of fishing through Cloudflare logs (the same
|
||||||
|
code is also logged by the Nexa dispatcher Worker if used).
|
||||||
|
"""
|
||||||
|
raw_body = request.httprequest.data
|
||||||
try:
|
try:
|
||||||
raw_body = request.httprequest.data.decode('utf-8')
|
event = json.loads(raw_body.decode('utf-8'))
|
||||||
event = json.loads(raw_body)
|
|
||||||
except (ValueError, UnicodeDecodeError):
|
except (ValueError, UnicodeDecodeError):
|
||||||
_logger.warning("Received invalid JSON from Clover webhook")
|
_logger.warning("Received invalid JSON from Clover webhook")
|
||||||
return request.make_json_response({'status': 'error'}, status=400)
|
return request.make_json_response({'status': 'error'}, status=400)
|
||||||
|
|
||||||
|
# --- Verification challenge ---------------------------------------
|
||||||
|
# This special POST is sent ONCE when the dev dashboard's "Send
|
||||||
|
# Verification Code" button is clicked. It is NOT signed (Clover
|
||||||
|
# has no signature to add yet — the webhook isn't activated),
|
||||||
|
# so we accept it without HMAC verification.
|
||||||
|
verification_code = event.get('verificationCode') if isinstance(event, dict) else None
|
||||||
|
if verification_code:
|
||||||
|
_logger.warning(
|
||||||
|
"================================================================\n"
|
||||||
|
"CLOVER WEBHOOK VERIFICATION CODE (paste into Clover dashboard):\n"
|
||||||
|
" %s\n"
|
||||||
|
"================================================================",
|
||||||
|
verification_code,
|
||||||
|
)
|
||||||
|
return request.make_json_response({
|
||||||
|
'status': 'ok',
|
||||||
|
'verification': verification_code,
|
||||||
|
})
|
||||||
|
|
||||||
|
if not self._verify_webhook_signature(raw_body):
|
||||||
|
_logger.warning(
|
||||||
|
"Clover webhook signature verification FAILED — "
|
||||||
|
"rejecting payload."
|
||||||
|
)
|
||||||
|
return request.make_json_response(
|
||||||
|
{'status': 'forbidden'}, status=403,
|
||||||
|
)
|
||||||
|
|
||||||
_logger.info(
|
_logger.info(
|
||||||
"Clover webhook notification received:\n%s",
|
"Clover webhook notification received:\n%s",
|
||||||
pprint.pformat(event),
|
pprint.pformat(event),
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
event_type = event.get('type', '')
|
self._dispatch_clover_webhook(event)
|
||||||
data = event.get('data', {})
|
except ValidationError:
|
||||||
|
_logger.exception("Unable to process Clover webhook; acknowledging to avoid retries")
|
||||||
|
|
||||||
if event_type in ('charge.succeeded', 'charge.captured'):
|
return request.make_json_response({'status': 'ok'})
|
||||||
|
|
||||||
|
def _dispatch_clover_webhook(self, event):
|
||||||
|
"""Route a Clover webhook payload to the appropriate handler.
|
||||||
|
|
||||||
|
Clover uses two payload shapes depending on the integration:
|
||||||
|
|
||||||
|
1. **Hosted Checkout / Ecommerce style** —
|
||||||
|
``{"type": "charge.succeeded", "data": {...}}``
|
||||||
|
|
||||||
|
2. **Merchant App firehose style** —
|
||||||
|
``{"appId": "...", "merchants": {"<mId>": [{"objectId": "P:xxx",
|
||||||
|
"type": "CREATE", "ts": 1234567890}, ...]}}``
|
||||||
|
|
||||||
|
The two shapes carry different information density. We handle
|
||||||
|
format 1 directly and decode format 2 into best-effort polls of
|
||||||
|
the underlying object (e.g. fetch the payment from Platform API
|
||||||
|
and synthesise a charge.succeeded equivalent).
|
||||||
|
"""
|
||||||
|
if not isinstance(event, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- Format 1: domain events ------------------------------------
|
||||||
|
if 'type' in event and 'data' in event:
|
||||||
|
event_type = event.get('type', '')
|
||||||
|
data = event.get('data', {}) or {}
|
||||||
|
if event_type in ('charge.succeeded', 'charge.captured', 'payment.created'):
|
||||||
self._handle_charge_webhook(data, 'succeeded')
|
self._handle_charge_webhook(data, 'succeeded')
|
||||||
elif event_type == 'charge.failed':
|
elif event_type == 'charge.failed':
|
||||||
self._handle_charge_webhook(data, 'failed')
|
self._handle_charge_webhook(data, 'failed')
|
||||||
@@ -100,13 +173,83 @@ class CloverController(http.Controller):
|
|||||||
self._handle_refund_webhook(data)
|
self._handle_refund_webhook(data)
|
||||||
elif event_type == 'refund.failed':
|
elif event_type == 'refund.failed':
|
||||||
_logger.warning("Clover refund failed webhook: %s", data.get('id', ''))
|
_logger.warning("Clover refund failed webhook: %s", data.get('id', ''))
|
||||||
elif event_type == 'payment.created':
|
else:
|
||||||
self._handle_charge_webhook(data, 'succeeded')
|
_logger.info("Unhandled Clover webhook event type: %s", event_type)
|
||||||
|
return
|
||||||
|
|
||||||
except ValidationError:
|
# --- Format 2: merchant-app firehose ----------------------------
|
||||||
_logger.exception("Unable to process Clover webhook; acknowledging to avoid retries")
|
merchants_block = event.get('merchants') or {}
|
||||||
|
if not isinstance(merchants_block, dict):
|
||||||
|
return
|
||||||
|
for merchant_id, events in merchants_block.items():
|
||||||
|
if not isinstance(events, list):
|
||||||
|
continue
|
||||||
|
for ev in events:
|
||||||
|
if not isinstance(ev, dict):
|
||||||
|
continue
|
||||||
|
object_ref = ev.get('objectId') or ''
|
||||||
|
# objectId looks like "P:E2DYXYRBT52K1/abcd1234" (Payment),
|
||||||
|
# "C:<mid>/<id>" (Customer), "O:<mid>/<id>" (Order), etc.
|
||||||
|
kind = object_ref[:2] if len(object_ref) >= 2 else ''
|
||||||
|
action = ev.get('type', '')
|
||||||
|
_logger.info(
|
||||||
|
"Clover firehose event: merchant=%s objectId=%s action=%s",
|
||||||
|
merchant_id, object_ref, action,
|
||||||
|
)
|
||||||
|
if kind == 'P:' and action in ('CREATE', 'UPDATE'):
|
||||||
|
# Don't synchronously fetch from the Platform API
|
||||||
|
# here — we'd block Clover's webhook ack timeout.
|
||||||
|
# If recovery from missed sync responses becomes a
|
||||||
|
# real need, schedule a queue_job and return 200 fast.
|
||||||
|
_logger.debug(
|
||||||
|
"Payment object change for merchant %s: %s (%s) — "
|
||||||
|
"no synchronous handler, see _dispatch_clover_webhook",
|
||||||
|
merchant_id, object_ref, action,
|
||||||
|
)
|
||||||
|
|
||||||
return request.make_json_response({'status': 'ok'})
|
def _verify_webhook_signature(self, raw_body):
|
||||||
|
"""Validate the HMAC signature on a Clover webhook payload.
|
||||||
|
|
||||||
|
:param bytes raw_body: The raw request body, exactly as received.
|
||||||
|
:return: True if the signature is valid OR if no provider has a
|
||||||
|
secret configured (development/sandbox mode). False if a
|
||||||
|
secret is configured but the signature does not match.
|
||||||
|
:rtype: bool
|
||||||
|
"""
|
||||||
|
# Find any Clover provider with an app secret configured. If none,
|
||||||
|
# we silently allow the webhook (sandbox/dev). If at least one has
|
||||||
|
# a secret, signatures become mandatory.
|
||||||
|
providers = request.env['payment.provider'].sudo().search([
|
||||||
|
('code', '=', 'clover'),
|
||||||
|
('clover_app_secret', '!=', False),
|
||||||
|
])
|
||||||
|
if not providers:
|
||||||
|
return True
|
||||||
|
|
||||||
|
sig_header = (
|
||||||
|
request.httprequest.headers.get('X-Clover-Auth-Code')
|
||||||
|
or request.httprequest.headers.get('X-Clover-Signature')
|
||||||
|
or ''
|
||||||
|
)
|
||||||
|
if not sig_header:
|
||||||
|
return False
|
||||||
|
|
||||||
|
sig_header_clean = sig_header.lower().strip()
|
||||||
|
if sig_header_clean.startswith('sha256='):
|
||||||
|
sig_header_clean = sig_header_clean[len('sha256='):]
|
||||||
|
|
||||||
|
for provider in providers:
|
||||||
|
secret = provider.clover_app_secret
|
||||||
|
if not secret:
|
||||||
|
continue
|
||||||
|
expected = hmac.new(
|
||||||
|
secret.encode('utf-8'),
|
||||||
|
raw_body,
|
||||||
|
hashlib.sha256,
|
||||||
|
).hexdigest().lower()
|
||||||
|
if hmac.compare_digest(expected, sig_header_clean):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def _handle_charge_webhook(self, data, status):
|
def _handle_charge_webhook(self, data, status):
|
||||||
"""Process a charge-related webhook event."""
|
"""Process a charge-related webhook event."""
|
||||||
@@ -200,62 +343,85 @@ class CloverController(http.Controller):
|
|||||||
def clover_oauth_callback(self, **data):
|
def clover_oauth_callback(self, **data):
|
||||||
"""Handle the OAuth2 authorization callback from Clover.
|
"""Handle the OAuth2 authorization callback from Clover.
|
||||||
|
|
||||||
After a merchant authorizes the app, Clover redirects here with
|
After the merchant authorises the Nexa app, Clover redirects to
|
||||||
an authorization code. We exchange it for an access token and
|
the Nexa OAuth dispatcher (https://oauth.nexasystems.ca/clover/
|
||||||
store the merchant_id.
|
callback), which verifies the signed state and 302-redirects
|
||||||
|
here with the original code/merchant_id/state query params.
|
||||||
|
|
||||||
|
Note: the dispatcher already verified the HMAC signature on
|
||||||
|
``state`` before forwarding. We re-verify here as defence in
|
||||||
|
depth — an attacker who tricks the user into hitting this
|
||||||
|
callback directly (skipping the dispatcher) must still know the
|
||||||
|
dispatcher secret to forge a valid state.
|
||||||
"""
|
"""
|
||||||
code = data.get('code', '')
|
code = data.get('code', '')
|
||||||
merchant_id = data.get('merchant_id', '')
|
merchant_id = data.get('merchant_id', '')
|
||||||
client_id = data.get('client_id', '')
|
|
||||||
state = data.get('state', '')
|
state = data.get('state', '')
|
||||||
|
|
||||||
if not code:
|
if not code:
|
||||||
_logger.warning("Clover OAuth callback missing authorization code")
|
_logger.warning("Clover OAuth callback missing authorization code")
|
||||||
return request.redirect('/odoo/settings')
|
return request.redirect('/odoo/settings')
|
||||||
|
|
||||||
if state:
|
# Locate the Clover provider record. There's normally one per
|
||||||
try:
|
# company; we pick the most recently configured.
|
||||||
provider_id = int(state)
|
provider = request.env['payment.provider'].sudo().search([
|
||||||
provider = request.env['payment.provider'].browse(provider_id)
|
('code', '=', 'clover'),
|
||||||
if provider.exists() and provider.code == 'clover':
|
], order='id desc', limit=1)
|
||||||
# Exchange code for access token
|
if not provider:
|
||||||
import requests as req
|
_logger.warning("Clover OAuth callback but no Clover provider exists.")
|
||||||
is_test = provider.state == 'test'
|
return request.redirect('/odoo/settings')
|
||||||
token_url = (
|
|
||||||
f"{data.get('_token_url', '')}"
|
|
||||||
or (
|
|
||||||
'https://apisandbox.dev.clover.com/oauth/token' if is_test
|
|
||||||
else 'https://api.clover.com/oauth/token'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
token_resp = req.get(token_url, params={
|
|
||||||
'client_id': provider.clover_app_id,
|
|
||||||
'client_secret': provider.sudo().clover_app_secret,
|
|
||||||
'code': code,
|
|
||||||
}, timeout=30)
|
|
||||||
|
|
||||||
if token_resp.status_code == 200:
|
# Defence-in-depth: verify the state HMAC ourselves.
|
||||||
token_data = token_resp.json()
|
if state and not self._verify_dispatcher_state(state, provider):
|
||||||
access_token = token_data.get('access_token', '')
|
_logger.error("Clover OAuth callback: invalid HMAC on state, refusing to exchange code.")
|
||||||
if access_token:
|
return request.redirect('/odoo/settings')
|
||||||
vals = {'clover_api_key': access_token}
|
|
||||||
if merchant_id:
|
|
||||||
vals['clover_merchant_id'] = merchant_id
|
|
||||||
provider.sudo().write(vals)
|
|
||||||
_logger.info(
|
|
||||||
"Clover OAuth: linked merchant %s to provider %s",
|
|
||||||
merchant_id, provider_id,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
_logger.error(
|
|
||||||
"Clover OAuth token exchange failed: %s",
|
|
||||||
token_resp.text[:500],
|
|
||||||
)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
_logger.warning("Invalid provider state in Clover OAuth callback: %s", state)
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
provider._clover_exchange_oauth_code(code)
|
||||||
|
except (ValidationError, UserError) as e:
|
||||||
|
_logger.exception("Clover OAuth code exchange failed: %s", e)
|
||||||
|
return request.redirect('/odoo/settings')
|
||||||
|
|
||||||
|
if merchant_id and not provider.clover_merchant_id:
|
||||||
|
provider.sudo().write({'clover_merchant_id': merchant_id})
|
||||||
|
|
||||||
|
_logger.info(
|
||||||
|
"Clover OAuth: linked merchant %s to provider id=%s",
|
||||||
|
merchant_id or '(unknown)', provider.id,
|
||||||
|
)
|
||||||
return request.redirect('/odoo/settings')
|
return request.redirect('/odoo/settings')
|
||||||
|
|
||||||
|
def _verify_dispatcher_state(self, state, provider):
|
||||||
|
"""Recompute the HMAC on the dispatcher state and constant-time
|
||||||
|
compare. Skips the iat freshness check (the dispatcher already
|
||||||
|
did that) but enforces the signature."""
|
||||||
|
secret = provider._clover_dispatcher_secret()
|
||||||
|
if not secret:
|
||||||
|
# No secret configured -> dev/local mode where state is just
|
||||||
|
# decorative. Accept whatever the dispatcher already vetted.
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
payload_b64u, sig_b64u = state.rsplit('.', 1)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
expected = hmac.new(
|
||||||
|
secret.encode('utf-8'),
|
||||||
|
payload_b64u.encode('ascii'),
|
||||||
|
hashlib.sha256,
|
||||||
|
).digest()
|
||||||
|
try:
|
||||||
|
received = self._b64u_decode(sig_b64u)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return hmac.compare_digest(expected, received)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _b64u_decode(s):
|
||||||
|
"""Decode a base64url string with optional padding."""
|
||||||
|
import base64
|
||||||
|
padded = s + '=' * (-len(s) % 4)
|
||||||
|
return base64.urlsafe_b64decode(padded.encode('ascii'))
|
||||||
|
|
||||||
# === SURCHARGE HELPER === #
|
# === SURCHARGE HELPER === #
|
||||||
|
|
||||||
def _apply_portal_surcharge(self, tx_sudo, card_type):
|
def _apply_portal_surcharge(self, tx_sudo, card_type):
|
||||||
@@ -433,12 +599,19 @@ class CloverController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/payment/clover/process_card', type='jsonrpc', auth='public')
|
@http.route('/payment/clover/process_card', type='jsonrpc', auth='public')
|
||||||
def clover_process_card(self, reference=None, card_token=None,
|
def clover_process_card(self, reference=None, card_token=None,
|
||||||
card_type=None, **kwargs):
|
card_type=None, save_token=False, **kwargs):
|
||||||
"""Process a card payment through Clover Ecommerce API.
|
"""Process a card payment through the Clover Ecommerce API.
|
||||||
|
|
||||||
The frontend tokenizes the card via Clover's iframe/API and sends
|
The frontend MUST tokenize the card client-side using Clover.js
|
||||||
the token here. Card data is NOT stored in Odoo.
|
(https://docs.clover.com/docs/web-sdk) and send only the
|
||||||
|
resulting ``clv_xxx`` token here. Raw PAN must never reach Odoo
|
||||||
|
(PCI scope).
|
||||||
|
|
||||||
|
:param str reference: The Odoo payment.transaction reference.
|
||||||
|
:param str card_token: The Clover.js source token (``clv_xxx``).
|
||||||
|
:param str card_type: Optional detected card brand for surcharge.
|
||||||
|
:param bool save_token: Whether to persist the token for future
|
||||||
|
charges (card-on-file).
|
||||||
:return: Dict with success status or error message.
|
:return: Dict with success status or error message.
|
||||||
:rtype: dict
|
:rtype: dict
|
||||||
"""
|
"""
|
||||||
@@ -453,12 +626,17 @@ class CloverController(http.Controller):
|
|||||||
if not tx_sudo:
|
if not tx_sudo:
|
||||||
return {'error': 'Transaction not found.'}
|
return {'error': 'Transaction not found.'}
|
||||||
|
|
||||||
if not card_token:
|
if not card_token or not isinstance(card_token, str) \
|
||||||
return {'error': 'Missing card token. Please try again.'}
|
or not card_token.startswith('clv_'):
|
||||||
|
return {
|
||||||
|
'error': 'Missing or invalid Clover token. '
|
||||||
|
'The card must be tokenized via Clover.js before '
|
||||||
|
'submission.',
|
||||||
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if card_type:
|
if card_type:
|
||||||
surcharge_fee = self._apply_portal_surcharge(tx_sudo, card_type)
|
self._apply_portal_surcharge(tx_sudo, card_type)
|
||||||
|
|
||||||
provider = tx_sudo.provider_id.sudo()
|
provider = tx_sudo.provider_id.sudo()
|
||||||
capture = not provider.capture_manually
|
capture = not provider.capture_manually
|
||||||
@@ -470,6 +648,7 @@ class CloverController(http.Controller):
|
|||||||
capture=capture,
|
capture=capture,
|
||||||
description=reference,
|
description=reference,
|
||||||
ecomind='ecom',
|
ecomind='ecom',
|
||||||
|
receipt_email=tx_sudo.partner_id.email or '',
|
||||||
metadata={'odoo_reference': reference},
|
metadata={'odoo_reference': reference},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -487,11 +666,15 @@ class CloverController(http.Controller):
|
|||||||
'clover_status': status,
|
'clover_status': status,
|
||||||
'source': result.get('source', {}),
|
'source': result.get('source', {}),
|
||||||
}
|
}
|
||||||
|
if save_token:
|
||||||
|
# Mark the transaction so _extract_token_values fires on
|
||||||
|
# _apply_updates.
|
||||||
|
tx_sudo.tokenize = True
|
||||||
tx_sudo._process('clover', payment_data)
|
tx_sudo._process('clover', payment_data)
|
||||||
|
|
||||||
return {'success': True, 'status': status}
|
return {'success': True, 'status': status}
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
return {'error': str(e)}
|
return {'error': str(e)}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.error("Card payment processing failed: %s", e)
|
_logger.exception("Card payment processing failed: %s", e)
|
||||||
return {'error': 'Payment processing failed. Please try again.'}
|
return {'error': 'Payment processing failed. Please try again.'}
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
<field name="inline_form_view_id" ref="inline_form"/>
|
<field name="inline_form_view_id" ref="inline_form"/>
|
||||||
<field name="allow_tokenization">True</field>
|
<field name="allow_tokenization">True</field>
|
||||||
<field name="state">disabled</field>
|
<field name="state">disabled</field>
|
||||||
<field name="image_128" type="base64" file="fusion_clover/static/src/img/clover_logo.png"/>
|
<!-- Logo is uploaded by the merchant via the UI after install
|
||||||
|
(Settings > Payment Providers > Clover > upload image_128). -->
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|||||||
@@ -56,12 +56,43 @@ class PaymentProvider(models.Model):
|
|||||||
copy=False,
|
copy=False,
|
||||||
groups='base.group_system',
|
groups='base.group_system',
|
||||||
)
|
)
|
||||||
|
clover_remote_app_id = fields.Char(
|
||||||
|
string="Remote App ID (RAID)",
|
||||||
|
help="The Remote App ID generated when the Clover dev app's "
|
||||||
|
"App Type is set to Web > 'Is this an integration of an "
|
||||||
|
"existing point of sale?' = Yes. Sent in the X-POS-Id "
|
||||||
|
"header on every REST Pay Display call. Required for "
|
||||||
|
"production terminal payments.",
|
||||||
|
copy=False,
|
||||||
|
)
|
||||||
clover_public_key = fields.Char(
|
clover_public_key = fields.Char(
|
||||||
string="Public API Key (PAKMS)",
|
string="Public API Key (PAKMS)",
|
||||||
help="The public token from Clover's Ecommerce API Tokens page. "
|
help="The public token from Clover's Ecommerce API Tokens page. "
|
||||||
"Used for client-side tokenization. Safe to expose in the browser.",
|
"Used for client-side tokenization. Safe to expose in the browser.",
|
||||||
copy=False,
|
copy=False,
|
||||||
)
|
)
|
||||||
|
clover_oauth_access_token = fields.Char(
|
||||||
|
string="OAuth Access Token",
|
||||||
|
help="Long-lived merchant OAuth access_token, obtained via the "
|
||||||
|
"Connect to Clover button. Used as the Bearer token for "
|
||||||
|
"all Platform API and REST Pay Display calls. Different "
|
||||||
|
"from the Ecommerce private token (clover_api_key).",
|
||||||
|
copy=False,
|
||||||
|
groups='base.group_system',
|
||||||
|
)
|
||||||
|
clover_oauth_refresh_token = fields.Char(
|
||||||
|
string="OAuth Refresh Token",
|
||||||
|
help="Refresh token used to renew the access token before it "
|
||||||
|
"expires.",
|
||||||
|
copy=False,
|
||||||
|
groups='base.group_system',
|
||||||
|
)
|
||||||
|
clover_oauth_token_expiry = fields.Datetime(
|
||||||
|
string="OAuth Token Expiry",
|
||||||
|
help="When the current access_token expires. The refresh flow "
|
||||||
|
"should run automatically before this time.",
|
||||||
|
copy=False,
|
||||||
|
)
|
||||||
clover_default_terminal_id = fields.Many2one(
|
clover_default_terminal_id = fields.Many2one(
|
||||||
'clover.terminal',
|
'clover.terminal',
|
||||||
string="Default Terminal",
|
string="Default Terminal",
|
||||||
@@ -90,9 +121,292 @@ class PaymentProvider(models.Model):
|
|||||||
return super()._get_default_payment_method_codes()
|
return super()._get_default_payment_method_codes()
|
||||||
return const.DEFAULT_PAYMENT_METHOD_CODES
|
return const.DEFAULT_PAYMENT_METHOD_CODES
|
||||||
|
|
||||||
|
# === BUSINESS METHODS - OAUTH HELPERS === #
|
||||||
|
|
||||||
|
def _clover_get_platform_token(self):
|
||||||
|
"""Pick the best Bearer token for Platform / REST Pay Display calls.
|
||||||
|
|
||||||
|
Priority order:
|
||||||
|
1. Live OAuth access_token (preferred — refreshable, app-scoped,
|
||||||
|
grants both Platform API and REST Pay Display).
|
||||||
|
2. Manual REST API token from Clover Dashboard > Setup > API Tokens
|
||||||
|
(legacy / single-merchant fallback).
|
||||||
|
3. Ecommerce private token (last-resort — works only for the
|
||||||
|
Platform GET /v3/merchants/{mId} test-connection call;
|
||||||
|
will 401 on REST Pay Display).
|
||||||
|
|
||||||
|
Will proactively refresh the OAuth access_token if it's expired
|
||||||
|
(or within a 60-second grace window of expiry).
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
if self.clover_oauth_access_token:
|
||||||
|
self._clover_refresh_oauth_if_needed()
|
||||||
|
return self.clover_oauth_access_token
|
||||||
|
if self.clover_rest_api_token:
|
||||||
|
return self.clover_rest_api_token
|
||||||
|
return self.clover_api_key or ''
|
||||||
|
|
||||||
|
def _clover_refresh_oauth_if_needed(self, grace_seconds=60):
|
||||||
|
"""Refresh the OAuth access_token if it's within ``grace_seconds``
|
||||||
|
of expiry. Silent no-op if no expiry / no refresh_token / refresh
|
||||||
|
endpoint not available. Logs at WARNING on refresh failure but
|
||||||
|
does not raise (the next API call will get 401 and may trigger
|
||||||
|
retry-with-refresh from the request method)."""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
self.ensure_one()
|
||||||
|
if not self.clover_oauth_refresh_token:
|
||||||
|
return False
|
||||||
|
if self.clover_oauth_token_expiry:
|
||||||
|
time_left = self.clover_oauth_token_expiry - datetime.utcnow()
|
||||||
|
if time_left > timedelta(seconds=grace_seconds):
|
||||||
|
return False # plenty of time
|
||||||
|
return self._clover_refresh_oauth_token()
|
||||||
|
|
||||||
|
def _clover_refresh_oauth_token(self):
|
||||||
|
"""Use the stored refresh_token to mint a new access_token via
|
||||||
|
Clover's /oauth/v2/refresh. Stores the new token + new expiry on
|
||||||
|
the provider record. Returns True on success, False on failure
|
||||||
|
(caller decides whether to fall back or raise)."""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
self.ensure_one()
|
||||||
|
if not self.clover_oauth_refresh_token or not self.clover_app_id:
|
||||||
|
return False
|
||||||
|
is_test = self.state == 'test'
|
||||||
|
url = const.OAUTH_REFRESH_URL_TEST if is_test else const.OAUTH_REFRESH_URL
|
||||||
|
try:
|
||||||
|
r = requests.post(
|
||||||
|
url,
|
||||||
|
json={
|
||||||
|
'client_id': self.clover_app_id,
|
||||||
|
'refresh_token': self.clover_oauth_refresh_token,
|
||||||
|
},
|
||||||
|
timeout=20,
|
||||||
|
)
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
_logger.warning("Clover OAuth refresh network error: %s", e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if r.status_code != 200:
|
||||||
|
_logger.warning(
|
||||||
|
"Clover OAuth refresh failed (%s): %s",
|
||||||
|
r.status_code, r.text[:300],
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
data = r.json()
|
||||||
|
except ValueError:
|
||||||
|
_logger.warning("Clover OAuth refresh response is not JSON")
|
||||||
|
return False
|
||||||
|
|
||||||
|
new_token = data.get('access_token', '')
|
||||||
|
if not new_token:
|
||||||
|
return False
|
||||||
|
vals = {'clover_oauth_access_token': new_token}
|
||||||
|
new_refresh = data.get('refresh_token', '')
|
||||||
|
if new_refresh:
|
||||||
|
vals['clover_oauth_refresh_token'] = new_refresh
|
||||||
|
expires = int(data.get('access_token_expiration', 0) or 0)
|
||||||
|
if expires:
|
||||||
|
if expires > 10 * 365 * 24 * 3600: # absolute timestamp
|
||||||
|
vals['clover_oauth_token_expiry'] = datetime.utcfromtimestamp(expires)
|
||||||
|
else: # duration in seconds
|
||||||
|
vals['clover_oauth_token_expiry'] = (
|
||||||
|
datetime.utcnow() + timedelta(seconds=expires)
|
||||||
|
)
|
||||||
|
self.sudo().write(vals)
|
||||||
|
_logger.info("Clover OAuth access_token refreshed for provider %s", self.id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _clover_dispatcher_url(self):
|
||||||
|
"""Return the Nexa OAuth dispatcher URL (where Clover redirects
|
||||||
|
after the merchant authorises). Configurable per-deployment via
|
||||||
|
ir.config_parameter ``fusion_clover.dispatcher_url``."""
|
||||||
|
return self.env['ir.config_parameter'].sudo().get_param(
|
||||||
|
'fusion_clover.dispatcher_url',
|
||||||
|
'https://oauth.nexasystems.ca/clover/callback',
|
||||||
|
)
|
||||||
|
|
||||||
|
def _clover_dispatcher_secret(self):
|
||||||
|
"""Return the HMAC secret shared with the Nexa OAuth dispatcher.
|
||||||
|
Stored in ``ir.config_parameter`` ``fusion_clover.dispatcher_secret``
|
||||||
|
so it can be rotated without a code deploy. Returns '' if not
|
||||||
|
configured (in which case the OAuth flow falls back to a direct
|
||||||
|
callback to this Odoo instance — useful for local dev)."""
|
||||||
|
return self.env['ir.config_parameter'].sudo().get_param(
|
||||||
|
'fusion_clover.dispatcher_secret', '',
|
||||||
|
)
|
||||||
|
|
||||||
|
def _clover_build_signed_state(self, customer_slug=''):
|
||||||
|
"""Build the HMAC-SHA256 signed state token sent to Clover's
|
||||||
|
OAuth authorize endpoint. The Nexa dispatcher Worker verifies
|
||||||
|
this signature and uses the embedded ``redirect_to`` to fan the
|
||||||
|
OAuth callback back to THIS Odoo instance.
|
||||||
|
|
||||||
|
Format: ``<base64url(payload_json)>.<base64url(hmac_sha256(secret, payload))>``
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac as _hmac
|
||||||
|
import json as _json
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
|
||||||
|
self.ensure_one()
|
||||||
|
secret = self._clover_dispatcher_secret()
|
||||||
|
if not secret:
|
||||||
|
raise UserError(_(
|
||||||
|
"The dispatcher HMAC secret is not configured. Set "
|
||||||
|
"the system parameter "
|
||||||
|
"'fusion_clover.dispatcher_secret' to the value "
|
||||||
|
"stored in the Cloudflare Worker before initiating "
|
||||||
|
"the Connect to Clover flow."
|
||||||
|
))
|
||||||
|
base_url = self.get_base_url()
|
||||||
|
payload = {
|
||||||
|
'redirect_to': f"{base_url}/payment/clover/oauth/callback",
|
||||||
|
'nonce': secrets.token_hex(16),
|
||||||
|
'iat': int(time.time()),
|
||||||
|
'customer': customer_slug or self.company_id.name or '',
|
||||||
|
}
|
||||||
|
payload_json = _json.dumps(payload, separators=(',', ':')).encode('utf-8')
|
||||||
|
payload_b64u = base64.urlsafe_b64encode(payload_json).rstrip(b'=').decode('ascii')
|
||||||
|
sig = _hmac.new(
|
||||||
|
secret.encode('utf-8'),
|
||||||
|
payload_b64u.encode('ascii'),
|
||||||
|
hashlib.sha256,
|
||||||
|
).digest()
|
||||||
|
sig_b64u = base64.urlsafe_b64encode(sig).rstrip(b'=').decode('ascii')
|
||||||
|
return f"{payload_b64u}.{sig_b64u}"
|
||||||
|
|
||||||
|
def action_clover_oauth_connect(self):
|
||||||
|
"""Kick off the OAuth flow: redirect the staff user to Clover's
|
||||||
|
authorize endpoint, with our dispatcher URL as redirect_uri and
|
||||||
|
a signed state encoding this Odoo's callback URL."""
|
||||||
|
from werkzeug.urls import url_encode
|
||||||
|
|
||||||
|
self.ensure_one()
|
||||||
|
if self.code != 'clover':
|
||||||
|
raise UserError(_("This action is only available for Clover providers."))
|
||||||
|
if not self.clover_app_id:
|
||||||
|
raise UserError(_(
|
||||||
|
"App ID is required. Add it on the payment provider record "
|
||||||
|
"under 'OAuth (Optional)' first."
|
||||||
|
))
|
||||||
|
|
||||||
|
is_test = self.state == 'test'
|
||||||
|
authorize_url = (
|
||||||
|
const.OAUTH_AUTHORIZE_URL_TEST if is_test else const.OAUTH_AUTHORIZE_URL
|
||||||
|
)
|
||||||
|
state = self._clover_build_signed_state()
|
||||||
|
# Per https://docs.clover.com/dev/docs/high-trust-app-auth-flow the
|
||||||
|
# v2 authorize endpoint only takes client_id + redirect_uri.
|
||||||
|
# `state` is documented elsewhere as supported for CSRF; we include
|
||||||
|
# it. Notably ABSENT: `response_type` (legacy v1 only) and `scope`
|
||||||
|
# (handled by app's Requested Permissions in the dev dashboard).
|
||||||
|
params = {
|
||||||
|
'client_id': self.clover_app_id,
|
||||||
|
'redirect_uri': self._clover_dispatcher_url(),
|
||||||
|
'state': state,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_url',
|
||||||
|
'url': f"{authorize_url}?{url_encode(params)}",
|
||||||
|
'target': 'self',
|
||||||
|
}
|
||||||
|
|
||||||
|
def _clover_exchange_oauth_code(self, code):
|
||||||
|
"""Exchange a one-time OAuth ``code`` for a long-lived
|
||||||
|
access_token + refresh_token by POSTing to Clover's token
|
||||||
|
endpoint with our App Secret. Stores the tokens on this
|
||||||
|
provider record.
|
||||||
|
|
||||||
|
Tries the v2 token endpoint first; if Clover responds with 401
|
||||||
|
"Failed to validate authentication code", it means the code was
|
||||||
|
generated by the legacy partial OAuth flow (App Market Connect
|
||||||
|
without an Alternate Launch Path) — in that case we fall back to
|
||||||
|
the v1 token endpoint which is what generated the code.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
self.ensure_one()
|
||||||
|
if not self.clover_app_id or not self.clover_app_secret:
|
||||||
|
raise ValidationError(_(
|
||||||
|
"Cannot exchange OAuth code: App ID or App Secret is missing."
|
||||||
|
))
|
||||||
|
is_test = self.state == 'test'
|
||||||
|
v2_url = const.OAUTH_TOKEN_URL_TEST if is_test else const.OAUTH_TOKEN_URL
|
||||||
|
# Legacy v1 token URL — same host, no /v2/.
|
||||||
|
v1_url = v2_url.replace('/oauth/v2/token', '/oauth/token')
|
||||||
|
payload = {
|
||||||
|
'client_id': self.clover_app_id,
|
||||||
|
'client_secret': self.clover_app_secret,
|
||||||
|
'code': code,
|
||||||
|
}
|
||||||
|
|
||||||
|
data = None
|
||||||
|
last_error = ''
|
||||||
|
for token_url in (v2_url, v1_url):
|
||||||
|
try:
|
||||||
|
# v1 endpoint historically used GET with query params;
|
||||||
|
# v2 uses POST with JSON body. We send POST+JSON to v1
|
||||||
|
# too (Clover accepts both there in our experience).
|
||||||
|
response = requests.post(token_url, json=payload, timeout=30)
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
raise ValidationError(_("Could not reach Clover OAuth token endpoint: %s", e))
|
||||||
|
|
||||||
|
if response.status_code < 400:
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
except ValueError:
|
||||||
|
raise ValidationError(_("Clover OAuth response was not valid JSON."))
|
||||||
|
_logger.info("Clover OAuth code exchanged successfully via %s", token_url)
|
||||||
|
break
|
||||||
|
|
||||||
|
last_error = response.text[:500]
|
||||||
|
_logger.warning(
|
||||||
|
"Clover OAuth exchange via %s returned %s: %s — trying next endpoint",
|
||||||
|
token_url, response.status_code, last_error,
|
||||||
|
)
|
||||||
|
# Only try v1 fallback if v2 specifically said "Failed to
|
||||||
|
# validate authentication code" — other 4xx errors won't
|
||||||
|
# benefit from the fallback.
|
||||||
|
if 'validate authentication code' not in last_error.lower():
|
||||||
|
break
|
||||||
|
|
||||||
|
if data is None:
|
||||||
|
raise ValidationError(_(
|
||||||
|
"Clover rejected the OAuth code at both v2 and v1 token endpoints. "
|
||||||
|
"Last response: %s", last_error,
|
||||||
|
))
|
||||||
|
|
||||||
|
access_token = data.get('access_token', '')
|
||||||
|
refresh_token = data.get('refresh_token', '')
|
||||||
|
expires_in = int(data.get('access_token_expiration', 0) or 0)
|
||||||
|
|
||||||
|
if not access_token:
|
||||||
|
raise ValidationError(_("Clover OAuth response did not contain an access_token."))
|
||||||
|
|
||||||
|
vals = {
|
||||||
|
'clover_oauth_access_token': access_token,
|
||||||
|
'clover_oauth_refresh_token': refresh_token or False,
|
||||||
|
}
|
||||||
|
if expires_in:
|
||||||
|
# Clover returns the expiry as a UNIX timestamp in seconds, not
|
||||||
|
# a duration. Detect both shapes (a duration is < ~10 years).
|
||||||
|
if expires_in > 10 * 365 * 24 * 3600:
|
||||||
|
vals['clover_oauth_token_expiry'] = datetime.utcfromtimestamp(expires_in)
|
||||||
|
else:
|
||||||
|
vals['clover_oauth_token_expiry'] = (
|
||||||
|
datetime.utcnow() + timedelta(seconds=expires_in)
|
||||||
|
)
|
||||||
|
self.sudo().write(vals)
|
||||||
|
return True
|
||||||
|
|
||||||
# === BUSINESS METHODS - API REQUESTS === #
|
# === BUSINESS METHODS - API REQUESTS === #
|
||||||
|
|
||||||
def _clover_make_ecom_request(self, method, endpoint, payload=None, params=None):
|
def _clover_make_ecom_request(self, method, endpoint, payload=None, params=None, _retry=True):
|
||||||
"""Make an authenticated API request to the Clover Ecommerce API.
|
"""Make an authenticated API request to the Clover Ecommerce API.
|
||||||
|
|
||||||
:param str method: HTTP method (GET, POST, PUT, DELETE).
|
:param str method: HTTP method (GET, POST, PUT, DELETE).
|
||||||
@@ -109,8 +423,16 @@ class PaymentProvider(models.Model):
|
|||||||
url = clover_utils.build_ecom_url(endpoint, is_test=is_test)
|
url = clover_utils.build_ecom_url(endpoint, is_test=is_test)
|
||||||
|
|
||||||
idempotency_key = clover_utils.generate_idempotency_key()
|
idempotency_key = clover_utils.generate_idempotency_key()
|
||||||
|
# Auth precedence: OAuth access_token (works for both Ecommerce
|
||||||
|
# AND Platform APIs per Clover docs) > Ecommerce private token.
|
||||||
|
# Routed through _clover_get_platform_token() so the OAuth token
|
||||||
|
# is proactively refreshed if it's near expiry.
|
||||||
|
if self.clover_oauth_access_token:
|
||||||
|
ecom_token = self._clover_get_platform_token()
|
||||||
|
else:
|
||||||
|
ecom_token = self.clover_api_key or ''
|
||||||
headers = clover_utils.build_ecom_headers(
|
headers = clover_utils.build_ecom_headers(
|
||||||
self.clover_api_key, idempotency_key=idempotency_key,
|
ecom_token, idempotency_key=idempotency_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
_logger.info(
|
_logger.info(
|
||||||
@@ -142,6 +464,13 @@ class PaymentProvider(models.Model):
|
|||||||
_logger.error("Clover returned non-JSON response: %s", response.text[:500])
|
_logger.error("Clover returned non-JSON response: %s", response.text[:500])
|
||||||
raise ValidationError(_("Clover returned an invalid response."))
|
raise ValidationError(_("Clover returned an invalid response."))
|
||||||
|
|
||||||
|
if response.status_code == 401 and _retry and self.clover_oauth_refresh_token:
|
||||||
|
_logger.info("Clover Ecom 401, attempting token refresh + retry")
|
||||||
|
if self._clover_refresh_oauth_token():
|
||||||
|
return self._clover_make_ecom_request(
|
||||||
|
method, endpoint, payload=payload, params=params, _retry=False,
|
||||||
|
)
|
||||||
|
|
||||||
if response.status_code >= 400:
|
if response.status_code >= 400:
|
||||||
error = result.get('error', {})
|
error = result.get('error', {})
|
||||||
error_msg = error.get('message', '') if isinstance(error, dict) else str(error)
|
error_msg = error.get('message', '') if isinstance(error, dict) else str(error)
|
||||||
@@ -161,7 +490,7 @@ class PaymentProvider(models.Model):
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _clover_make_platform_request(self, method, endpoint, payload=None, params=None):
|
def _clover_make_platform_request(self, method, endpoint, payload=None, params=None, _retry=True):
|
||||||
"""Make an authenticated request to the Clover Platform API.
|
"""Make an authenticated request to the Clover Platform API.
|
||||||
|
|
||||||
:param str method: HTTP method.
|
:param str method: HTTP method.
|
||||||
@@ -179,8 +508,8 @@ class PaymentProvider(models.Model):
|
|||||||
endpoint, merchant_id=self.clover_merchant_id, is_test=is_test,
|
endpoint, merchant_id=self.clover_merchant_id, is_test=is_test,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Platform API uses the REST API token, falling back to ecom key
|
# Platform API auth precedence: OAuth > merchant REST API token > ecom key
|
||||||
api_token = self.clover_rest_api_token or self.clover_api_key
|
api_token = self._clover_get_platform_token()
|
||||||
headers = clover_utils.build_ecom_headers(api_token)
|
headers = clover_utils.build_ecom_headers(api_token)
|
||||||
|
|
||||||
_logger.info("Clover Platform API %s request to %s", method, url)
|
_logger.info("Clover Platform API %s request to %s", method, url)
|
||||||
@@ -208,6 +537,13 @@ class PaymentProvider(models.Model):
|
|||||||
return {}
|
return {}
|
||||||
raise ValidationError(_("Clover returned an invalid response."))
|
raise ValidationError(_("Clover returned an invalid response."))
|
||||||
|
|
||||||
|
if response.status_code == 401 and _retry and self.clover_oauth_refresh_token:
|
||||||
|
_logger.info("Clover Platform 401, attempting token refresh + retry")
|
||||||
|
if self._clover_refresh_oauth_token():
|
||||||
|
return self._clover_make_platform_request(
|
||||||
|
method, endpoint, payload=payload, params=params, _retry=False,
|
||||||
|
)
|
||||||
|
|
||||||
if response.status_code >= 400:
|
if response.status_code >= 400:
|
||||||
error_msg = result.get('message', result.get('error', 'Unknown error'))
|
error_msg = result.get('message', result.get('error', 'Unknown error'))
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
@@ -217,6 +553,122 @@ class PaymentProvider(models.Model):
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
# === BUSINESS METHODS - TOKENIZATION === #
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _clover_detect_brand_from_pan(pan):
|
||||||
|
"""Detect the card brand from a digits-only PAN, returning one of
|
||||||
|
Clover's accepted brand codes (VISA, MC, AMEX, DISCOVER, DINERS,
|
||||||
|
JCB). Falls back to VISA if unrecognised — Clover will then
|
||||||
|
decline at the charge step rather than the tokenize step, which
|
||||||
|
is a slightly more useful error path."""
|
||||||
|
if not pan or len(pan) < 2:
|
||||||
|
return 'VISA'
|
||||||
|
if pan[:2] in ('34', '37'):
|
||||||
|
return 'AMEX'
|
||||||
|
if pan[0] == '4':
|
||||||
|
return 'VISA'
|
||||||
|
try:
|
||||||
|
p2 = int(pan[:2])
|
||||||
|
p4 = int(pan[:4]) if len(pan) >= 4 else 0
|
||||||
|
except ValueError:
|
||||||
|
return 'VISA'
|
||||||
|
if 51 <= p2 <= 55:
|
||||||
|
return 'MC'
|
||||||
|
if 2221 <= p4 <= 2720:
|
||||||
|
return 'MC'
|
||||||
|
if pan[:4] == '6011' or pan[:2] == '65':
|
||||||
|
return 'DISCOVER'
|
||||||
|
if pan[:4] in ('3014', '3036', '3038') or pan[:2] in ('30', '36', '38'):
|
||||||
|
return 'DINERS'
|
||||||
|
if pan[:2] == '35':
|
||||||
|
return 'JCB'
|
||||||
|
return 'VISA'
|
||||||
|
|
||||||
|
|
||||||
|
def _clover_tokenize_card(self, card_number, exp_month, exp_year, cvv,
|
||||||
|
cardholder_name='', postal_code=''):
|
||||||
|
"""Server-side card tokenization via Clover's tokenization service.
|
||||||
|
|
||||||
|
Used by the back-office wizard for staff-keyed card entry. Returns
|
||||||
|
a ``clv_xxx`` token that can then be passed to ``/v1/charges`` as
|
||||||
|
the ``source``. The raw PAN is sent ONCE to Clover's tokenization
|
||||||
|
endpoint and never persisted in Odoo.
|
||||||
|
|
||||||
|
Note: Westin Healthcare staff should normally use the terminal
|
||||||
|
flow rather than manual card entry, which keeps even this brief
|
||||||
|
in-memory PAN handling out of the picture.
|
||||||
|
|
||||||
|
:param str card_number: Card number, digits only.
|
||||||
|
:param int exp_month: Expiry month (1-12).
|
||||||
|
:param int exp_year: Expiry year (4-digit).
|
||||||
|
:param str cvv: Card verification value.
|
||||||
|
:param str cardholder_name: Optional name on card.
|
||||||
|
:param str postal_code: Optional billing postal code.
|
||||||
|
:return: The Clover token string ``clv_xxx``.
|
||||||
|
:rtype: str
|
||||||
|
:raises ValidationError: If tokenization fails.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
is_test = self.state == 'test'
|
||||||
|
base_url = const.TOKEN_BASE_URL_TEST if is_test else const.TOKEN_BASE_URL
|
||||||
|
url = f"{base_url}/v1/tokens"
|
||||||
|
|
||||||
|
if not self.clover_public_key:
|
||||||
|
raise ValidationError(_(
|
||||||
|
"No Clover Public API Key (PAKMS) configured. Add it on "
|
||||||
|
"the payment provider record before staff can charge cards "
|
||||||
|
"from the back-office wizard."
|
||||||
|
))
|
||||||
|
|
||||||
|
# Clover's tokenization endpoint requires the lowercase `apikey`
|
||||||
|
# header (NOT the `apiAccessKey` field name returned by /pakms).
|
||||||
|
# https://docs.clover.com/dev/reference/create-card-token
|
||||||
|
headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'apikey': self.clover_public_key,
|
||||||
|
}
|
||||||
|
# Detect card brand from the BIN. Clover requires a real brand
|
||||||
|
# name (VISA/MC/AMEX/DISCOVER/DINERS/JCB) — `CARD` is not valid.
|
||||||
|
clean_pan = str(card_number).replace(' ', '').replace('-', '')
|
||||||
|
brand = self._clover_detect_brand_from_pan(clean_pan)
|
||||||
|
payload = {
|
||||||
|
'card': {
|
||||||
|
'number': clean_pan,
|
||||||
|
'exp_month': str(exp_month).zfill(2),
|
||||||
|
'exp_year': str(exp_year),
|
||||||
|
'cvv': str(cvv),
|
||||||
|
'brand': brand,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if cardholder_name:
|
||||||
|
payload['card']['name'] = cardholder_name
|
||||||
|
if postal_code:
|
||||||
|
payload['card']['address_zip'] = postal_code
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(url, json=payload, headers=headers, timeout=30)
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
raise ValidationError(_("Could not reach Clover tokenization service: %s", e))
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = response.json()
|
||||||
|
except ValueError:
|
||||||
|
raise ValidationError(_("Clover returned an invalid tokenization response."))
|
||||||
|
|
||||||
|
if response.status_code >= 400 or 'id' not in result:
|
||||||
|
error = result.get('error', {})
|
||||||
|
error_msg = error.get('message', '') if isinstance(error, dict) else str(error)
|
||||||
|
raise ValidationError(
|
||||||
|
_("Clover tokenization failed (%(code)s): %(msg)s",
|
||||||
|
code=response.status_code, msg=error_msg or 'Unknown error')
|
||||||
|
)
|
||||||
|
token = result['id']
|
||||||
|
if not token.startswith('clv_'):
|
||||||
|
raise ValidationError(_("Unexpected token format from Clover: %s", token))
|
||||||
|
return token
|
||||||
|
|
||||||
# === BUSINESS METHODS - CHARGE / TOKENIZE === #
|
# === BUSINESS METHODS - CHARGE / TOKENIZE === #
|
||||||
|
|
||||||
def _clover_create_charge(self, source_token, amount, currency,
|
def _clover_create_charge(self, source_token, amount, currency,
|
||||||
@@ -360,9 +812,19 @@ class PaymentProvider(models.Model):
|
|||||||
"""
|
"""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
|
|
||||||
partner = self.env['res.partner'].browse(partner_id).exists()
|
partner = self.env['res.partner'].browse(int(partner_id)).exists() if partner_id else self.env['res.partner']
|
||||||
minor_amount = clover_utils.format_clover_amount(amount, currency) if amount else 0
|
minor_amount = clover_utils.format_clover_amount(amount, currency) if amount else 0
|
||||||
|
|
||||||
|
# Map the Odoo language to a Clover-supported locale (Clover only
|
||||||
|
# supports en-US, en-CA, fr-CA today). Anything else falls back to
|
||||||
|
# en-US (Clover SDK default).
|
||||||
|
partner_lang = (partner.lang or self.env.user.lang or 'en_US').replace('_', '-')
|
||||||
|
clover_locale = ''
|
||||||
|
if partner_lang.startswith('fr'):
|
||||||
|
clover_locale = 'fr-CA'
|
||||||
|
elif partner_lang in ('en-CA',):
|
||||||
|
clover_locale = 'en-CA'
|
||||||
|
|
||||||
inline_form_values = {
|
inline_form_values = {
|
||||||
'provider_id': self.id,
|
'provider_id': self.id,
|
||||||
'merchant_id': self.clover_merchant_id,
|
'merchant_id': self.clover_merchant_id,
|
||||||
@@ -371,6 +833,7 @@ class PaymentProvider(models.Model):
|
|||||||
'minor_amount': minor_amount,
|
'minor_amount': minor_amount,
|
||||||
'capture_method': 'manual' if self.capture_manually else 'automatic',
|
'capture_method': 'manual' if self.capture_manually else 'automatic',
|
||||||
'is_test': self.state == 'test',
|
'is_test': self.state == 'test',
|
||||||
|
'locale': clover_locale,
|
||||||
'billing_details': {
|
'billing_details': {
|
||||||
'name': partner.name or '',
|
'name': partner.name or '',
|
||||||
'email': partner.email or '',
|
'email': partner.email or '',
|
||||||
@@ -411,7 +874,7 @@ class PaymentProvider(models.Model):
|
|||||||
# === BUSINESS METHODS - TERMINAL (REST Pay Display Cloud API) === #
|
# === BUSINESS METHODS - TERMINAL (REST Pay Display Cloud API) === #
|
||||||
|
|
||||||
def _clover_terminal_request(self, method, endpoint, serial_number=None,
|
def _clover_terminal_request(self, method, endpoint, serial_number=None,
|
||||||
payload=None, params=None):
|
payload=None, params=None, _retry=True):
|
||||||
"""Make a request to the Clover REST Pay Display Cloud API.
|
"""Make a request to the Clover REST Pay Display Cloud API.
|
||||||
|
|
||||||
Sends commands to Clover terminals through Clover's cloud (Cloud Pay Display).
|
Sends commands to Clover terminals through Clover's cloud (Cloud Pay Display).
|
||||||
@@ -434,8 +897,12 @@ class PaymentProvider(models.Model):
|
|||||||
headers = {
|
headers = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
'Authorization': f'Bearer {self.clover_rest_api_token or self.clover_api_key}',
|
'Authorization': f'Bearer {self._clover_get_platform_token()}',
|
||||||
'X-POS-ID': 'FusionCloverOdoo',
|
# X-POS-Id MUST be the Remote App ID (RAID) from the Clover
|
||||||
|
# developer dashboard (App Type > Web > "is integration of an
|
||||||
|
# existing POS" = Yes). Falls back to a static identifier in
|
||||||
|
# sandbox where some merchants use the legacy free-form value.
|
||||||
|
'X-POS-Id': self.clover_remote_app_id or 'FusionCloverOdoo',
|
||||||
}
|
}
|
||||||
if serial_number:
|
if serial_number:
|
||||||
headers['X-Clover-Device-Id'] = serial_number
|
headers['X-Clover-Device-Id'] = serial_number
|
||||||
@@ -472,6 +939,14 @@ class PaymentProvider(models.Model):
|
|||||||
_logger.error("Clover Terminal returned non-JSON: %s", response.text[:500])
|
_logger.error("Clover Terminal returned non-JSON: %s", response.text[:500])
|
||||||
raise ValidationError(_("Clover terminal returned an invalid response."))
|
raise ValidationError(_("Clover terminal returned an invalid response."))
|
||||||
|
|
||||||
|
if response.status_code == 401 and _retry and self.clover_oauth_refresh_token:
|
||||||
|
_logger.info("Clover Terminal 401, attempting token refresh + retry")
|
||||||
|
if self._clover_refresh_oauth_token():
|
||||||
|
return self._clover_terminal_request(
|
||||||
|
method, endpoint, serial_number=serial_number,
|
||||||
|
payload=payload, params=params, _retry=False,
|
||||||
|
)
|
||||||
|
|
||||||
if response.status_code >= 400:
|
if response.status_code >= 400:
|
||||||
error_msg = result.get('message', result.get('error', 'Unknown error'))
|
error_msg = result.get('message', result.get('error', 'Unknown error'))
|
||||||
_logger.error(
|
_logger.error(
|
||||||
|
|||||||
@@ -442,6 +442,51 @@ class PaymentTransaction(models.Model):
|
|||||||
|
|
||||||
return tx
|
return tx
|
||||||
|
|
||||||
|
def _extract_amount_data(self, payment_data):
|
||||||
|
"""Override of `payment` for the Odoo 19 amount-tamper check.
|
||||||
|
|
||||||
|
Odoo's ``_validate_amount`` calls this and KErrors if the dict
|
||||||
|
doesn't contain ``amount`` + ``currency_code``. Other providers
|
||||||
|
always include these in their ``payment_data``; we historically
|
||||||
|
didn't. Extract from the Clover charge response shape:
|
||||||
|
|
||||||
|
- source token charges (Ecommerce):
|
||||||
|
``{'amount': <cents>, 'currency': 'usd', ...}`` (raw Clover
|
||||||
|
response usually shoved into ``payment_data['raw']``)
|
||||||
|
- terminal payments: ``payment_data['amount']`` (we set this
|
||||||
|
explicitly when we build the dict)
|
||||||
|
|
||||||
|
Returns ``None`` to opt out of the amount check when no usable
|
||||||
|
amount field is present — the alternative (hard-failing the
|
||||||
|
transaction) is worse than skipping validation, and our charge
|
||||||
|
amounts are server-controlled (we send them; Clover doesn't
|
||||||
|
invent new amounts).
|
||||||
|
"""
|
||||||
|
if self.provider_code != 'clover':
|
||||||
|
return super()._extract_amount_data(payment_data)
|
||||||
|
|
||||||
|
amount_minor = payment_data.get('amount')
|
||||||
|
currency_code = payment_data.get('currency')
|
||||||
|
# Some code paths put the raw Clover response under 'raw' or pass
|
||||||
|
# the whole charge dict — try those fallbacks before opting out.
|
||||||
|
if amount_minor is None and isinstance(payment_data.get('raw'), dict):
|
||||||
|
amount_minor = payment_data['raw'].get('amount')
|
||||||
|
currency_code = payment_data['raw'].get('currency') or currency_code
|
||||||
|
if amount_minor is None or not currency_code:
|
||||||
|
return None # opt out of amount validation (server-controlled)
|
||||||
|
|
||||||
|
# Clover amounts are in minor units (cents for USD/CAD, no
|
||||||
|
# decimals for JPY/KRW). Convert back to major units using the
|
||||||
|
# transaction's currency to know the divisor.
|
||||||
|
from odoo.addons.fusion_clover import utils as clover_utils
|
||||||
|
amount_major = clover_utils.parse_clover_amount(
|
||||||
|
int(amount_minor), self.currency_id,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
'amount': float(amount_major),
|
||||||
|
'currency_code': str(currency_code).upper(),
|
||||||
|
}
|
||||||
|
|
||||||
def _apply_updates(self, payment_data):
|
def _apply_updates(self, payment_data):
|
||||||
"""Override of `payment` to update the transaction based on Clover data."""
|
"""Override of `payment` to update the transaction based on Clover data."""
|
||||||
if self.provider_code != 'clover':
|
if self.provider_code != 'clover':
|
||||||
|
|||||||
BIN
fusion_clover/static/description/fusion_clover.png
Normal file
BIN
fusion_clover/static/description/fusion_clover.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
@@ -1,33 +1,40 @@
|
|||||||
/** @odoo-module **/
|
/** @odoo-module **/
|
||||||
|
|
||||||
import { _t } from '@web/core/l10n/translation';
|
import { _t } from "@web/core/l10n/translation";
|
||||||
import { patch } from '@web/core/utils/patch';
|
import { patch } from "@web/core/utils/patch";
|
||||||
import { rpc } from '@web/core/network/rpc';
|
import { rpc } from "@web/core/network/rpc";
|
||||||
|
import { loadJS } from "@web/core/assets";
|
||||||
|
|
||||||
import { PaymentForm } from '@payment/interactions/payment_form';
|
import { PaymentForm } from "@payment/interactions/payment_form";
|
||||||
|
|
||||||
|
const CLOVER_SDK_URL_TEST = "https://checkout.sandbox.dev.clover.com/sdk.js";
|
||||||
|
const CLOVER_SDK_URL_PROD = "https://checkout.clover.com/sdk.js";
|
||||||
|
|
||||||
patch(PaymentForm.prototype, {
|
patch(PaymentForm.prototype, {
|
||||||
|
|
||||||
setup() {
|
setup() {
|
||||||
super.setup();
|
super.setup();
|
||||||
this.cloverFormData = {};
|
this.cloverFormData = {};
|
||||||
this._detectedCardType = 'other';
|
this.cloverInstance = null;
|
||||||
this._selectedCardType = 'other';
|
this.cloverElements = null;
|
||||||
|
this.cloverMountedElements = {};
|
||||||
|
this._detectedCardType = "other";
|
||||||
|
this._selectedCardType = "other";
|
||||||
},
|
},
|
||||||
|
|
||||||
// #=== DOM MANIPULATION ===#
|
// #=== DOM MANIPULATION ===#
|
||||||
|
|
||||||
async _prepareInlineForm(providerId, providerCode, paymentOptionId, paymentMethodCode, flow) {
|
async _prepareInlineForm(providerId, providerCode, paymentOptionId, paymentMethodCode, flow) {
|
||||||
if (providerCode !== 'clover') {
|
if (providerCode !== "clover") {
|
||||||
await super._prepareInlineForm(...arguments);
|
await super._prepareInlineForm(...arguments);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (flow === 'token') {
|
if (flow === "token") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this._setPaymentFlow('direct');
|
this._setPaymentFlow("direct");
|
||||||
|
|
||||||
const radio = document.querySelector('input[name="o_payment_radio"]:checked');
|
const radio = document.querySelector('input[name="o_payment_radio"]:checked');
|
||||||
const inlineForm = this._getInlineForm(radio);
|
const inlineForm = this._getInlineForm(radio);
|
||||||
@@ -37,45 +44,135 @@ patch(PaymentForm.prototype, {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawValues = cloverContainer.dataset['cloverInlineFormValues'];
|
const rawValues = cloverContainer.dataset["cloverInlineFormValues"];
|
||||||
if (rawValues) {
|
if (rawValues) {
|
||||||
this.cloverFormData = JSON.parse(rawValues);
|
this.cloverFormData = JSON.parse(rawValues);
|
||||||
}
|
}
|
||||||
|
|
||||||
this._setupCardFormatting(cloverContainer);
|
|
||||||
this._setupTerminalToggle(cloverContainer);
|
this._setupTerminalToggle(cloverContainer);
|
||||||
this._setupSurcharge(cloverContainer);
|
this._setupSurcharge(cloverContainer);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this._loadCloverSDK(this.cloverFormData.is_test);
|
||||||
|
this._initialiseCloverIframe(cloverContainer);
|
||||||
|
} catch (error) {
|
||||||
|
this._showCloverSdkError(
|
||||||
|
cloverContainer,
|
||||||
|
error?.message || _t("Could not initialise the Clover payment form."),
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
_detectCardBrand(number) {
|
async _loadCloverSDK(isTest) {
|
||||||
const num = (number || '').replace(/\D/g, '');
|
const url = isTest ? CLOVER_SDK_URL_TEST : CLOVER_SDK_URL_PROD;
|
||||||
if (num.length < 2) return 'other';
|
await loadJS(url);
|
||||||
const prefix2 = num.substring(0, 2);
|
if (typeof window.Clover === "undefined") {
|
||||||
if (prefix2 === '34' || prefix2 === '37') return 'amex';
|
throw new Error(_t("Clover SDK failed to load."));
|
||||||
if (num[0] === '4') return 'visa';
|
|
||||||
const p2 = parseInt(prefix2, 10);
|
|
||||||
if (p2 >= 51 && p2 <= 55) return 'mastercard';
|
|
||||||
if (num.length >= 4) {
|
|
||||||
const p4 = parseInt(num.substring(0, 4), 10);
|
|
||||||
if (p4 >= 2221 && p4 <= 2720) return 'mastercard';
|
|
||||||
}
|
}
|
||||||
return 'other';
|
},
|
||||||
|
|
||||||
|
_initialiseCloverIframe(container) {
|
||||||
|
const data = this.cloverFormData;
|
||||||
|
const apiAccessKey = data.public_key;
|
||||||
|
const merchantId = data.merchant_id;
|
||||||
|
|
||||||
|
if (!apiAccessKey) {
|
||||||
|
this._showCloverSdkError(
|
||||||
|
container,
|
||||||
|
_t("This Clover provider has no Public API Key (PAKMS) configured. Add it on the payment provider record before customers can pay online."),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cloverConfig = { merchantId };
|
||||||
|
if (data.locale) {
|
||||||
|
cloverConfig.locale = data.locale;
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line no-undef
|
||||||
|
this.cloverInstance = new Clover(apiAccessKey, cloverConfig);
|
||||||
|
this.cloverElements = this.cloverInstance.elements();
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
input: {
|
||||||
|
fontSize: "16px",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
color: "#212529",
|
||||||
|
padding: "10px 12px",
|
||||||
|
},
|
||||||
|
".invalid": { color: "#dc3545" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const mounts = [
|
||||||
|
["CARD_NUMBER", "#clover-card-number", "#clover-card-number-errors"],
|
||||||
|
["CARD_DATE", "#clover-card-date", "#clover-card-date-errors"],
|
||||||
|
["CARD_CVV", "#clover-card-cvv", "#clover-card-cvv-errors"],
|
||||||
|
["CARD_POSTAL_CODE", "#clover-card-postal-code", "#clover-card-postal-code-errors"],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [type, mountSelector, errorSelector] of mounts) {
|
||||||
|
const element = this.cloverElements.create(type, styles);
|
||||||
|
element.mount(mountSelector);
|
||||||
|
element.addEventListener("change", (event) => {
|
||||||
|
const errorEl = container.querySelector(errorSelector);
|
||||||
|
if (!errorEl) return;
|
||||||
|
if (event && event[type] && event[type].error) {
|
||||||
|
errorEl.textContent = event[type].error;
|
||||||
|
} else {
|
||||||
|
errorEl.textContent = "";
|
||||||
|
}
|
||||||
|
if (type === "CARD_NUMBER" && event && event.CARD_NUMBER) {
|
||||||
|
const brand = (event.CARD_NUMBER.brand || "").toLowerCase();
|
||||||
|
if (brand) {
|
||||||
|
const mapped = this._mapCloverBrandToOdoo(brand);
|
||||||
|
if (mapped !== this._detectedCardType) {
|
||||||
|
this._detectedCardType = mapped;
|
||||||
|
if (mapped !== "other") {
|
||||||
|
this._selectedCardType = mapped;
|
||||||
|
}
|
||||||
|
this._updateSurchargeDisplay(container);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.cloverMountedElements[type] = element;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_mapCloverBrandToOdoo(brand) {
|
||||||
|
const map = {
|
||||||
|
visa: "visa",
|
||||||
|
mastercard: "mastercard",
|
||||||
|
mc: "mastercard",
|
||||||
|
amex: "amex",
|
||||||
|
"american_express": "amex",
|
||||||
|
discover: "other",
|
||||||
|
"diners_club": "other",
|
||||||
|
jcb: "other",
|
||||||
|
unionpay: "other",
|
||||||
|
unknown: "other",
|
||||||
|
};
|
||||||
|
return map[brand] || "other";
|
||||||
|
},
|
||||||
|
|
||||||
|
_showCloverSdkError(container, message) {
|
||||||
|
const banner = container.querySelector("#clover-sdk-error");
|
||||||
|
const messageEl = container.querySelector("#clover-sdk-error-message");
|
||||||
|
if (banner) banner.classList.remove("d-none");
|
||||||
|
if (messageEl) messageEl.textContent = message;
|
||||||
},
|
},
|
||||||
|
|
||||||
_setupSurcharge(container) {
|
_setupSurcharge(container) {
|
||||||
const surchargeConfig = this.cloverFormData.surcharge;
|
const surchargeConfig = this.cloverFormData.surcharge;
|
||||||
if (!surchargeConfig || !surchargeConfig.enabled) return;
|
if (!surchargeConfig || !surchargeConfig.enabled) return;
|
||||||
|
|
||||||
const cardTypeSection = container.querySelector('.o_clover_card_type_section');
|
const cardTypeSection = container.querySelector(".o_clover_card_type_section");
|
||||||
const surchargeNotice = container.querySelector('.o_clover_surcharge_notice');
|
|
||||||
|
|
||||||
if (cardTypeSection) {
|
if (cardTypeSection) {
|
||||||
cardTypeSection.style.display = 'block';
|
cardTypeSection.style.display = "block";
|
||||||
}
|
}
|
||||||
|
|
||||||
const cardTypeRadios = container.querySelectorAll('input[name="clover_card_type"]');
|
const cardTypeRadios = container.querySelectorAll('input[name="clover_card_type"]');
|
||||||
cardTypeRadios.forEach(radio => {
|
cardTypeRadios.forEach((radio) => {
|
||||||
radio.addEventListener('change', () => {
|
radio.addEventListener("change", () => {
|
||||||
this._selectedCardType = radio.value;
|
this._selectedCardType = radio.value;
|
||||||
this._updateSurchargeDisplay(container);
|
this._updateSurchargeDisplay(container);
|
||||||
});
|
});
|
||||||
@@ -88,137 +185,82 @@ patch(PaymentForm.prototype, {
|
|||||||
const surchargeConfig = this.cloverFormData.surcharge;
|
const surchargeConfig = this.cloverFormData.surcharge;
|
||||||
if (!surchargeConfig || !surchargeConfig.enabled) return;
|
if (!surchargeConfig || !surchargeConfig.enabled) return;
|
||||||
|
|
||||||
const cardType = this._detectedCardType !== 'other'
|
const cardType = this._detectedCardType !== "other"
|
||||||
? this._detectedCardType
|
? this._detectedCardType
|
||||||
: this._selectedCardType;
|
: this._selectedCardType;
|
||||||
|
|
||||||
const rate = surchargeConfig[cardType] || surchargeConfig['other'] || 0;
|
const rate = surchargeConfig[cardType] || surchargeConfig["other"] || 0;
|
||||||
const amount = this.cloverFormData.minor_amount || 0;
|
const minorAmount = this.cloverFormData.minor_amount || 0;
|
||||||
|
|
||||||
const baseAmount = amount / 100;
|
const baseAmount = minorAmount / 100;
|
||||||
const feeAmount = Math.round(baseAmount * rate) / 100;
|
const feeAmount = Math.round(baseAmount * rate) / 100;
|
||||||
|
|
||||||
const rateEl = container.querySelector('#clover_surcharge_rate');
|
const rateEl = container.querySelector("#clover_surcharge_rate");
|
||||||
const amountEl = container.querySelector('#clover_surcharge_amount');
|
const amountEl = container.querySelector("#clover_surcharge_amount");
|
||||||
const noticeEl = container.querySelector('.o_clover_surcharge_notice');
|
const noticeEl = container.querySelector(".o_clover_surcharge_notice");
|
||||||
|
|
||||||
if (rateEl) rateEl.textContent = rate.toFixed(2);
|
if (rateEl) rateEl.textContent = rate.toFixed(2);
|
||||||
if (amountEl) amountEl.textContent = `$${feeAmount.toFixed(2)}`;
|
if (amountEl) amountEl.textContent = `$${feeAmount.toFixed(2)}`;
|
||||||
if (noticeEl) {
|
if (noticeEl) {
|
||||||
noticeEl.style.display = rate > 0 ? 'block' : 'none';
|
noticeEl.style.display = rate > 0 ? "block" : "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
const radioToCheck = container.querySelector(
|
const radioToCheck = container.querySelector(
|
||||||
`input[name="clover_card_type"][value="${cardType}"]`
|
`input[name="clover_card_type"][value="${cardType}"]`,
|
||||||
);
|
);
|
||||||
if (radioToCheck && !radioToCheck.checked) {
|
if (radioToCheck && !radioToCheck.checked) {
|
||||||
radioToCheck.checked = true;
|
radioToCheck.checked = true;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
_setupCardFormatting(container) {
|
|
||||||
const cardInput = container.querySelector('#clover_card_number');
|
|
||||||
if (cardInput) {
|
|
||||||
cardInput.addEventListener('input', (e) => {
|
|
||||||
let value = e.target.value.replace(/\D/g, '');
|
|
||||||
let formatted = '';
|
|
||||||
for (let i = 0; i < value.length && i < 16; i++) {
|
|
||||||
if (i > 0 && i % 4 === 0) {
|
|
||||||
formatted += ' ';
|
|
||||||
}
|
|
||||||
formatted += value[i];
|
|
||||||
}
|
|
||||||
e.target.value = formatted;
|
|
||||||
|
|
||||||
const detected = this._detectCardBrand(value);
|
|
||||||
if (detected !== this._detectedCardType) {
|
|
||||||
this._detectedCardType = detected;
|
|
||||||
if (detected !== 'other') {
|
|
||||||
this._selectedCardType = detected;
|
|
||||||
}
|
|
||||||
this._updateSurchargeDisplay(
|
|
||||||
e.target.closest('.o_clover_payment_form')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const expiryInput = container.querySelector('#clover_expiry');
|
|
||||||
if (expiryInput) {
|
|
||||||
expiryInput.addEventListener('input', (e) => {
|
|
||||||
let value = e.target.value.replace(/\D/g, '');
|
|
||||||
if (value.length >= 2) {
|
|
||||||
value = value.substring(0, 2) + '/' + value.substring(2, 4);
|
|
||||||
}
|
|
||||||
e.target.value = value;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
_setupTerminalToggle(container) {
|
_setupTerminalToggle(container) {
|
||||||
const terminalCheckbox = container.querySelector('#clover_use_terminal');
|
const terminalCheckbox = container.querySelector("#clover_use_terminal");
|
||||||
const terminalSelect = container.querySelector('#clover_terminal_select_wrapper');
|
const terminalSelect = container.querySelector("#clover_terminal_select_wrapper");
|
||||||
const cardFields = container.querySelectorAll(
|
const iframeForm = container.querySelector(".o_clover_iframe_form");
|
||||||
'#clover_card_number, #clover_expiry, #clover_cvv, #clover_cardholder'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!terminalCheckbox) {
|
if (!terminalCheckbox) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
terminalCheckbox.addEventListener('change', () => {
|
terminalCheckbox.addEventListener("change", () => {
|
||||||
if (terminalCheckbox.checked) {
|
if (terminalCheckbox.checked) {
|
||||||
if (terminalSelect) {
|
if (terminalSelect) terminalSelect.style.display = "block";
|
||||||
terminalSelect.style.display = 'block';
|
if (iframeForm) iframeForm.style.display = "none";
|
||||||
}
|
|
||||||
cardFields.forEach(f => {
|
|
||||||
f.closest('.mb-3').style.display = 'none';
|
|
||||||
f.removeAttribute('required');
|
|
||||||
});
|
|
||||||
this._loadTerminals(container);
|
this._loadTerminals(container);
|
||||||
} else {
|
} else {
|
||||||
if (terminalSelect) {
|
if (terminalSelect) terminalSelect.style.display = "none";
|
||||||
terminalSelect.style.display = 'none';
|
if (iframeForm) iframeForm.style.display = "block";
|
||||||
}
|
|
||||||
cardFields.forEach(f => {
|
|
||||||
f.closest('.mb-3').style.display = 'block';
|
|
||||||
if (f.id !== 'clover_cardholder') {
|
|
||||||
f.setAttribute('required', 'required');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async _loadTerminals(container) {
|
async _loadTerminals(container) {
|
||||||
const selectEl = container.querySelector('#clover_terminal_select');
|
const selectEl = container.querySelector("#clover_terminal_select");
|
||||||
if (!selectEl || selectEl.options.length > 1) {
|
if (!selectEl || selectEl.options.length > 1) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const terminals = await rpc('/payment/clover/terminals', {
|
const terminals = await rpc("/payment/clover/terminals", {
|
||||||
provider_id: this.cloverFormData.provider_id,
|
provider_id: this.cloverFormData.provider_id,
|
||||||
});
|
});
|
||||||
|
selectEl.innerHTML = "";
|
||||||
selectEl.innerHTML = '';
|
|
||||||
if (terminals && terminals.length > 0) {
|
if (terminals && terminals.length > 0) {
|
||||||
terminals.forEach(t => {
|
terminals.forEach((t) => {
|
||||||
const option = document.createElement('option');
|
const option = document.createElement("option");
|
||||||
option.value = t.id;
|
option.value = t.id;
|
||||||
option.textContent = `${t.name} (${t.status})`;
|
option.textContent = `${t.name} (${t.status})`;
|
||||||
selectEl.appendChild(option);
|
selectEl.appendChild(option);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const option = document.createElement('option');
|
const option = document.createElement("option");
|
||||||
option.value = '';
|
option.value = "";
|
||||||
option.textContent = _t('No terminals available');
|
option.textContent = _t("No terminals available");
|
||||||
selectEl.appendChild(option);
|
selectEl.appendChild(option);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
const option = document.createElement('option');
|
const option = document.createElement("option");
|
||||||
option.value = '';
|
option.value = "";
|
||||||
option.textContent = _t('Failed to load terminals');
|
option.textContent = _t("Failed to load terminals");
|
||||||
selectEl.appendChild(option);
|
selectEl.appendChild(option);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -226,18 +268,18 @@ patch(PaymentForm.prototype, {
|
|||||||
// #=== PAYMENT FLOW ===#
|
// #=== PAYMENT FLOW ===#
|
||||||
|
|
||||||
async _initiatePaymentFlow(providerCode, paymentOptionId, paymentMethodCode, flow) {
|
async _initiatePaymentFlow(providerCode, paymentOptionId, paymentMethodCode, flow) {
|
||||||
if (providerCode !== 'clover' || flow === 'token') {
|
if (providerCode !== "clover" || flow === "token") {
|
||||||
await super._initiatePaymentFlow(...arguments);
|
await super._initiatePaymentFlow(...arguments);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const radio = document.querySelector('input[name="o_payment_radio"]:checked');
|
const radio = document.querySelector('input[name="o_payment_radio"]:checked');
|
||||||
const inlineForm = this._getInlineForm(radio);
|
const inlineForm = this._getInlineForm(radio);
|
||||||
const useTerminal = inlineForm.querySelector('#clover_use_terminal');
|
const useTerminal = inlineForm.querySelector("#clover_use_terminal");
|
||||||
|
|
||||||
if (useTerminal && useTerminal.checked) {
|
if (useTerminal && useTerminal.checked) {
|
||||||
const terminalId = inlineForm.querySelector('#clover_terminal_select').value;
|
const terminalSelect = inlineForm.querySelector("#clover_terminal_select");
|
||||||
if (!terminalId) {
|
if (!terminalSelect || !terminalSelect.value) {
|
||||||
this._displayErrorDialog(
|
this._displayErrorDialog(
|
||||||
_t("Terminal Required"),
|
_t("Terminal Required"),
|
||||||
_t("Please select a terminal device."),
|
_t("Please select a terminal device."),
|
||||||
@@ -245,64 +287,27 @@ patch(PaymentForm.prototype, {
|
|||||||
this._enableButton();
|
this._enableButton();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else if (!this.cloverInstance) {
|
||||||
const validationError = this._validateCardInputs(inlineForm);
|
this._displayErrorDialog(
|
||||||
if (validationError) {
|
_t("Payment form not ready"),
|
||||||
this._displayErrorDialog(
|
_t("The Clover payment form has not finished loading. Please wait a moment and try again."),
|
||||||
_t("Invalid Card Details"),
|
);
|
||||||
validationError,
|
this._enableButton();
|
||||||
);
|
return;
|
||||||
this._enableButton();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await super._initiatePaymentFlow(...arguments);
|
await super._initiatePaymentFlow(...arguments);
|
||||||
},
|
},
|
||||||
|
|
||||||
_validateCardInputs(inlineForm) {
|
|
||||||
const cardNumber = inlineForm.querySelector('#clover_card_number');
|
|
||||||
const expiry = inlineForm.querySelector('#clover_expiry');
|
|
||||||
const cvv = inlineForm.querySelector('#clover_cvv');
|
|
||||||
|
|
||||||
const cardDigits = cardNumber.value.replace(/\D/g, '');
|
|
||||||
if (cardDigits.length < 13 || cardDigits.length > 19) {
|
|
||||||
return _t("Please enter a valid card number.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const expiryValue = expiry.value;
|
|
||||||
if (!/^\d{2}\/\d{2}$/.test(expiryValue)) {
|
|
||||||
return _t("Please enter a valid expiry date (MM/YY).");
|
|
||||||
}
|
|
||||||
|
|
||||||
const [month, year] = expiryValue.split('/').map(Number);
|
|
||||||
if (month < 1 || month > 12) {
|
|
||||||
return _t("Invalid expiry month.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const expiryDate = new Date(2000 + year, month);
|
|
||||||
if (expiryDate <= now) {
|
|
||||||
return _t("Card has expired.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const cvvValue = cvv.value.replace(/\D/g, '');
|
|
||||||
if (cvvValue.length < 3 || cvvValue.length > 4) {
|
|
||||||
return _t("Please enter a valid CVV.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
|
|
||||||
async _processDirectFlow(providerCode, paymentOptionId, paymentMethodCode, processingValues) {
|
async _processDirectFlow(providerCode, paymentOptionId, paymentMethodCode, processingValues) {
|
||||||
if (providerCode !== 'clover') {
|
if (providerCode !== "clover") {
|
||||||
await super._processDirectFlow(...arguments);
|
await super._processDirectFlow(...arguments);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const radio = document.querySelector('input[name="o_payment_radio"]:checked');
|
const radio = document.querySelector('input[name="o_payment_radio"]:checked');
|
||||||
const inlineForm = this._getInlineForm(radio);
|
const inlineForm = this._getInlineForm(radio);
|
||||||
const useTerminal = inlineForm.querySelector('#clover_use_terminal');
|
const useTerminal = inlineForm.querySelector("#clover_use_terminal");
|
||||||
|
|
||||||
if (useTerminal && useTerminal.checked) {
|
if (useTerminal && useTerminal.checked) {
|
||||||
await this._processTerminalPayment(processingValues, inlineForm);
|
await this._processTerminalPayment(processingValues, inlineForm);
|
||||||
@@ -313,36 +318,73 @@ patch(PaymentForm.prototype, {
|
|||||||
|
|
||||||
_getSelectedCardType(inlineForm) {
|
_getSelectedCardType(inlineForm) {
|
||||||
const checked = inlineForm.querySelector('input[name="clover_card_type"]:checked');
|
const checked = inlineForm.querySelector('input[name="clover_card_type"]:checked');
|
||||||
return checked ? checked.value : 'other';
|
return checked ? checked.value : "other";
|
||||||
},
|
},
|
||||||
|
|
||||||
async _processCardPayment(processingValues, inlineForm) {
|
async _processCardPayment(processingValues, inlineForm) {
|
||||||
const cardNumber = inlineForm.querySelector('#clover_card_number').value.replace(/\D/g, '');
|
if (!this.cloverInstance) {
|
||||||
const expiry = inlineForm.querySelector('#clover_expiry').value;
|
this._displayErrorDialog(
|
||||||
const cvv = inlineForm.querySelector('#clover_cvv').value;
|
_t("Payment form error"),
|
||||||
const cardholder = inlineForm.querySelector('#clover_cardholder').value;
|
_t("Clover SDK has not been initialised. Please reload the page."),
|
||||||
const cardType = this._detectedCardType !== 'other'
|
);
|
||||||
|
this._enableButton();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardType = this._detectedCardType !== "other"
|
||||||
? this._detectedCardType
|
? this._detectedCardType
|
||||||
: this._getSelectedCardType(inlineForm);
|
: this._getSelectedCardType(inlineForm);
|
||||||
|
|
||||||
const [expMonth, expYear] = expiry.split('/').map(Number);
|
let result;
|
||||||
|
try {
|
||||||
|
result = await this.cloverInstance.createToken();
|
||||||
|
} catch (error) {
|
||||||
|
this._displayErrorDialog(
|
||||||
|
_t("Card validation error"),
|
||||||
|
error?.message || _t("Could not validate the card details."),
|
||||||
|
);
|
||||||
|
this._enableButton();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result || result.errors) {
|
||||||
|
const messages = [];
|
||||||
|
if (result && result.errors) {
|
||||||
|
Object.values(result.errors).forEach((value) => {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
messages.push(value);
|
||||||
|
} else if (value && value.error) {
|
||||||
|
messages.push(value.error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this._displayErrorDialog(
|
||||||
|
_t("Invalid Card Details"),
|
||||||
|
messages.join(" ") || _t("Please check the card details and try again."),
|
||||||
|
);
|
||||||
|
this._enableButton();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = result.token;
|
||||||
|
if (!token) {
|
||||||
|
this._displayErrorDialog(
|
||||||
|
_t("Tokenization Failed"),
|
||||||
|
_t("Clover did not return a card token. Please try again."),
|
||||||
|
);
|
||||||
|
this._enableButton();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await rpc('/payment/clover/process_card', {
|
const response = await rpc("/payment/clover/process_card", {
|
||||||
reference: processingValues.reference,
|
reference: processingValues.reference,
|
||||||
card_number: cardNumber,
|
card_token: token,
|
||||||
exp_month: expMonth,
|
|
||||||
exp_year: 2000 + expYear,
|
|
||||||
cvv: cvv,
|
|
||||||
cardholder_name: cardholder,
|
|
||||||
card_type: cardType,
|
card_type: cardType,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.error) {
|
if (response.error) {
|
||||||
this._displayErrorDialog(
|
this._displayErrorDialog(_t("Payment Failed"), response.error);
|
||||||
_t("Payment Failed"),
|
|
||||||
result.error,
|
|
||||||
);
|
|
||||||
this._enableButton();
|
this._enableButton();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -358,25 +400,19 @@ patch(PaymentForm.prototype, {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async _processTerminalPayment(processingValues, inlineForm) {
|
async _processTerminalPayment(processingValues, inlineForm) {
|
||||||
const terminalId = inlineForm.querySelector('#clover_terminal_select').value;
|
const terminalId = inlineForm.querySelector("#clover_terminal_select").value;
|
||||||
const cardType = this._getSelectedCardType(inlineForm);
|
const cardType = this._getSelectedCardType(inlineForm);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await rpc('/payment/clover/send_to_terminal', {
|
const result = await rpc("/payment/clover/send_to_terminal", {
|
||||||
reference: processingValues.reference,
|
reference: processingValues.reference,
|
||||||
terminal_id: parseInt(terminalId),
|
terminal_id: parseInt(terminalId),
|
||||||
card_type: cardType,
|
card_type: cardType,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
this._displayErrorDialog(
|
this._displayErrorDialog(_t("Terminal Payment Failed"), result.error);
|
||||||
_t("Terminal Payment Failed"),
|
|
||||||
result.error,
|
|
||||||
);
|
|
||||||
this._enableButton();
|
this._enableButton();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this._showTerminalWaitingScreen(processingValues, terminalId);
|
this._showTerminalWaitingScreen(processingValues, terminalId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this._displayErrorDialog(
|
this._displayErrorDialog(
|
||||||
@@ -388,7 +424,7 @@ patch(PaymentForm.prototype, {
|
|||||||
},
|
},
|
||||||
|
|
||||||
_showTerminalWaitingScreen(processingValues, terminalId) {
|
_showTerminalWaitingScreen(processingValues, terminalId) {
|
||||||
const container = document.querySelector('.o_clover_payment_form');
|
const container = document.querySelector(".o_clover_payment_form");
|
||||||
if (container) {
|
if (container) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="text-center p-4">
|
<div class="text-center p-4">
|
||||||
@@ -405,14 +441,12 @@ patch(PaymentForm.prototype, {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
this._pollTerminalStatus(processingValues, terminalId);
|
this._pollTerminalStatus(processingValues, terminalId);
|
||||||
},
|
},
|
||||||
|
|
||||||
async _pollTerminalStatus(processingValues, terminalId, attempt = 0) {
|
async _pollTerminalStatus(processingValues, terminalId, attempt = 0) {
|
||||||
const maxAttempts = 60;
|
const maxAttempts = 60;
|
||||||
const pollInterval = 3000;
|
const pollInterval = 3000;
|
||||||
|
|
||||||
if (attempt >= maxAttempts) {
|
if (attempt >= maxAttempts) {
|
||||||
this._displayErrorDialog(
|
this._displayErrorDialog(
|
||||||
_t("Timeout"),
|
_t("Timeout"),
|
||||||
@@ -421,26 +455,26 @@ patch(PaymentForm.prototype, {
|
|||||||
this._enableButton();
|
this._enableButton();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await rpc('/payment/clover/terminal_status', {
|
const result = await rpc("/payment/clover/terminal_status", {
|
||||||
reference: processingValues.reference,
|
reference: processingValues.reference,
|
||||||
terminal_id: parseInt(terminalId),
|
terminal_id: parseInt(terminalId),
|
||||||
});
|
});
|
||||||
|
const statusEl = document.getElementById("clover_terminal_status");
|
||||||
const statusEl = document.getElementById('clover_terminal_status');
|
if (
|
||||||
|
result.status === "CLOSED" || result.status === "CAPTURED"
|
||||||
if (result.status === 'CLOSED' || result.status === 'CAPTURED'
|
|| result.status === "AUTH" || result.status === "AUTHORIZED"
|
||||||
|| result.status === 'AUTH' || result.status === 'AUTHORIZED') {
|
) {
|
||||||
if (statusEl) {
|
if (statusEl) {
|
||||||
statusEl.textContent = _t("Payment completed! Redirecting...");
|
statusEl.textContent = _t("Payment completed! Redirecting...");
|
||||||
}
|
}
|
||||||
window.location.href = processingValues.return_url;
|
window.location.href = processingValues.return_url;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
if (result.status === 'DECLINED' || result.status === 'FAILED'
|
result.status === "DECLINED" || result.status === "FAILED"
|
||||||
|| result.status === 'FAIL') {
|
|| result.status === "FAIL"
|
||||||
|
) {
|
||||||
this._displayErrorDialog(
|
this._displayErrorDialog(
|
||||||
_t("Payment Declined"),
|
_t("Payment Declined"),
|
||||||
_t("The payment was declined at the terminal."),
|
_t("The payment was declined at the terminal."),
|
||||||
@@ -448,11 +482,9 @@ patch(PaymentForm.prototype, {
|
|||||||
this._enableButton();
|
this._enableButton();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (statusEl) {
|
if (statusEl) {
|
||||||
statusEl.textContent = _t("Status: ") + (result.status || _t("Pending"));
|
statusEl.textContent = _t("Status: ") + (result.status || _t("Pending"));
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(
|
setTimeout(
|
||||||
() => this._pollTerminalStatus(processingValues, terminalId, attempt + 1),
|
() => this._pollTerminalStatus(processingValues, terminalId, attempt + 1),
|
||||||
pollInterval,
|
pollInterval,
|
||||||
|
|||||||
@@ -155,23 +155,45 @@ def build_charge_payload(amount, currency, source_token, capture=True,
|
|||||||
|
|
||||||
|
|
||||||
def build_refund_payload(charge_id, amount=None, currency=None, reason=''):
|
def build_refund_payload(charge_id, amount=None, currency=None, reason=''):
|
||||||
"""Build a Clover refund payload.
|
"""Build a Clover ``/v1/refunds`` payload.
|
||||||
|
|
||||||
|
The Clover Ecommerce ``/v1/refunds`` endpoint accepts ONLY the
|
||||||
|
``charge`` field — including any other field triggers a 400
|
||||||
|
"Invalid JSON format" response. ``amount`` and ``reason`` arguments
|
||||||
|
are accepted for backwards-compat but ignored by this endpoint.
|
||||||
|
|
||||||
|
For partial refunds, callers should use the
|
||||||
|
``/v1/payments/{paymentId}/refunds`` endpoint instead, which takes
|
||||||
|
``{"amount": <cents>}`` or ``{"fullRefund": true}``.
|
||||||
|
|
||||||
|
https://docs.clover.com/dev/docs/ecommerce-refunding-payments
|
||||||
|
|
||||||
:param str charge_id: The Clover charge ID to refund.
|
:param str charge_id: The Clover charge ID to refund.
|
||||||
:param float amount: Optional partial refund amount in major currency units.
|
:param float amount: IGNORED on this endpoint (full refund only).
|
||||||
:param recordset currency: Optional currency record (needed for partial refunds).
|
Use a /payments/{id}/refunds call for partial.
|
||||||
:param str reason: Optional reason for the refund.
|
:param recordset currency: IGNORED.
|
||||||
|
:param str reason: IGNORED on this endpoint.
|
||||||
:return: The Clover-formatted refund payload.
|
:return: The Clover-formatted refund payload.
|
||||||
:rtype: dict
|
:rtype: dict
|
||||||
"""
|
"""
|
||||||
payload = {
|
return {'charge': charge_id}
|
||||||
'charge': charge_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
def build_payment_refund_payload(amount=None, currency=None, full_refund=False):
|
||||||
|
"""Build a Clover ``/v1/payments/{paymentId}/refunds`` payload.
|
||||||
|
|
||||||
|
Used for partial refunds (the ``/v1/refunds`` endpoint is full-refund
|
||||||
|
only). Either pass ``full_refund=True`` to refund the entire payment,
|
||||||
|
or ``amount`` (with ``currency``) for a partial.
|
||||||
|
|
||||||
|
:param float amount: Partial refund amount in major currency units.
|
||||||
|
:param recordset currency: Currency record for the partial refund.
|
||||||
|
:param bool full_refund: If True, refund the entire payment.
|
||||||
|
:return: The Clover-formatted payment-refund payload.
|
||||||
|
:rtype: dict
|
||||||
|
"""
|
||||||
|
if full_refund:
|
||||||
|
return {'fullRefund': True}
|
||||||
if amount is not None and currency:
|
if amount is not None and currency:
|
||||||
payload['amount'] = format_clover_amount(amount, currency)
|
return {'amount': format_clover_amount(amount, currency)}
|
||||||
|
return {'fullRefund': True}
|
||||||
if reason:
|
|
||||||
payload['reason'] = reason
|
|
||||||
|
|
||||||
return payload
|
|
||||||
|
|||||||
@@ -100,7 +100,7 @@
|
|||||||
name="action_sync_terminals"
|
name="action_sync_terminals"
|
||||||
class="btn-secondary"
|
class="btn-secondary"
|
||||||
icon="fa-refresh"
|
icon="fa-refresh"
|
||||||
invisible="not clover_merchant_id or (not clover_rest_api_token and not clover_api_key)"
|
invisible="not clover_merchant_id or (not clover_rest_api_token and not clover_api_key and not clover_oauth_access_token)"
|
||||||
colspan="2"/>
|
colspan="2"/>
|
||||||
</xpath>
|
</xpath>
|
||||||
</field>
|
</field>
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<odoo>
|
<odoo>
|
||||||
|
|
||||||
<!-- Inline payment form template for Clover -->
|
<!-- Inline payment form template for Clover.
|
||||||
|
|
||||||
|
The card data fields are RENDERED BY CLOVER in iframes mounted
|
||||||
|
into the empty <div id="clover-card-*"> containers below. Card
|
||||||
|
data NEVER touches Odoo - the Clover iframe SDK posts directly
|
||||||
|
to Clover's tokenization endpoint and returns a clv_xxx token
|
||||||
|
which is what we then send to the backend. This is the only
|
||||||
|
way to keep Westin / NEXA out of PCI SAQ-D scope. -->
|
||||||
<template id="inline_form">
|
<template id="inline_form">
|
||||||
<t t-set="inline_form_values"
|
<t t-set="inline_form_values"
|
||||||
t-value="provider_sudo._clover_get_inline_form_values(
|
t-value="provider_sudo._clover_get_inline_form_values(
|
||||||
@@ -16,8 +23,10 @@
|
|||||||
class="o_clover_payment_form"
|
class="o_clover_payment_form"
|
||||||
t-att-data-clover-inline-form-values="inline_form_values">
|
t-att-data-clover-inline-form-values="inline_form_values">
|
||||||
|
|
||||||
<!-- Terminal toggle -->
|
<!-- Terminal toggle (back-office staff or in-person checkout) -->
|
||||||
<div class="mb-3 form-check">
|
<div class="mb-3 form-check"
|
||||||
|
name="o_clover_terminal_toggle_wrapper"
|
||||||
|
style="display:none;">
|
||||||
<input type="checkbox" class="form-check-input"
|
<input type="checkbox" class="form-check-input"
|
||||||
id="clover_use_terminal" name="use_terminal"/>
|
id="clover_use_terminal" name="use_terminal"/>
|
||||||
<label class="form-check-label" for="clover_use_terminal">
|
<label class="form-check-label" for="clover_use_terminal">
|
||||||
@@ -26,7 +35,6 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Terminal select (hidden by default) -->
|
|
||||||
<div class="mb-3" id="clover_terminal_select_wrapper" style="display:none;">
|
<div class="mb-3" id="clover_terminal_select_wrapper" style="display:none;">
|
||||||
<label class="form-label" for="clover_terminal_select">Select Terminal</label>
|
<label class="form-label" for="clover_terminal_select">Select Terminal</label>
|
||||||
<select class="form-select" id="clover_terminal_select" name="terminal_id">
|
<select class="form-select" id="clover_terminal_select" name="terminal_id">
|
||||||
@@ -34,53 +42,51 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Card number input -->
|
<!-- Clover.js iframe mount points (card data lives inside these
|
||||||
<div class="mb-3">
|
iframes, never in our DOM). -->
|
||||||
<label class="form-label" for="clover_card_number">Card Number</label>
|
<div class="o_clover_iframe_form" name="o_clover_iframe_form">
|
||||||
<input type="text" class="form-control"
|
<div class="mb-3">
|
||||||
id="clover_card_number"
|
<label class="form-label" for="clover-card-number">Card Number</label>
|
||||||
name="card_number"
|
<div id="clover-card-number" class="form-control"
|
||||||
placeholder="4111 1111 1111 1111"
|
style="height:42px; padding:0;"/>
|
||||||
maxlength="19"
|
<div class="invalid-feedback d-block"
|
||||||
autocomplete="cc-number"
|
id="clover-card-number-errors" role="alert"/>
|
||||||
required="required"/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Expiry and CVV row -->
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-6">
|
|
||||||
<label class="form-label" for="clover_expiry">Expiry Date</label>
|
|
||||||
<input type="text" class="form-control"
|
|
||||||
id="clover_expiry"
|
|
||||||
name="expiry"
|
|
||||||
placeholder="MM/YY"
|
|
||||||
maxlength="5"
|
|
||||||
autocomplete="cc-exp"
|
|
||||||
required="required"/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6">
|
<div class="row mb-3">
|
||||||
<label class="form-label" for="clover_cvv">CVV</label>
|
<div class="col-6">
|
||||||
<input type="password" class="form-control"
|
<label class="form-label" for="clover-card-date">Expiry (MM/YY)</label>
|
||||||
id="clover_cvv"
|
<div id="clover-card-date" class="form-control"
|
||||||
name="cvv"
|
style="height:42px; padding:0;"/>
|
||||||
placeholder="123"
|
<div class="invalid-feedback d-block"
|
||||||
maxlength="4"
|
id="clover-card-date-errors" role="alert"/>
|
||||||
autocomplete="cc-csc"
|
</div>
|
||||||
required="required"/>
|
<div class="col-6">
|
||||||
|
<label class="form-label" for="clover-card-cvv">CVV</label>
|
||||||
|
<div id="clover-card-cvv" class="form-control"
|
||||||
|
style="height:42px; padding:0;"/>
|
||||||
|
<div class="invalid-feedback d-block"
|
||||||
|
id="clover-card-cvv-errors" role="alert"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="clover-card-postal-code">Postal Code</label>
|
||||||
|
<div id="clover-card-postal-code" class="form-control"
|
||||||
|
style="height:42px; padding:0;"/>
|
||||||
|
<div class="invalid-feedback d-block"
|
||||||
|
id="clover-card-postal-code-errors" role="alert"/>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-warning d-none"
|
||||||
|
id="clover-sdk-error" role="alert">
|
||||||
|
<i class="fa fa-exclamation-triangle me-1"/>
|
||||||
|
<span id="clover-sdk-error-message">
|
||||||
|
Could not load the Clover payment form.
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Cardholder name -->
|
<!-- Card type selector (only visible if surcharge is on AND the
|
||||||
<div class="mb-3">
|
iframe SDK couldn't auto-detect the brand from the typed
|
||||||
<label class="form-label" for="clover_cardholder">Cardholder Name</label>
|
card number, e.g. for "Other"). -->
|
||||||
<input type="text" class="form-control"
|
|
||||||
id="clover_cardholder"
|
|
||||||
name="cardholder_name"
|
|
||||||
placeholder="John Doe"
|
|
||||||
autocomplete="cc-name"/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Card type selector -->
|
|
||||||
<div class="mb-3 o_clover_card_type_section" style="display:none;">
|
<div class="mb-3 o_clover_card_type_section" style="display:none;">
|
||||||
<label class="form-label">Card Type</label>
|
<label class="form-label">Card Type</label>
|
||||||
<div class="d-flex gap-2 flex-wrap">
|
<div class="d-flex gap-2 flex-wrap">
|
||||||
@@ -107,7 +113,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Surcharge notice -->
|
|
||||||
<div class="mb-3 o_clover_surcharge_notice" style="display:none;">
|
<div class="mb-3 o_clover_surcharge_notice" style="display:none;">
|
||||||
<div class="alert alert-info py-2 mb-0">
|
<div class="alert alert-info py-2 mb-0">
|
||||||
<small>
|
<small>
|
||||||
|
|||||||
@@ -23,24 +23,40 @@
|
|||||||
<field name="clover_rest_api_token"
|
<field name="clover_rest_api_token"
|
||||||
password="True"
|
password="True"
|
||||||
placeholder="From Clover Dashboard: Setup > API Tokens"/>
|
placeholder="From Clover Dashboard: Setup > API Tokens"/>
|
||||||
<separator string="OAuth (Optional)"/>
|
<separator string="Nexa Developer App (OAuth)"/>
|
||||||
<field name="clover_app_id"
|
<field name="clover_app_id"
|
||||||
placeholder="App ID (for OAuth flow)"/>
|
placeholder="App ID from Clover Developer Dashboard"/>
|
||||||
<label for="clover_app_secret"/>
|
<label for="clover_app_secret"/>
|
||||||
<div class="o_row" col="2">
|
<div class="o_row" col="2">
|
||||||
<field name="clover_app_secret" password="True"/>
|
<field name="clover_app_secret" password="True"/>
|
||||||
</div>
|
</div>
|
||||||
|
<field name="clover_remote_app_id"
|
||||||
|
placeholder="Remote App ID (RAID) - X-POS-Id header"/>
|
||||||
|
<separator string="OAuth Status (auto-populated by Connect to Clover)"/>
|
||||||
|
<field name="clover_oauth_access_token"
|
||||||
|
string="Access Token Status"
|
||||||
|
readonly="1"
|
||||||
|
password="True"/>
|
||||||
|
<field name="clover_oauth_token_expiry" readonly="1"/>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
<group name="provider_credentials" position="after">
|
<group name="provider_credentials" position="after">
|
||||||
<group string="Clover Actions"
|
<group string="Clover Actions"
|
||||||
invisible="code != 'clover'" name="clover_actions"
|
invisible="code != 'clover'" name="clover_actions"
|
||||||
col="4">
|
col="4">
|
||||||
|
<button string="Connect to Clover"
|
||||||
|
type="object"
|
||||||
|
name="action_clover_oauth_connect"
|
||||||
|
class="btn-primary"
|
||||||
|
icon="fa-link"
|
||||||
|
invisible="not clover_app_id or not clover_app_secret"
|
||||||
|
colspan="2"
|
||||||
|
help="Redirects you to Clover to authorise this Odoo to act on the merchant's behalf. Required for terminal payments and webhooks."/>
|
||||||
<button string="Test Connection"
|
<button string="Test Connection"
|
||||||
type="object"
|
type="object"
|
||||||
name="action_clover_test_connection"
|
name="action_clover_test_connection"
|
||||||
class="btn-primary"
|
class="btn-secondary"
|
||||||
invisible="not clover_merchant_id or (not clover_api_key and not clover_rest_api_token)"
|
invisible="not clover_merchant_id or (not clover_api_key and not clover_rest_api_token and not clover_oauth_access_token)"
|
||||||
colspan="2"/>
|
colspan="2"/>
|
||||||
</group>
|
</group>
|
||||||
<group string="Terminal Settings"
|
<group string="Terminal Settings"
|
||||||
|
|||||||
@@ -10,107 +10,63 @@
|
|||||||
<app data-string="Fusion Clover" string="Fusion Clover" name="fusion_clover"
|
<app data-string="Fusion Clover" string="Fusion Clover" name="fusion_clover"
|
||||||
groups="fusion_clover.group_fusion_clover_admin">
|
groups="fusion_clover.group_fusion_clover_admin">
|
||||||
|
|
||||||
<h2>Credit Card Surcharge</h2>
|
<block title="Credit Card Surcharge"
|
||||||
<div class="row mt-4 o_settings_container">
|
help="Automatically add a credit card processing fee to invoices when collecting payment via Clover.">
|
||||||
<div class="col-12 col-lg-6 o_setting_box">
|
<setting id="fusion_clover_surcharge"
|
||||||
<div class="o_setting_left_pane">
|
string="Enable Surcharge"
|
||||||
<field name="clover_surcharge_enabled"/>
|
help="When enabled, a percentage-based fee line is added to the invoice before the Clover charge is created. The percentage depends on the card brand selected.">
|
||||||
</div>
|
<field name="clover_surcharge_enabled"/>
|
||||||
<div class="o_setting_right_pane">
|
<div class="content-group" invisible="not clover_surcharge_enabled">
|
||||||
<span class="o_form_label">Credit Card Processing Fee</span>
|
<div class="row mt16">
|
||||||
<div class="text-muted">
|
<label for="clover_surcharge_visa_rate"
|
||||||
Automatically add a surcharge line to invoices when collecting payment
|
string="Visa (%)"
|
||||||
via Clover. The fee is calculated as a percentage of the invoice total.
|
class="col-lg-5 o_light_label"/>
|
||||||
|
<field name="clover_surcharge_visa_rate"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="row">
|
||||||
</div>
|
<label for="clover_surcharge_mastercard_rate"
|
||||||
</div>
|
string="Mastercard (%)"
|
||||||
|
class="col-lg-5 o_light_label"/>
|
||||||
<div class="row mt-4 o_settings_container"
|
<field name="clover_surcharge_mastercard_rate"/>
|
||||||
invisible="not clover_surcharge_enabled">
|
|
||||||
<div class="col-12 col-lg-6 o_setting_box">
|
|
||||||
<div class="o_setting_right_pane">
|
|
||||||
<span class="o_form_label">Surcharge Rates by Card Type</span>
|
|
||||||
<div class="text-muted mb-2">
|
|
||||||
Configure the processing fee percentage for each card brand.
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2">
|
<div class="row">
|
||||||
<div class="row mb-2">
|
<label for="clover_surcharge_amex_rate"
|
||||||
<label for="clover_surcharge_visa_rate"
|
string="Amex (%)"
|
||||||
class="col-5 col-form-label">Visa</label>
|
class="col-lg-5 o_light_label"/>
|
||||||
<div class="col-4">
|
<field name="clover_surcharge_amex_rate"/>
|
||||||
<field name="clover_surcharge_visa_rate" class="o_input"/>
|
|
||||||
</div>
|
|
||||||
<div class="col-1 col-form-label">%</div>
|
|
||||||
</div>
|
|
||||||
<div class="row mb-2">
|
|
||||||
<label for="clover_surcharge_mastercard_rate"
|
|
||||||
class="col-5 col-form-label">Mastercard</label>
|
|
||||||
<div class="col-4">
|
|
||||||
<field name="clover_surcharge_mastercard_rate" class="o_input"/>
|
|
||||||
</div>
|
|
||||||
<div class="col-1 col-form-label">%</div>
|
|
||||||
</div>
|
|
||||||
<div class="row mb-2">
|
|
||||||
<label for="clover_surcharge_amex_rate"
|
|
||||||
class="col-5 col-form-label">American Express</label>
|
|
||||||
<div class="col-4">
|
|
||||||
<field name="clover_surcharge_amex_rate" class="o_input"/>
|
|
||||||
</div>
|
|
||||||
<div class="col-1 col-form-label">%</div>
|
|
||||||
</div>
|
|
||||||
<div class="row mb-2">
|
|
||||||
<label for="clover_surcharge_debit_rate"
|
|
||||||
class="col-5 col-form-label">Debit</label>
|
|
||||||
<div class="col-4">
|
|
||||||
<field name="clover_surcharge_debit_rate" class="o_input"/>
|
|
||||||
</div>
|
|
||||||
<div class="col-1 col-form-label">%</div>
|
|
||||||
</div>
|
|
||||||
<div class="row mb-2">
|
|
||||||
<label for="clover_surcharge_other_rate"
|
|
||||||
class="col-5 col-form-label">Other Cards</label>
|
|
||||||
<div class="col-4">
|
|
||||||
<field name="clover_surcharge_other_rate" class="o_input"/>
|
|
||||||
</div>
|
|
||||||
<div class="col-1 col-form-label">%</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="row">
|
||||||
</div>
|
<label for="clover_surcharge_debit_rate"
|
||||||
|
string="Debit (%)"
|
||||||
<div class="col-12 col-lg-6 o_setting_box">
|
class="col-lg-5 o_light_label"/>
|
||||||
<div class="o_setting_right_pane">
|
<field name="clover_surcharge_debit_rate"/>
|
||||||
<span class="o_form_label">Surcharge Product</span>
|
|
||||||
<div class="text-muted mb-2">
|
|
||||||
The service product used for the processing fee invoice line.
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2">
|
<div class="row">
|
||||||
|
<label for="clover_surcharge_other_rate"
|
||||||
|
string="Other (%)"
|
||||||
|
class="col-lg-5 o_light_label"/>
|
||||||
|
<field name="clover_surcharge_other_rate"/>
|
||||||
|
</div>
|
||||||
|
<div class="row mt16">
|
||||||
|
<label for="clover_surcharge_product_id"
|
||||||
|
string="Fee Product"
|
||||||
|
class="col-lg-5 o_light_label"/>
|
||||||
<field name="clover_surcharge_product_id"
|
<field name="clover_surcharge_product_id"
|
||||||
domain="[('type', '=', 'service')]"/>
|
domain="[('type', '=', 'service')]"
|
||||||
|
options="{'no_create': True, 'no_open': True}"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</setting>
|
||||||
</div>
|
<setting id="fusion_clover_provider_link"
|
||||||
|
string="Payment Provider"
|
||||||
<h2>Quick Links</h2>
|
help="Open the Clover payment provider record to configure your Merchant ID, Ecommerce API tokens and REST API token.">
|
||||||
<div class="row mt-4 o_settings_container">
|
<button name="action_open_clover_provider"
|
||||||
<div class="col-12 col-lg-6 o_setting_box">
|
type="object"
|
||||||
<div class="o_setting_right_pane">
|
string="Configure Clover"
|
||||||
<span class="o_form_label">Payment Provider</span>
|
class="btn-link"
|
||||||
<div class="text-muted mb-2">
|
icon="oi-arrow-right"/>
|
||||||
Configure your Clover API credentials and merchant ID.
|
</setting>
|
||||||
</div>
|
</block>
|
||||||
<div class="mt-2">
|
|
||||||
<button name="action_open_clover_provider"
|
|
||||||
type="object"
|
|
||||||
string="Configure Payment Provider"
|
|
||||||
class="btn-link"
|
|
||||||
icon="fa-arrow-right"/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</app>
|
</app>
|
||||||
</xpath>
|
</xpath>
|
||||||
|
|||||||
@@ -464,24 +464,26 @@ class CloverPaymentWizard(models.TransientModel):
|
|||||||
provider = self._get_provider_sudo()
|
provider = self._get_provider_sudo()
|
||||||
capture = not provider.capture_manually
|
capture = not provider.capture_manually
|
||||||
|
|
||||||
minor_amount = clover_utils.format_clover_amount(
|
# Tokenize the card client-server FIRST (Clover's tokenization
|
||||||
self.amount, self.currency_id,
|
# endpoint), then charge using only the token. Raw PAN never
|
||||||
|
# reaches /v1/charges and is never persisted in Odoo.
|
||||||
|
card_token = provider._clover_tokenize_card(
|
||||||
|
card_number=self.card_number,
|
||||||
|
exp_month=int(self.exp_month),
|
||||||
|
exp_year=int(self.exp_year) if len(self.exp_year) == 4
|
||||||
|
else 2000 + int(self.exp_year),
|
||||||
|
cvv=self.cvv,
|
||||||
|
cardholder_name=self.cardholder_name or '',
|
||||||
)
|
)
|
||||||
|
|
||||||
payload = {
|
result = provider._clover_create_charge(
|
||||||
'amount': minor_amount,
|
source_token=card_token,
|
||||||
'currency': self.currency_id.name.lower(),
|
amount=self.amount,
|
||||||
'capture': capture,
|
currency=self.currency_id,
|
||||||
'ecomind': 'moto',
|
capture=capture,
|
||||||
'description': reference,
|
description=reference,
|
||||||
'source': self.card_number.replace(' ', ''),
|
ecomind='moto',
|
||||||
'metadata': {
|
metadata={'odoo_reference': reference},
|
||||||
'odoo_reference': reference,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
result = provider._clover_make_ecom_request(
|
|
||||||
'POST', 'v1/charges', payload=payload,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
charge_id = result.get('id', '')
|
charge_id = result.get('id', '')
|
||||||
@@ -497,6 +499,11 @@ class CloverPaymentWizard(models.TransientModel):
|
|||||||
'clover_charge_id': charge_id,
|
'clover_charge_id': charge_id,
|
||||||
'clover_status': status,
|
'clover_status': status,
|
||||||
'source': result.get('source', {}),
|
'source': result.get('source', {}),
|
||||||
|
# Pass amount + currency back so Odoo 19's amount-tamper
|
||||||
|
# check (_validate_amount) can verify Clover charged the
|
||||||
|
# exact amount we asked for.
|
||||||
|
'amount': result.get('amount'),
|
||||||
|
'currency': result.get('currency'),
|
||||||
}
|
}
|
||||||
|
|
||||||
if status == 'failed':
|
if status == 'failed':
|
||||||
@@ -586,6 +593,15 @@ class CloverPaymentWizard(models.TransientModel):
|
|||||||
raise UserError(_("Please enter a valid expiry year."))
|
raise UserError(_("Please enter a valid expiry year."))
|
||||||
if not self.cvv or not self.cvv.isdigit():
|
if not self.cvv or not self.cvv.isdigit():
|
||||||
raise UserError(_("Please enter the CVV."))
|
raise UserError(_("Please enter the CVV."))
|
||||||
|
# Clover production rejects cards without a 2-part name
|
||||||
|
# ("Firstname Lastname"). Sandbox is lenient. Validate here so the
|
||||||
|
# error message is helpful instead of a generic API 400.
|
||||||
|
if not self.cardholder_name or len(self.cardholder_name.strip().split()) < 2:
|
||||||
|
raise UserError(_(
|
||||||
|
"Please enter the cardholder's name as it appears on "
|
||||||
|
"the card (e.g. \"John Doe\"). Clover requires both "
|
||||||
|
"first and last name."
|
||||||
|
))
|
||||||
|
|
||||||
def _create_payment_transaction(self):
|
def _create_payment_transaction(self):
|
||||||
"""Create a payment.transaction linked to the invoice."""
|
"""Create a payment.transaction linked to the invoice."""
|
||||||
|
|||||||
@@ -83,7 +83,8 @@
|
|||||||
required="payment_mode == 'card' and state in ('draft', 'error')"
|
required="payment_mode == 'card' and state in ('draft', 'error')"
|
||||||
password="True"/>
|
password="True"/>
|
||||||
<field name="cardholder_name"
|
<field name="cardholder_name"
|
||||||
placeholder="Name on card"/>
|
placeholder="Firstname Lastname"
|
||||||
|
required="payment_mode == 'card' and state in ('draft', 'error')"/>
|
||||||
</group>
|
</group>
|
||||||
<group>
|
<group>
|
||||||
<field name="exp_month"
|
<field name="exp_month"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating',
|
'name': 'Fusion Plating',
|
||||||
'version': '19.0.12.5.0',
|
'version': '19.0.12.6.2',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ class SimpleRecipeController(http.Controller):
|
|||||||
'requires_signoff': step.requires_signoff,
|
'requires_signoff': step.requires_signoff,
|
||||||
'requires_rack_assignment': step.requires_rack_assignment,
|
'requires_rack_assignment': step.requires_rack_assignment,
|
||||||
'requires_transition_form': step.requires_transition_form,
|
'requires_transition_form': step.requires_transition_form,
|
||||||
|
'description': step.description or '',
|
||||||
|
'notes': step.notes or '',
|
||||||
'tank_ids': [
|
'tank_ids': [
|
||||||
{'id': t.id, 'name': t.name, 'code': t.code}
|
{'id': t.id, 'name': t.name, 'code': t.code}
|
||||||
for t in step.tank_ids
|
for t in step.tank_ids
|
||||||
@@ -160,13 +162,24 @@ class SimpleRecipeController(http.Controller):
|
|||||||
|
|
||||||
def _sequence_for_position(self, recipe, position):
|
def _sequence_for_position(self, recipe, position):
|
||||||
siblings = recipe.child_ids.sorted('sequence')
|
siblings = recipe.child_ids.sorted('sequence')
|
||||||
if not siblings or position >= len(siblings):
|
if not siblings:
|
||||||
return (siblings[-1].sequence + 10) if siblings else 10
|
return 10
|
||||||
|
if position >= len(siblings):
|
||||||
|
return siblings[-1].sequence + 10
|
||||||
if position <= 0:
|
if position <= 0:
|
||||||
return max(1, siblings[0].sequence - 10)
|
return max(1, siblings[0].sequence - 10)
|
||||||
before = siblings[position - 1].sequence
|
before = siblings[position - 1].sequence
|
||||||
after = siblings[position].sequence
|
after = siblings[position].sequence
|
||||||
return (before + after) // 2 if (after - before) > 1 else before + 1
|
if after - before > 1:
|
||||||
|
return (before + after) // 2
|
||||||
|
# Sequences are tightly packed (gap == 1 → midpoint == after,
|
||||||
|
# which collides). Renumber siblings to 10/20/30… first, then
|
||||||
|
# the new step lands cleanly between renumbered neighbours.
|
||||||
|
for idx, sib in enumerate(siblings):
|
||||||
|
new_seq = (idx + 1) * 10
|
||||||
|
if sib.sequence != new_seq:
|
||||||
|
sib.sequence = new_seq
|
||||||
|
return position * 10 + 5
|
||||||
|
|
||||||
def _copy_inputs_from_template(self, tpl, new_node):
|
def _copy_inputs_from_template(self, tpl, new_node):
|
||||||
NodeInput = request.env['fusion.plating.process.node.input']
|
NodeInput = request.env['fusion.plating.process.node.input']
|
||||||
|
|||||||
@@ -16,12 +16,40 @@
|
|||||||
# cancelled (rework reverts here)
|
# cancelled (rework reverts here)
|
||||||
# on_hold can be entered from confirmed or in_progress.
|
# on_hold can be entered from confirmed or in_progress.
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
|
||||||
from odoo import _, api, fields, models
|
from odoo import _, api, fields, models
|
||||||
from odoo.exceptions import UserError
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
|
||||||
class FpJob(models.Model):
|
class FpJob(models.Model):
|
||||||
_name = 'fp.job'
|
_name = 'fp.job'
|
||||||
|
|
||||||
|
def fp_format_local(self, dt, fmt='%Y-%m-%d %H:%M'):
|
||||||
|
"""Format a UTC datetime in the viewer's local timezone.
|
||||||
|
|
||||||
|
Used by report templates: QWeb's eval scope doesn't expose pytz
|
||||||
|
or format_datetime, but record methods are always callable, so
|
||||||
|
templates do `<span t-esc="job.fp_format_local(dt, '%H:%M')"/>`.
|
||||||
|
|
||||||
|
Resolution order matches the rest of the module: env.user.tz →
|
||||||
|
company.x_fc_default_tz → UTC.
|
||||||
|
"""
|
||||||
|
if not dt:
|
||||||
|
return ''
|
||||||
|
tz_name = (
|
||||||
|
self.env.user.tz
|
||||||
|
or ('x_fc_default_tz' in self.env.company._fields
|
||||||
|
and self.env.company.x_fc_default_tz)
|
||||||
|
or 'UTC'
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
tz = pytz.timezone(tz_name)
|
||||||
|
except Exception:
|
||||||
|
tz = pytz.UTC
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = pytz.UTC.localize(dt)
|
||||||
|
return dt.astimezone(tz).strftime(fmt)
|
||||||
_description = 'Plating Job'
|
_description = 'Plating Job'
|
||||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||||
_order = 'priority desc, date_deadline asc, id desc'
|
_order = 'priority desc, date_deadline asc, id desc'
|
||||||
|
|||||||
@@ -168,6 +168,56 @@ class FpJobStep(models.Model):
|
|||||||
)
|
)
|
||||||
qty_at_step_start = fields.Integer(string='Qty at Step Start')
|
qty_at_step_start = fields.Integer(string='Qty at Step Start')
|
||||||
qty_at_step_finish = fields.Integer(string='Qty at Step Finish')
|
qty_at_step_finish = fields.Integer(string='Qty at Step Finish')
|
||||||
|
# Live "qty currently parked at this step" — drives partial-qty
|
||||||
|
# workflows. = (incoming moves' qty − outgoing moves' qty), with a
|
||||||
|
# first-step seed: the lowest-sequence step on a confirmed job
|
||||||
|
# implicitly receives the full job qty when the job starts (no
|
||||||
|
# explicit "kickoff" move record). Without that seed, the first
|
||||||
|
# step would always show 0 here until the operator manually moved
|
||||||
|
# parts in, which doesn't match how the floor thinks about it.
|
||||||
|
qty_at_step = fields.Integer(
|
||||||
|
string='Qty Here',
|
||||||
|
compute='_compute_qty_at_step',
|
||||||
|
help='Quantity currently parked at this step. Drains as moves '
|
||||||
|
'transfer parts to later steps. The Move dialog defaults '
|
||||||
|
'to this value and blocks moves above it.',
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends('move_ids.qty_moved', 'move_ids.to_step_id',
|
||||||
|
'incoming_move_ids.qty_moved',
|
||||||
|
'incoming_move_ids.from_step_id',
|
||||||
|
'state', 'job_id.qty', 'job_id.step_ids',
|
||||||
|
'job_id.step_ids.sequence', 'sequence')
|
||||||
|
def _compute_qty_at_step(self):
|
||||||
|
for rec in self:
|
||||||
|
# Terminal states: nothing parked here anymore. Operators
|
||||||
|
# don't care if "done" steps technically have qty residue —
|
||||||
|
# surfacing zero keeps the column readable.
|
||||||
|
if rec.state in ('done', 'cancelled', 'skipped'):
|
||||||
|
rec.qty_at_step = 0
|
||||||
|
continue
|
||||||
|
# Self-loop moves (from_step == to_step, transfer_type='step')
|
||||||
|
# are how the Record Inputs wizard logs measurements; they
|
||||||
|
# don't move qty so we exclude them on both sides.
|
||||||
|
incoming = sum(
|
||||||
|
m.qty_moved for m in rec.incoming_move_ids
|
||||||
|
if m.from_step_id != rec
|
||||||
|
)
|
||||||
|
outgoing = sum(
|
||||||
|
m.qty_moved for m in rec.move_ids
|
||||||
|
if m.to_step_id != rec
|
||||||
|
)
|
||||||
|
# First-step seed: the earliest non-terminal step on a job
|
||||||
|
# implicitly receives the full job qty when the job kicks
|
||||||
|
# off (no explicit kickoff move). Without this seed, qty
|
||||||
|
# here would read 0 even when the floor has the full batch.
|
||||||
|
if not incoming and rec.job_id and rec.job_id.qty:
|
||||||
|
first_active = rec.job_id.step_ids.filtered(
|
||||||
|
lambda s: s.state not in ('done', 'cancelled', 'skipped')
|
||||||
|
).sorted('sequence')[:1]
|
||||||
|
if rec == first_active:
|
||||||
|
incoming = int(rec.job_id.qty)
|
||||||
|
rec.qty_at_step = max(0, incoming - outgoing)
|
||||||
|
|
||||||
@api.depends('rack_id')
|
@api.depends('rack_id')
|
||||||
def _compute_is_racked(self):
|
def _compute_is_racked(self):
|
||||||
@@ -226,7 +276,7 @@ class FpJobStep(models.Model):
|
|||||||
) % (step.name, step.state))
|
) % (step.name, step.state))
|
||||||
now = fields.Datetime.now()
|
now = fields.Datetime.now()
|
||||||
open_log = step.time_log_ids.filtered(lambda l: not l.date_finished)
|
open_log = step.time_log_ids.filtered(lambda l: not l.date_finished)
|
||||||
open_log.write({'date_finished': now})
|
open_log.write({'date_finished': now, 'state': 'paused'})
|
||||||
step.state = 'paused'
|
step.state = 'paused'
|
||||||
step.message_post(body=_('Step paused by %s') % self.env.user.name)
|
step.message_post(body=_('Step paused by %s') % self.env.user.name)
|
||||||
return True
|
return True
|
||||||
@@ -269,7 +319,7 @@ class FpJobStep(models.Model):
|
|||||||
) % (step.name, step.state))
|
) % (step.name, step.state))
|
||||||
now = fields.Datetime.now()
|
now = fields.Datetime.now()
|
||||||
open_log = step.time_log_ids.filtered(lambda l: not l.date_finished)
|
open_log = step.time_log_ids.filtered(lambda l: not l.date_finished)
|
||||||
open_log.write({'date_finished': now})
|
open_log.write({'date_finished': now, 'state': 'stopped'})
|
||||||
step.state = 'cancelled'
|
step.state = 'cancelled'
|
||||||
step.message_post(body=_('Step cancelled by %s') % self.env.user.name)
|
step.message_post(body=_('Step cancelled by %s') % self.env.user.name)
|
||||||
return True
|
return True
|
||||||
@@ -305,7 +355,7 @@ class FpJobStep(models.Model):
|
|||||||
now = fields.Datetime.now()
|
now = fields.Datetime.now()
|
||||||
# Close the open timelog (the one with no date_finished)
|
# Close the open timelog (the one with no date_finished)
|
||||||
open_log = step.time_log_ids.filtered(lambda l: not l.date_finished)
|
open_log = step.time_log_ids.filtered(lambda l: not l.date_finished)
|
||||||
open_log.write({'date_finished': now})
|
open_log.write({'date_finished': now, 'state': 'stopped'})
|
||||||
step.state = 'done'
|
step.state = 'done'
|
||||||
# First-finish audit (mirrors button_start first-start guard)
|
# First-finish audit (mirrors button_start first-start guard)
|
||||||
if not step.date_finished:
|
if not step.date_finished:
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { registry } from "@web/core/registry";
|
|||||||
import { rpc } from "@web/core/network/rpc";
|
import { rpc } from "@web/core/network/rpc";
|
||||||
import { useService } from "@web/core/utils/hooks";
|
import { useService } from "@web/core/utils/hooks";
|
||||||
import { _t } from "@web/core/l10n/translation";
|
import { _t } from "@web/core/l10n/translation";
|
||||||
|
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
|
||||||
|
|
||||||
|
|
||||||
export class FpSimpleRecipeEditor extends Component {
|
export class FpSimpleRecipeEditor extends Component {
|
||||||
@@ -37,6 +38,12 @@ export class FpSimpleRecipeEditor extends Component {
|
|||||||
dragOverIndex: null, // 0..N (insertion index)
|
dragOverIndex: null, // 0..N (insertion index)
|
||||||
dragPreviewLabel: "", // shown next to the indicator line
|
dragPreviewLabel: "", // shown next to the indicator line
|
||||||
dragPreviewIcon: "fa-cog",
|
dragPreviewIcon: "fa-cog",
|
||||||
|
// Inline edit panel — id of the step currently being edited
|
||||||
|
// (null = no panel open). Mirrors live values so the textarea
|
||||||
|
// stays controlled without RPC roundtrip on every keystroke.
|
||||||
|
editingStepId: null,
|
||||||
|
editName: "",
|
||||||
|
editInstructions: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
this._recipeId = null;
|
this._recipeId = null;
|
||||||
@@ -90,11 +97,24 @@ export class FpSimpleRecipeEditor extends Component {
|
|||||||
async reorderStep(stepId, newIndex) {
|
async reorderStep(stepId, newIndex) {
|
||||||
const ids = this.state.steps.map((s) => s.id);
|
const ids = this.state.steps.map((s) => s.id);
|
||||||
const oldIndex = ids.indexOf(stepId);
|
const oldIndex = ids.indexOf(stepId);
|
||||||
if (oldIndex < 0 || oldIndex === newIndex) {
|
if (oldIndex < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// dragOverIndex is the insertion point in the ORIGINAL list. Once
|
||||||
|
// we splice the dragged item out, every position to the right of
|
||||||
|
// oldIndex shifts left by one — so an insertion at newIndex when
|
||||||
|
// newIndex > oldIndex must be decremented. Without this, dropping
|
||||||
|
// right after itself moves the row one slot down instead of
|
||||||
|
// staying put.
|
||||||
|
let adjusted = newIndex;
|
||||||
|
if (newIndex > oldIndex) {
|
||||||
|
adjusted -= 1;
|
||||||
|
}
|
||||||
|
if (adjusted === oldIndex) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ids.splice(oldIndex, 1);
|
ids.splice(oldIndex, 1);
|
||||||
ids.splice(Math.min(newIndex, ids.length), 0, stepId);
|
ids.splice(Math.max(0, Math.min(adjusted, ids.length)), 0, stepId);
|
||||||
await rpc("/fp/simple_recipe/step/reorder", { node_ids: ids });
|
await rpc("/fp/simple_recipe/step/reorder", { node_ids: ids });
|
||||||
await this.loadAll();
|
await this.loadAll();
|
||||||
}
|
}
|
||||||
@@ -199,6 +219,26 @@ export class FpSimpleRecipeEditor extends Component {
|
|||||||
this.state.dragOverIndex = this.state.steps.length;
|
this.state.dragOverIndex = this.state.steps.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Panel-level dragover. Required so HTML5 `drop` actually fires
|
||||||
|
* across the whole panel surface — including the gap between rows
|
||||||
|
* (.25rem margin) and the panel padding (1rem). Without this, drops
|
||||||
|
* on those areas are silently rejected by the browser. Row-level
|
||||||
|
* dragover handlers still run first and set the precise index;
|
||||||
|
* this is the safety net that keeps the most-recently-set index
|
||||||
|
* (or end-of-list fallback) live until the user releases.
|
||||||
|
*/
|
||||||
|
onPanelDragOver(ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.dataTransfer.dropEffect =
|
||||||
|
ev.dataTransfer.types.includes("application/x-fp-library")
|
||||||
|
? "copy"
|
||||||
|
: "move";
|
||||||
|
if (this.state.dragOverIndex === null) {
|
||||||
|
this.state.dragOverIndex = this.state.steps.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async onDrop(ev) {
|
async onDrop(ev) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
const targetIndex = this.state.dragOverIndex !== null
|
const targetIndex = this.state.dragOverIndex !== null
|
||||||
@@ -236,12 +276,87 @@ export class FpSimpleRecipeEditor extends Component {
|
|||||||
this.state.dragPreviewIcon = "fa-cog";
|
this.state.dragPreviewIcon = "fa-cog";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- edit panel
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the inline edit panel for a step. Closing without explicit
|
||||||
|
* Save discards changes — operator-style "I clicked the wrong row"
|
||||||
|
* shouldn't write garbage to the recipe.
|
||||||
|
*/
|
||||||
|
onToggleEdit(stepId) {
|
||||||
|
if (this.state.editingStepId === stepId) {
|
||||||
|
this.state.editingStepId = null;
|
||||||
|
this.state.editName = "";
|
||||||
|
this.state.editInstructions = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const step = this.state.steps.find((s) => s.id === stepId);
|
||||||
|
if (!step) return;
|
||||||
|
this.state.editingStepId = stepId;
|
||||||
|
this.state.editName = step.name || "";
|
||||||
|
this.state.editInstructions = this._htmlToText(step.description || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
async onSaveStep() {
|
||||||
|
const stepId = this.state.editingStepId;
|
||||||
|
if (!stepId) return;
|
||||||
|
const vals = {
|
||||||
|
name: this.state.editName || _t("Untitled Step"),
|
||||||
|
description: this._textToHtml(this.state.editInstructions),
|
||||||
|
};
|
||||||
|
await rpc("/fp/simple_recipe/step/write", {
|
||||||
|
node_id: stepId,
|
||||||
|
vals: vals,
|
||||||
|
});
|
||||||
|
this.state.editingStepId = null;
|
||||||
|
this.state.editName = "";
|
||||||
|
this.state.editInstructions = "";
|
||||||
|
await this.loadAll();
|
||||||
|
this.notification.add(_t("Step updated"), { type: "success" });
|
||||||
|
}
|
||||||
|
|
||||||
|
onCancelEdit() {
|
||||||
|
this.state.editingStepId = null;
|
||||||
|
this.state.editName = "";
|
||||||
|
this.state.editInstructions = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render stored HTML as plain text for the textarea. Strips tags,
|
||||||
|
* collapses block elements to newlines. Good enough for the simple
|
||||||
|
* editor — the tree editor handles full rich text.
|
||||||
|
*/
|
||||||
|
_htmlToText(html) {
|
||||||
|
if (!html) return "";
|
||||||
|
const tmp = document.createElement("div");
|
||||||
|
tmp.innerHTML = html;
|
||||||
|
// Replace block elements + <br> with newlines before reading text.
|
||||||
|
tmp.querySelectorAll("br").forEach((br) => br.replaceWith("\n"));
|
||||||
|
tmp.querySelectorAll("p, div, li").forEach((el) => {
|
||||||
|
el.append("\n");
|
||||||
|
});
|
||||||
|
return (tmp.textContent || "").replace(/\n{3,}/g, "\n\n").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wrap user text into safe HTML so the Html field stores cleanly. */
|
||||||
|
_textToHtml(text) {
|
||||||
|
if (!text) return "";
|
||||||
|
const escaped = text
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">");
|
||||||
|
return escaped
|
||||||
|
.split(/\n{2,}/)
|
||||||
|
.map((p) => `<p>${p.replace(/\n/g, "<br/>")}</p>`)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------------- helpers
|
// --------------------------------------------------------------- helpers
|
||||||
|
|
||||||
async _confirm(message) {
|
async _confirm(message) {
|
||||||
return await new Promise((resolve) => {
|
return await new Promise((resolve) => {
|
||||||
this.dialog.add(
|
this.dialog.add(
|
||||||
"web.ConfirmationDialog",
|
ConfirmationDialog,
|
||||||
{
|
{
|
||||||
body: message,
|
body: message,
|
||||||
confirm: () => resolve(true),
|
confirm: () => resolve(true),
|
||||||
|
|||||||
@@ -152,6 +152,12 @@ $fp-se-drop: var(--fp-drop-bg, #{$_fp_se_drop_hex});
|
|||||||
background: $fp-se-drop;
|
background: $fp-se-drop;
|
||||||
border-color: $fp-se-accent;
|
border-color: $fp-se-accent;
|
||||||
}
|
}
|
||||||
|
&.o_fp_step_row_editing {
|
||||||
|
border-color: $fp-se-accent;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.o_fp_drag_handle {
|
.o_fp_drag_handle {
|
||||||
color: $fp-se-muted;
|
color: $fp-se-muted;
|
||||||
@@ -163,6 +169,10 @@ $fp-se-drop: var(--fp-drop-bg, #{$_fp_se_drop_hex});
|
|||||||
min-width: 1.5rem;
|
min-width: 1.5rem;
|
||||||
}
|
}
|
||||||
.o_fp_step_name { flex: 1; }
|
.o_fp_step_name { flex: 1; }
|
||||||
|
.o_fp_step_has_instructions {
|
||||||
|
color: $fp-se-accent;
|
||||||
|
font-size: .85rem;
|
||||||
|
}
|
||||||
.o_fp_station_badge {
|
.o_fp_station_badge {
|
||||||
font-size: .75rem;
|
font-size: .75rem;
|
||||||
color: $fp-se-muted;
|
color: $fp-se-muted;
|
||||||
@@ -170,19 +180,64 @@ $fp-se-drop: var(--fp-drop-bg, #{$_fp_se_drop_hex});
|
|||||||
padding: .125rem .5rem;
|
padding: .125rem .5rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
}
|
}
|
||||||
|
.o_fp_step_edit,
|
||||||
.o_fp_step_remove {
|
.o_fp_step_remove {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: $fp-se-muted;
|
color: $fp-se-muted;
|
||||||
font-size: 1.25rem;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity .1s;
|
transition: opacity .1s;
|
||||||
padding: 0 .25rem;
|
padding: 0 .25rem;
|
||||||
}
|
}
|
||||||
&:hover .o_fp_step_remove {
|
.o_fp_step_edit { font-size: .9rem; }
|
||||||
|
.o_fp_step_remove { font-size: 1.25rem; }
|
||||||
|
&:hover .o_fp_step_edit,
|
||||||
|
&:hover .o_fp_step_remove,
|
||||||
|
&.o_fp_step_row_editing .o_fp_step_edit {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
.o_fp_step_edit:hover {
|
||||||
|
color: $fp-se-accent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_step_edit_panel {
|
||||||
|
background: $fp-se-card;
|
||||||
|
border: 1px solid $fp-se-accent;
|
||||||
|
border-top: none;
|
||||||
|
border-radius: 0 0 4px 4px;
|
||||||
|
padding: .75rem;
|
||||||
|
margin-bottom: .25rem;
|
||||||
|
|
||||||
|
.o_fp_edit_field {
|
||||||
|
margin-bottom: .75rem;
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: .85rem;
|
||||||
|
margin-bottom: .25rem;
|
||||||
|
color: $fp-se-accent;
|
||||||
|
}
|
||||||
|
textarea {
|
||||||
|
font-family: inherit;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_edit_hint {
|
||||||
|
margin: .25rem 0 0 0;
|
||||||
|
font-size: .75rem;
|
||||||
|
color: $fp-se-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_edit_actions {
|
||||||
|
display: flex;
|
||||||
|
gap: .5rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.o_fp_step_dropzone {
|
.o_fp_step_dropzone {
|
||||||
|
|||||||
@@ -34,10 +34,11 @@
|
|||||||
|
|
||||||
<div class="o_fp_simple_editor_body" t-if="!state.loading">
|
<div class="o_fp_simple_editor_body" t-if="!state.loading">
|
||||||
<div class="o_fp_selected_panel"
|
<div class="o_fp_selected_panel"
|
||||||
|
t-on-dragover="(ev) => this.onPanelDragOver(ev)"
|
||||||
t-on-dragleave="(ev) => this.onDragLeave(ev)"
|
t-on-dragleave="(ev) => this.onDragLeave(ev)"
|
||||||
t-on-dragend="() => this.onDragEnd()"
|
t-on-dragend="() => this.onDragEnd()"
|
||||||
t-on-drop="(ev) => this.onDrop(ev)">
|
t-on-drop="(ev) => this.onDrop(ev)">
|
||||||
<h3>Selected (drag to reorder)</h3>
|
<h3>Selected (drag to reorder, click pencil to edit)</h3>
|
||||||
<div class="o_fp_steps_list">
|
<div class="o_fp_steps_list">
|
||||||
|
|
||||||
<!-- Top drop indicator (insertion at index 0). Visible
|
<!-- Top drop indicator (insertion at index 0). Visible
|
||||||
@@ -53,6 +54,7 @@
|
|||||||
|
|
||||||
<t t-foreach="state.steps" t-as="step" t-key="step.id">
|
<t t-foreach="state.steps" t-as="step" t-key="step.id">
|
||||||
<div class="o_fp_step_row"
|
<div class="o_fp_step_row"
|
||||||
|
t-att-class="state.editingStepId === step.id ? 'o_fp_step_row_editing' : ''"
|
||||||
draggable="true"
|
draggable="true"
|
||||||
t-on-dragstart="(ev) => this.onSelectedDragStart(step.id, ev)"
|
t-on-dragstart="(ev) => this.onSelectedDragStart(step.id, ev)"
|
||||||
t-on-dragover="(ev) => this.onRowDragOver(step_index, ev)">
|
t-on-dragover="(ev) => this.onRowDragOver(step_index, ev)">
|
||||||
@@ -60,16 +62,58 @@
|
|||||||
<span class="o_fp_step_position"><t t-esc="step_index + 1"/>.</span>
|
<span class="o_fp_step_position"><t t-esc="step_index + 1"/>.</span>
|
||||||
<i t-att-class="'fa ' + (step.icon || 'fa-cog')"/>
|
<i t-att-class="'fa ' + (step.icon || 'fa-cog')"/>
|
||||||
<span class="o_fp_step_name" t-esc="step.name"/>
|
<span class="o_fp_step_name" t-esc="step.name"/>
|
||||||
|
<span class="o_fp_step_has_instructions"
|
||||||
|
t-if="step.description"
|
||||||
|
title="Has operator instructions">
|
||||||
|
<i class="fa fa-file-text-o"/>
|
||||||
|
</span>
|
||||||
<span class="o_fp_station_badge"
|
<span class="o_fp_station_badge"
|
||||||
t-if="step.tank_ids and step.tank_ids.length">
|
t-if="step.tank_ids and step.tank_ids.length">
|
||||||
<t t-esc="step.tank_ids.length"/> stations
|
<t t-esc="step.tank_ids.length"/> stations
|
||||||
</span>
|
</span>
|
||||||
|
<button class="o_fp_step_edit"
|
||||||
|
title="Edit name & instructions"
|
||||||
|
t-on-click="() => this.onToggleEdit(step.id)">
|
||||||
|
<i class="fa fa-pencil"/>
|
||||||
|
</button>
|
||||||
<button class="o_fp_step_remove"
|
<button class="o_fp_step_remove"
|
||||||
|
title="Remove step"
|
||||||
t-on-click="() => this.onRemoveStep(step.id)">
|
t-on-click="() => this.onRemoveStep(step.id)">
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Inline edit panel (shown when this step is selected for editing). -->
|
||||||
|
<div class="o_fp_step_edit_panel"
|
||||||
|
t-if="state.editingStepId === step.id">
|
||||||
|
<div class="o_fp_edit_field">
|
||||||
|
<label>Step name</label>
|
||||||
|
<input type="text" class="form-control"
|
||||||
|
t-model="state.editName"
|
||||||
|
placeholder="e.g. Acid Etch"/>
|
||||||
|
</div>
|
||||||
|
<div class="o_fp_edit_field">
|
||||||
|
<label>Default instructions for operator</label>
|
||||||
|
<textarea class="form-control"
|
||||||
|
rows="5"
|
||||||
|
t-model="state.editInstructions"
|
||||||
|
placeholder="What the operator/employee sees on the shop floor when running this step. Plain text — line breaks are preserved."/>
|
||||||
|
<p class="o_fp_edit_hint">
|
||||||
|
Shown to operators when running this step at the tank. Use line breaks for separate points.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="o_fp_edit_actions">
|
||||||
|
<button class="btn btn-primary btn-sm"
|
||||||
|
t-on-click="() => this.onSaveStep()">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary btn-sm"
|
||||||
|
t-on-click="() => this.onCancelEdit()">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Indicator AFTER each row (insertion at index = step_index + 1) -->
|
<!-- Indicator AFTER each row (insertion at index = step_index + 1) -->
|
||||||
<div class="o_fp_drop_indicator"
|
<div class="o_fp_drop_indicator"
|
||||||
t-att-class="state.dragOverIndex === (step_index + 1) ? 'o_fp_drop_indicator_active' : ''">
|
t-att-class="state.dragOverIndex === (step_index + 1) ? 'o_fp_drop_indicator_active' : ''">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Configurator',
|
'name': 'Fusion Plating — Configurator',
|
||||||
'version': '19.0.18.2.0',
|
'version': '19.0.18.3.2',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
|
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -13,6 +13,30 @@ from odoo import fields, models
|
|||||||
class AccountMoveLine(models.Model):
|
class AccountMoveLine(models.Model):
|
||||||
_inherit = 'account.move.line'
|
_inherit = 'account.move.line'
|
||||||
|
|
||||||
|
def fp_customer_description(self):
|
||||||
|
"""Strip the "[code] product_name" prefix from line.name.
|
||||||
|
|
||||||
|
Mirror of sale.order.line.fp_customer_description so the shared
|
||||||
|
customer_line_description QWeb macro renders cleanly on invoice
|
||||||
|
PDFs too.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
name = (self.name or '').strip()
|
||||||
|
if not self.product_id or not name:
|
||||||
|
return name
|
||||||
|
code = self.product_id.default_code or ''
|
||||||
|
pname = self.product_id.name or ''
|
||||||
|
prefixes = []
|
||||||
|
if code and pname:
|
||||||
|
prefixes.append(f'[{code}] {pname}')
|
||||||
|
if pname:
|
||||||
|
prefixes.append(pname)
|
||||||
|
for prefix in prefixes:
|
||||||
|
if name.startswith(prefix):
|
||||||
|
tail = name[len(prefix):]
|
||||||
|
return tail.lstrip(' \t\r\n-—–:').strip()
|
||||||
|
return name
|
||||||
|
|
||||||
x_fc_part_catalog_id = fields.Many2one(
|
x_fc_part_catalog_id = fields.Many2one(
|
||||||
'fp.part.catalog',
|
'fp.part.catalog',
|
||||||
string='Part',
|
string='Part',
|
||||||
|
|||||||
@@ -25,9 +25,26 @@ class FpCoatingThickness(models.Model):
|
|||||||
ondelete='cascade',
|
ondelete='cascade',
|
||||||
)
|
)
|
||||||
value = fields.Float(
|
value = fields.Float(
|
||||||
|
string='Nominal',
|
||||||
digits=(10, 4),
|
digits=(10, 4),
|
||||||
required=True,
|
required=True,
|
||||||
help='Target thickness value (magnitude only; UoM in the next field).',
|
help='Target / nominal thickness value (the number printed on the cert). '
|
||||||
|
'Magnitude only — UoM lives in the next field.',
|
||||||
|
)
|
||||||
|
# Hitting an exact thickness on plated parts is impossible — the spec
|
||||||
|
# is always "X mils ± tolerance" or a min/max range. These fields
|
||||||
|
# capture the acceptance band so QC can mark a reading pass/fail
|
||||||
|
# against real customer specs (e.g. AMS-2404 Class 4 = 0.001"–0.0015").
|
||||||
|
# Both optional: leave blank for legacy single-value entries.
|
||||||
|
value_min = fields.Float(
|
||||||
|
string='Min',
|
||||||
|
digits=(10, 4),
|
||||||
|
help='Lower acceptance bound. Readings below this fail QC.',
|
||||||
|
)
|
||||||
|
value_max = fields.Float(
|
||||||
|
string='Max',
|
||||||
|
digits=(10, 4),
|
||||||
|
help='Upper acceptance bound. Readings above this fail QC.',
|
||||||
)
|
)
|
||||||
uom = fields.Selection(
|
uom = fields.Selection(
|
||||||
[('mils', 'mils (0.001 in)'),
|
[('mils', 'mils (0.001 in)'),
|
||||||
@@ -44,7 +61,7 @@ class FpCoatingThickness(models.Model):
|
|||||||
store=True,
|
store=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@api.depends('value', 'uom')
|
@api.depends('value', 'value_min', 'value_max', 'uom')
|
||||||
def _compute_display_name(self):
|
def _compute_display_name(self):
|
||||||
uom_labels = dict(self._fields['uom'].selection)
|
uom_labels = dict(self._fields['uom'].selection)
|
||||||
for rec in self:
|
for rec in self:
|
||||||
@@ -52,7 +69,22 @@ class FpCoatingThickness(models.Model):
|
|||||||
# Strip the bracketed clarification for a tighter dropdown row.
|
# Strip the bracketed clarification for a tighter dropdown row.
|
||||||
if ' (' in label:
|
if ' (' in label:
|
||||||
label = label.split(' (')[0]
|
label = label.split(' (')[0]
|
||||||
if rec.value:
|
# Range overrides single value when both bounds are set —
|
||||||
|
# operators see the real spec, not a phantom-precise nominal.
|
||||||
|
if rec.value_min and rec.value_max:
|
||||||
|
rec.display_name = (
|
||||||
|
f'{rec.value_min:g}–{rec.value_max:g} {label}'.strip()
|
||||||
|
)
|
||||||
|
elif rec.value:
|
||||||
rec.display_name = f'{rec.value:g} {label}'.strip()
|
rec.display_name = f'{rec.value:g} {label}'.strip()
|
||||||
else:
|
else:
|
||||||
rec.display_name = label
|
rec.display_name = label
|
||||||
|
|
||||||
|
@api.constrains('value_min', 'value_max')
|
||||||
|
def _check_range(self):
|
||||||
|
for rec in self:
|
||||||
|
if rec.value_min and rec.value_max and rec.value_min > rec.value_max:
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
|
raise ValidationError(_(
|
||||||
|
'Thickness Min (%(mn)s) cannot exceed Max (%(mx)s).'
|
||||||
|
) % {'mn': rec.value_min, 'mx': rec.value_max})
|
||||||
|
|||||||
@@ -10,6 +10,36 @@ from odoo.exceptions import ValidationError
|
|||||||
class SaleOrderLine(models.Model):
|
class SaleOrderLine(models.Model):
|
||||||
_inherit = 'sale.order.line'
|
_inherit = 'sale.order.line'
|
||||||
|
|
||||||
|
def fp_customer_description(self):
|
||||||
|
"""Return line.name with the leading "[code] product_name" stripped.
|
||||||
|
|
||||||
|
Odoo's _compute_name re-prepends the product code + name on save,
|
||||||
|
polluting customer-facing PDFs with internal-product noise like
|
||||||
|
"[FP-SERVICE] Plating Service". This helper peels that prefix
|
||||||
|
off so the QWeb macros print only what the estimator actually
|
||||||
|
typed for the customer to see. Same logic mirrored on
|
||||||
|
account.move.line for invoice rendering.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
name = (self.name or '').strip()
|
||||||
|
if not self.product_id or not name:
|
||||||
|
return name
|
||||||
|
code = self.product_id.default_code or ''
|
||||||
|
pname = self.product_id.name or ''
|
||||||
|
# Try the bracketed form first ("[CODE] Name"), then bare name.
|
||||||
|
# Whichever matches gets stripped along with any trailing
|
||||||
|
# newline / dash / em-dash separator.
|
||||||
|
prefixes = []
|
||||||
|
if code and pname:
|
||||||
|
prefixes.append(f'[{code}] {pname}')
|
||||||
|
if pname:
|
||||||
|
prefixes.append(pname)
|
||||||
|
for prefix in prefixes:
|
||||||
|
if name.startswith(prefix):
|
||||||
|
tail = name[len(prefix):]
|
||||||
|
return tail.lstrip(' \t\r\n-—–:').strip()
|
||||||
|
return name
|
||||||
|
|
||||||
x_fc_part_catalog_id = fields.Many2one(
|
x_fc_part_catalog_id = fields.Many2one(
|
||||||
'fp.part.catalog', string='Part',
|
'fp.part.catalog', string='Part',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -77,7 +77,9 @@
|
|||||||
<field name="thickness_option_ids">
|
<field name="thickness_option_ids">
|
||||||
<list editable="bottom">
|
<list editable="bottom">
|
||||||
<field name="sequence" widget="handle"/>
|
<field name="sequence" widget="handle"/>
|
||||||
<field name="value"/>
|
<field name="value" string="Nominal"/>
|
||||||
|
<field name="value_min" string="Min"/>
|
||||||
|
<field name="value_max" string="Max"/>
|
||||||
<field name="uom"/>
|
<field name="uom"/>
|
||||||
<field name="display_name" string="Display" readonly="1"/>
|
<field name="display_name" string="Display" readonly="1"/>
|
||||||
<field name="active" widget="boolean_toggle"/>
|
<field name="active" widget="boolean_toggle"/>
|
||||||
|
|||||||
@@ -100,11 +100,39 @@
|
|||||||
</xpath>
|
</xpath>
|
||||||
<xpath expr="//notebook" position="inside">
|
<xpath expr="//notebook" position="inside">
|
||||||
<page string="Plating" name="plating_tab">
|
<page string="Plating" name="plating_tab">
|
||||||
<group>
|
<!-- Multi-part summary: read-only list of every order line
|
||||||
<group string="Part & Coating">
|
showing part / coating / process. The Order Lines tab
|
||||||
<field name="x_fc_configurator_id" readonly="1"/>
|
is the editable surface; this is the at-a-glance view
|
||||||
|
so you can confirm an order has the right parts/coatings
|
||||||
|
without scrolling pricing columns. The pre-Sub-12 SO-
|
||||||
|
header singletons (x_fc_part_catalog_id /
|
||||||
|
x_fc_coating_config_id) only ever populated when the
|
||||||
|
order was built via the quote configurator — they're
|
||||||
|
silent on direct orders, which is why they appeared
|
||||||
|
empty after confirm. They still exist on the model
|
||||||
|
(used by configurator/portal) but are no longer the
|
||||||
|
primary display. -->
|
||||||
|
<separator string="Parts on this order"/>
|
||||||
|
<field name="order_line" nolabel="1"
|
||||||
|
context="{'tree_view_ref': 'fusion_plating_configurator.view_sale_order_line_plating_summary'}"
|
||||||
|
readonly="1">
|
||||||
|
<list create="false" delete="false" edit="false">
|
||||||
<field name="x_fc_part_catalog_id"/>
|
<field name="x_fc_part_catalog_id"/>
|
||||||
<field name="x_fc_coating_config_id"/>
|
<field name="x_fc_coating_config_id"/>
|
||||||
|
<field name="x_fc_thickness_id" optional="show"/>
|
||||||
|
<field name="x_fc_process_variant_id" optional="show"
|
||||||
|
string="Process"/>
|
||||||
|
<field name="product_uom_qty" string="Qty"/>
|
||||||
|
<field name="x_fc_part_deadline" optional="show"
|
||||||
|
string="Part Deadline"/>
|
||||||
|
<field name="x_fc_rush_order" optional="hide"/>
|
||||||
|
<field name="x_fc_job_number" optional="show"
|
||||||
|
string="Job #"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
<group>
|
||||||
|
<group string="Configurator (legacy)" invisible="not x_fc_configurator_id">
|
||||||
|
<field name="x_fc_configurator_id" readonly="1"/>
|
||||||
<field name="x_fc_process_summary" readonly="1"/>
|
<field name="x_fc_process_summary" readonly="1"/>
|
||||||
</group>
|
</group>
|
||||||
<group string="RFQ / PO">
|
<group string="RFQ / PO">
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
# Part of the Fusion Plating product family.
|
# Part of the Fusion Plating product family.
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from odoo import _, api, fields, models
|
from odoo import _, api, fields, models
|
||||||
from odoo.exceptions import UserError
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
@@ -220,23 +222,39 @@ class FpDirectOrderWizard(models.Model):
|
|||||||
self._apply_strategy_payment_term()
|
self._apply_strategy_payment_term()
|
||||||
return
|
return
|
||||||
|
|
||||||
# Legacy partner-field defaults (pre-Sub-5).
|
# Partner-level plating defaults — primary cascade. Customers
|
||||||
if 'x_fc_default_invoice_strategy' in self.partner_id._fields:
|
# migrated to the new partner fields skip the legacy lookup below.
|
||||||
self.invoice_strategy = self.partner_id.x_fc_default_invoice_strategy or False
|
partner = self.partner_id
|
||||||
self.deposit_percent = self.partner_id.x_fc_default_deposit_percent or 0.0
|
if partner.x_fc_default_invoice_strategy:
|
||||||
|
self.invoice_strategy = partner.x_fc_default_invoice_strategy
|
||||||
|
if partner.x_fc_default_deposit_percent:
|
||||||
|
self.deposit_percent = partner.x_fc_default_deposit_percent
|
||||||
|
if partner.x_fc_default_delivery_method:
|
||||||
|
self.delivery_method = partner.x_fc_default_delivery_method
|
||||||
|
|
||||||
|
# Deadline auto-fill — anchored to planned_start_date with today
|
||||||
|
# as fallback. Honours explicit deadlines the user already typed.
|
||||||
|
anchor = self.planned_start_date or fields.Date.context_today(self)
|
||||||
|
if (partner.x_fc_default_internal_deadline_days
|
||||||
|
and not self.internal_deadline):
|
||||||
|
self.internal_deadline = (
|
||||||
|
anchor + timedelta(days=partner.x_fc_default_internal_deadline_days)
|
||||||
|
)
|
||||||
|
if (partner.x_fc_default_customer_deadline_days
|
||||||
|
and not self.customer_deadline):
|
||||||
|
self.customer_deadline = (
|
||||||
|
anchor + timedelta(days=partner.x_fc_default_customer_deadline_days)
|
||||||
|
)
|
||||||
|
|
||||||
# Addresses.
|
# Addresses.
|
||||||
addrs = self.partner_id.address_get(['invoice', 'delivery'])
|
addrs = partner.address_get(['invoice', 'delivery'])
|
||||||
self.partner_invoice_id = addrs.get('invoice') or self.partner_id.id
|
self.partner_invoice_id = addrs.get('invoice') or partner.id
|
||||||
self.partner_shipping_id = addrs.get('delivery') or self.partner_id.id
|
self.partner_shipping_id = addrs.get('delivery') or partner.id
|
||||||
|
|
||||||
# Per-customer invoice strategy default (fp.invoice.strategy.default).
|
# Legacy fallback: fp.invoice.strategy.default (kept for sites
|
||||||
# Pull strategy + deposit even when payment_term_id is empty — the
|
# mid-migration). Only fills gaps the partner fields didn't cover.
|
||||||
# previous condition `if isd and isd.payment_term_id` silently
|
|
||||||
# skipped the strategy fill for net-terms customers without
|
|
||||||
# explicit terms configured.
|
|
||||||
isd = self.env['fp.invoice.strategy.default'].search(
|
isd = self.env['fp.invoice.strategy.default'].search(
|
||||||
[('partner_id', '=', self.partner_id.id)], limit=1,
|
[('partner_id', '=', partner.id)], limit=1,
|
||||||
)
|
)
|
||||||
term = False
|
term = False
|
||||||
if isd:
|
if isd:
|
||||||
@@ -245,8 +263,8 @@ class FpDirectOrderWizard(models.Model):
|
|||||||
if not self.deposit_percent:
|
if not self.deposit_percent:
|
||||||
self.deposit_percent = isd.default_deposit_percent or 0.0
|
self.deposit_percent = isd.default_deposit_percent or 0.0
|
||||||
term = isd.payment_term_id
|
term = isd.payment_term_id
|
||||||
if not term and self.partner_id.property_payment_term_id:
|
if not term and partner.property_payment_term_id:
|
||||||
term = self.partner_id.property_payment_term_id
|
term = partner.property_payment_term_id
|
||||||
self.payment_term_id = term or False
|
self.payment_term_id = term or False
|
||||||
|
|
||||||
# Re-apply strategy → terms mapping after partner switch.
|
# Re-apply strategy → terms mapping after partner switch.
|
||||||
@@ -271,6 +289,29 @@ class FpDirectOrderWizard(models.Model):
|
|||||||
"""Map the strategy onto sensible payment terms."""
|
"""Map the strategy onto sensible payment terms."""
|
||||||
self._apply_strategy_payment_term()
|
self._apply_strategy_payment_term()
|
||||||
|
|
||||||
|
@api.onchange('planned_start_date')
|
||||||
|
def _onchange_planned_start_date(self):
|
||||||
|
"""Recompute deadlines from partner offsets when start moves.
|
||||||
|
|
||||||
|
Runs only if the partner has offsets configured AND deadlines
|
||||||
|
are still blank — typing a manual deadline locks it.
|
||||||
|
"""
|
||||||
|
if not self.partner_id or not self.planned_start_date:
|
||||||
|
return
|
||||||
|
partner = self.partner_id
|
||||||
|
if (partner.x_fc_default_internal_deadline_days
|
||||||
|
and not self.internal_deadline):
|
||||||
|
self.internal_deadline = (
|
||||||
|
self.planned_start_date
|
||||||
|
+ timedelta(days=partner.x_fc_default_internal_deadline_days)
|
||||||
|
)
|
||||||
|
if (partner.x_fc_default_customer_deadline_days
|
||||||
|
and not self.customer_deadline):
|
||||||
|
self.customer_deadline = (
|
||||||
|
self.planned_start_date
|
||||||
|
+ timedelta(days=partner.x_fc_default_customer_deadline_days)
|
||||||
|
)
|
||||||
|
|
||||||
def _apply_strategy_payment_term(self):
|
def _apply_strategy_payment_term(self):
|
||||||
"""Mapping rule:
|
"""Mapping rule:
|
||||||
- cod_prepay → Immediate Payment
|
- cod_prepay → Immediate Payment
|
||||||
@@ -435,6 +476,12 @@ class FpDirectOrderWizard(models.Model):
|
|||||||
[('default_code', '=', 'FP-SERVICE')], limit=1,
|
[('default_code', '=', 'FP-SERVICE')], limit=1,
|
||||||
)
|
)
|
||||||
if not product:
|
if not product:
|
||||||
|
# Seed the product with the company's default sale tax so the
|
||||||
|
# customer's fiscal position has something to RE-MAP. Without
|
||||||
|
# this, lines come out tax-free regardless of how the customer's
|
||||||
|
# fiscal position is configured (fiscal positions only re-map
|
||||||
|
# existing taxes; they don't manufacture them).
|
||||||
|
default_sale_tax = self.env.company.account_sale_tax_id
|
||||||
product = self.env['product.product'].create({
|
product = self.env['product.product'].create({
|
||||||
'name': 'Plating Service',
|
'name': 'Plating Service',
|
||||||
'default_code': 'FP-SERVICE',
|
'default_code': 'FP-SERVICE',
|
||||||
@@ -442,7 +489,14 @@ class FpDirectOrderWizard(models.Model):
|
|||||||
'list_price': 0,
|
'list_price': 0,
|
||||||
'sale_ok': True,
|
'sale_ok': True,
|
||||||
'purchase_ok': False,
|
'purchase_ok': False,
|
||||||
|
'taxes_id': [(6, 0, default_sale_tax.ids)] if default_sale_tax else False,
|
||||||
})
|
})
|
||||||
|
elif not product.taxes_id and self.env.company.account_sale_tax_id:
|
||||||
|
# Self-heal: pre-existing FP-SERVICE without taxes (created in
|
||||||
|
# an earlier version) silently produced tax-free lines. Top up
|
||||||
|
# with the company default sale tax so customer fiscal positions
|
||||||
|
# can re-map correctly.
|
||||||
|
product.taxes_id = [(6, 0, self.env.company.account_sale_tax_id.ids)]
|
||||||
|
|
||||||
# 3. Build SO header
|
# 3. Build SO header
|
||||||
so_vals = {
|
so_vals = {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Invoicing',
|
'name': 'Fusion Plating — Invoicing',
|
||||||
'version': '19.0.3.2.0',
|
'version': '19.0.3.3.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Invoice strategy engine with deposit, progress billing, net terms, COD/prepay, and account holds.',
|
'summary': 'Invoice strategy engine with deposit, progress billing, net terms, COD/prepay, and account holds.',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
"""Backfill plating defaults from fp.invoice.strategy.default → res.partner.
|
||||||
|
|
||||||
|
v3.3 merges the per-customer invoice strategy onto the partner record
|
||||||
|
itself so the new "Plating Defaults" tab is the single source of truth.
|
||||||
|
Only plain columns are migrated here (invoice_strategy + deposit %).
|
||||||
|
The legacy `fp.invoice.strategy.default` model is left in place; the
|
||||||
|
new sale.order onchange falls back to it for any partner whose record
|
||||||
|
hasn't been migrated, so downstream code keeps working mid-rollout.
|
||||||
|
|
||||||
|
property_payment_term_id is intentionally skipped — it lives in
|
||||||
|
ir_property rather than as a plain column, and the legacy onchange
|
||||||
|
fallback already reads payment_term from the strategy default record
|
||||||
|
when the partner doesn't have one set directly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(cr, version):
|
||||||
|
if not version:
|
||||||
|
return
|
||||||
|
cr.execute("""
|
||||||
|
UPDATE res_partner p
|
||||||
|
SET x_fc_default_invoice_strategy = COALESCE(
|
||||||
|
p.x_fc_default_invoice_strategy, isd.default_strategy),
|
||||||
|
x_fc_default_deposit_percent = COALESCE(
|
||||||
|
NULLIF(p.x_fc_default_deposit_percent, 0),
|
||||||
|
isd.default_deposit_percent)
|
||||||
|
FROM fp_invoice_strategy_default isd
|
||||||
|
WHERE isd.partner_id = p.id
|
||||||
|
""")
|
||||||
|
_logger.info(
|
||||||
|
'fusion_plating_invoicing migration 19.0.3.3.0: backfilled %d '
|
||||||
|
'partner records from fp.invoice.strategy.default',
|
||||||
|
cr.rowcount,
|
||||||
|
)
|
||||||
@@ -9,6 +9,7 @@ from odoo import fields, models
|
|||||||
class ResPartner(models.Model):
|
class ResPartner(models.Model):
|
||||||
_inherit = 'res.partner'
|
_inherit = 'res.partner'
|
||||||
|
|
||||||
|
# ===== Account hold (existing) ============================================
|
||||||
x_fc_account_hold = fields.Boolean(
|
x_fc_account_hold = fields.Boolean(
|
||||||
string='Account Hold', tracking=True,
|
string='Account Hold', tracking=True,
|
||||||
help='When active, blocks SO confirmation, invoicing, and shipping.',
|
help='When active, blocks SO confirmation, invoicing, and shipping.',
|
||||||
@@ -20,3 +21,45 @@ class ResPartner(models.Model):
|
|||||||
x_fc_account_hold_by_id = fields.Many2one(
|
x_fc_account_hold_by_id = fields.Many2one(
|
||||||
'res.users', string='Hold Placed By',
|
'res.users', string='Hold Placed By',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ===== Plating Defaults (cascade onto every new SO for this customer) =====
|
||||||
|
# The estimator sets these once on the customer record; they pre-fill
|
||||||
|
# invoice strategy, delivery method, and deadlines on every new SO so
|
||||||
|
# repeat customers don't need re-typing the same values each order.
|
||||||
|
# Tax type lives on `property_account_position_id` (Odoo native fiscal
|
||||||
|
# position) and payment terms on `property_payment_term_id` — both are
|
||||||
|
# surfaced on the same Plating Defaults tab in the partner form.
|
||||||
|
|
||||||
|
x_fc_default_invoice_strategy = fields.Selection(
|
||||||
|
[('deposit', 'Deposit'),
|
||||||
|
('progress', 'Progress Billing'),
|
||||||
|
('net_terms', 'Net Terms'),
|
||||||
|
('cod_prepay', 'COD / Prepay')],
|
||||||
|
string='Default Invoice Strategy',
|
||||||
|
help='Pre-fills the SO invoice strategy when this customer is selected. '
|
||||||
|
'The estimator can still override per order.',
|
||||||
|
)
|
||||||
|
x_fc_default_deposit_percent = fields.Float(
|
||||||
|
string='Default Deposit %',
|
||||||
|
help='Used when invoice strategy is "Deposit". e.g. 50.0 for 50%.',
|
||||||
|
)
|
||||||
|
x_fc_default_delivery_method = fields.Selection(
|
||||||
|
[('local_delivery', 'Local Delivery'),
|
||||||
|
('shipping_partner', 'Shipping Partner'),
|
||||||
|
('customer_pickup', 'Customer Pickup')],
|
||||||
|
string='Default Delivery Method',
|
||||||
|
help='Pre-fills the SO delivery method when this customer is selected.',
|
||||||
|
)
|
||||||
|
# Lead-time defaults are expressed as offsets FROM the SO's planned-start
|
||||||
|
# date so they track real production schedules, not just "today + N".
|
||||||
|
# If planned_start is unset on the SO, the cascade falls back to today.
|
||||||
|
x_fc_default_internal_deadline_days = fields.Integer(
|
||||||
|
string='Internal Deadline (+ days from start)',
|
||||||
|
help='Pre-fills SO internal deadline as planned_start_date + this '
|
||||||
|
'many days. e.g. 5 means "ship five days after we start".',
|
||||||
|
)
|
||||||
|
x_fc_default_customer_deadline_days = fields.Integer(
|
||||||
|
string='Customer Deadline (+ days from start)',
|
||||||
|
help='Pre-fills the customer-facing commitment date as '
|
||||||
|
'planned_start_date + this many days.',
|
||||||
|
)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
# Part of the Fusion Plating product family.
|
# Part of the Fusion Plating product family.
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from odoo import api, fields, models, _
|
from odoo import api, fields, models, _
|
||||||
from odoo.exceptions import UserError
|
from odoo.exceptions import UserError
|
||||||
@@ -16,16 +17,70 @@ class SaleOrder(models.Model):
|
|||||||
|
|
||||||
@api.onchange('partner_id')
|
@api.onchange('partner_id')
|
||||||
def _onchange_partner_id_invoice_strategy(self):
|
def _onchange_partner_id_invoice_strategy(self):
|
||||||
"""Auto-fill invoice strategy from customer defaults."""
|
"""Auto-fill plating defaults from customer profile.
|
||||||
if self.partner_id:
|
|
||||||
default = self.env['fp.invoice.strategy.default'].search(
|
Cascade order: partner-level defaults first (the new fast-order
|
||||||
[('partner_id', '=', self.partner_id.id)], limit=1,
|
path), then fall back to the legacy fp.invoice.strategy.default
|
||||||
)
|
records for customers migrated before that model was retired.
|
||||||
if default:
|
Native Odoo cascades (payment terms, fiscal position) handle
|
||||||
self.x_fc_invoice_strategy = default.default_strategy
|
themselves via property_* fields and don't need code here.
|
||||||
self.x_fc_deposit_percent = default.default_deposit_percent
|
"""
|
||||||
if default.payment_term_id:
|
if not self.partner_id:
|
||||||
self.payment_term_id = default.payment_term_id
|
return
|
||||||
|
|
||||||
|
partner = self.partner_id
|
||||||
|
|
||||||
|
if partner.x_fc_default_invoice_strategy:
|
||||||
|
self.x_fc_invoice_strategy = partner.x_fc_default_invoice_strategy
|
||||||
|
if partner.x_fc_default_deposit_percent:
|
||||||
|
self.x_fc_deposit_percent = partner.x_fc_default_deposit_percent
|
||||||
|
if partner.x_fc_default_delivery_method:
|
||||||
|
self.x_fc_delivery_method = partner.x_fc_default_delivery_method
|
||||||
|
|
||||||
|
self._fp_recompute_default_deadlines()
|
||||||
|
|
||||||
|
# Legacy fallback: invoice strategy default model. Only fills
|
||||||
|
# gaps left by the partner fields above so a partial migration
|
||||||
|
# doesn't clobber explicit partner-level values.
|
||||||
|
legacy = self.env['fp.invoice.strategy.default'].search(
|
||||||
|
[('partner_id', '=', partner.id)], limit=1,
|
||||||
|
)
|
||||||
|
if legacy:
|
||||||
|
if not self.x_fc_invoice_strategy:
|
||||||
|
self.x_fc_invoice_strategy = legacy.default_strategy
|
||||||
|
if not self.x_fc_deposit_percent:
|
||||||
|
self.x_fc_deposit_percent = legacy.default_deposit_percent
|
||||||
|
if legacy.payment_term_id and not self.payment_term_id:
|
||||||
|
self.payment_term_id = legacy.payment_term_id
|
||||||
|
|
||||||
|
@api.onchange('x_fc_planned_start_date')
|
||||||
|
def _onchange_planned_start_date_deadlines(self):
|
||||||
|
"""Recompute deadlines when planned start changes — without it
|
||||||
|
the partner offsets would only fire on partner_id change."""
|
||||||
|
self._fp_recompute_default_deadlines()
|
||||||
|
|
||||||
|
def _fp_recompute_default_deadlines(self):
|
||||||
|
"""Apply partner deadline offsets relative to planned_start_date.
|
||||||
|
|
||||||
|
Falls back to today when planned_start is unset so the estimator
|
||||||
|
gets a value immediately. Never overwrites a deadline already
|
||||||
|
set by the user (we honour explicit input over auto-fill).
|
||||||
|
"""
|
||||||
|
for order in self:
|
||||||
|
partner = order.partner_id
|
||||||
|
if not partner:
|
||||||
|
continue
|
||||||
|
anchor = order.x_fc_planned_start_date or fields.Date.context_today(order)
|
||||||
|
if (partner.x_fc_default_internal_deadline_days
|
||||||
|
and not order.x_fc_internal_deadline):
|
||||||
|
order.x_fc_internal_deadline = (
|
||||||
|
anchor + timedelta(days=partner.x_fc_default_internal_deadline_days)
|
||||||
|
)
|
||||||
|
if (partner.x_fc_default_customer_deadline_days
|
||||||
|
and not order.commitment_date):
|
||||||
|
order.commitment_date = (
|
||||||
|
anchor + timedelta(days=partner.x_fc_default_customer_deadline_days)
|
||||||
|
)
|
||||||
|
|
||||||
def action_confirm(self):
|
def action_confirm(self):
|
||||||
"""Override to check account hold + customer PO# and trigger
|
"""Override to check account hold + customer PO# and trigger
|
||||||
|
|||||||
@@ -23,7 +23,36 @@
|
|||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</xpath>
|
</xpath>
|
||||||
|
|
||||||
|
<!-- Single "Plating Defaults" tab — invoice strategy, delivery,
|
||||||
|
deadlines, tax type, payment terms. Set once here, cascades
|
||||||
|
onto every new SO for this customer. -->
|
||||||
<xpath expr="//notebook" position="inside">
|
<xpath expr="//notebook" position="inside">
|
||||||
|
<page string="Plating Defaults" name="fp_plating_defaults_tab"
|
||||||
|
invisible="is_company == False and parent_id"
|
||||||
|
groups="fusion_plating_invoicing.group_fp_accounting">
|
||||||
|
<p class="text-muted">
|
||||||
|
Set defaults once per customer to speed up order entry.
|
||||||
|
These cascade onto every new sale order; the estimator
|
||||||
|
can override per order.
|
||||||
|
</p>
|
||||||
|
<group>
|
||||||
|
<group string="Invoicing">
|
||||||
|
<field name="x_fc_default_invoice_strategy"/>
|
||||||
|
<field name="x_fc_default_deposit_percent"
|
||||||
|
invisible="x_fc_default_invoice_strategy != 'deposit'"/>
|
||||||
|
<field name="property_payment_term_id"/>
|
||||||
|
<field name="property_account_position_id"
|
||||||
|
string="Tax Type (Fiscal Position)"/>
|
||||||
|
</group>
|
||||||
|
<group string="Fulfilment">
|
||||||
|
<field name="x_fc_default_delivery_method"/>
|
||||||
|
<field name="x_fc_default_internal_deadline_days"/>
|
||||||
|
<field name="x_fc_default_customer_deadline_days"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</page>
|
||||||
|
|
||||||
<page string="Account Hold" name="account_hold_tab"
|
<page string="Account Hold" name="account_hold_tab"
|
||||||
groups="fusion_plating_invoicing.group_fp_accounting">
|
groups="fusion_plating_invoicing.group_fp_accounting">
|
||||||
<group>
|
<group>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Native Jobs',
|
'name': 'Fusion Plating — Native Jobs',
|
||||||
'version': '19.0.8.8.0',
|
'version': '19.0.8.11.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||||
'author': 'Nexa Systems Inc.',
|
'author': 'Nexa Systems Inc.',
|
||||||
|
|||||||
@@ -202,9 +202,9 @@ class FpJob(models.Model):
|
|||||||
job.racking_inspection_state = ri.state if ri else False
|
job.racking_inspection_state = ri.state if ri else False
|
||||||
|
|
||||||
def action_view_racking_inspection(self):
|
def action_view_racking_inspection(self):
|
||||||
"""Open the racking inspection. Auto-create if missing (e.g. job
|
"""Open the racking inspection. Auto-create if missing, or seed
|
||||||
was created before Sub 8 shipped, or auto-create silently failed
|
lines from the SO if it exists but was created before line auto-
|
||||||
at action_confirm time)."""
|
seeding shipped (the helper handles both cases idempotently)."""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
if 'fp.racking.inspection' not in self.env:
|
if 'fp.racking.inspection' not in self.env:
|
||||||
from odoo.exceptions import UserError
|
from odoo.exceptions import UserError
|
||||||
@@ -212,9 +212,12 @@ class FpJob(models.Model):
|
|||||||
'Sub 8 racking inspection module not installed. '
|
'Sub 8 racking inspection module not installed. '
|
||||||
'Install fusion_plating_receiving to enable.'
|
'Install fusion_plating_receiving to enable.'
|
||||||
))
|
))
|
||||||
if not self.racking_inspection_id:
|
# Always call the helper — it short-circuits for already-populated
|
||||||
self._fp_create_racking_inspection()
|
# draft inspections and creates fresh ones when missing. This is
|
||||||
self.invalidate_recordset(['racking_inspection_ids'])
|
# also the entry point that backfills lines on inspections that
|
||||||
|
# pre-date the line-seeding feature.
|
||||||
|
self._fp_create_racking_inspection()
|
||||||
|
self.invalidate_recordset(['racking_inspection_ids'])
|
||||||
ri = self.racking_inspection_id or self.racking_inspection_ids[:1]
|
ri = self.racking_inspection_id or self.racking_inspection_ids[:1]
|
||||||
if not ri:
|
if not ri:
|
||||||
from odoo.exceptions import UserError
|
from odoo.exceptions import UserError
|
||||||
@@ -239,11 +242,39 @@ class FpJob(models.Model):
|
|||||||
'context': {'default_job_id': self.id},
|
'context': {'default_job_id': self.id},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def action_finish_current_step(self):
|
||||||
|
"""Steelhead-style header button: finish whatever's currently
|
||||||
|
in_progress and auto-start the next pending/ready step. If
|
||||||
|
nothing is running yet, start the lowest-sequence pending step
|
||||||
|
instead — operator's first click on a fresh job just begins
|
||||||
|
the line.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
running = self.step_ids.filtered(lambda s: s.state == 'in_progress')[:1]
|
||||||
|
if running:
|
||||||
|
return running.action_finish_and_advance()
|
||||||
|
# No running step — kick off the first pending/ready one.
|
||||||
|
first = self.step_ids.filtered(
|
||||||
|
lambda s: s.state in ('pending', 'ready', 'paused')
|
||||||
|
).sorted('sequence')[:1]
|
||||||
|
if not first:
|
||||||
|
raise UserError(_(
|
||||||
|
'No runnable step found on this job — either every step '
|
||||||
|
'is done or the job is still in draft.'
|
||||||
|
))
|
||||||
|
first.with_context(fp_skip_predecessor_check=True).button_start()
|
||||||
|
self.message_post(body=_(
|
||||||
|
'Started first step "%s".'
|
||||||
|
) % first.name)
|
||||||
|
return True
|
||||||
|
|
||||||
def action_open_move_wizard(self):
|
def action_open_move_wizard(self):
|
||||||
"""Header button — opens the Move wizard pre-filled with the
|
"""Original Move wizard — kept available for cross-station moves
|
||||||
currently in-progress (or most recently in-progress) step as the
|
and rework / scrap transfers. The simple "finish current → start
|
||||||
from-step. Lets the manager move the job forward without first
|
next" flow is now action_finish_current_step (header button).
|
||||||
clicking into a specific step row.
|
|
||||||
|
Opens the wizard pre-filled with the currently in-progress (or
|
||||||
|
most recently in-progress) step as the from-step.
|
||||||
"""
|
"""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
active_step = self.step_ids.filtered(
|
active_step = self.step_ids.filtered(
|
||||||
@@ -871,6 +902,9 @@ class FpJob(models.Model):
|
|||||||
production_id too so legacy reports keep working.
|
production_id too so legacy reports keep working.
|
||||||
|
|
||||||
Idempotent — if an inspection already exists for this job, skip.
|
Idempotent — if an inspection already exists for this job, skip.
|
||||||
|
Either way the inspection's lines are seeded from the SO's
|
||||||
|
plating order lines so the racker walks into a pre-populated
|
||||||
|
checklist instead of an empty form.
|
||||||
"""
|
"""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
if 'fp.racking.inspection' not in self.env:
|
if 'fp.racking.inspection' not in self.env:
|
||||||
@@ -883,17 +917,62 @@ class FpJob(models.Model):
|
|||||||
('x_fc_job_id', '=', self.id),
|
('x_fc_job_id', '=', self.id),
|
||||||
], limit=1)
|
], limit=1)
|
||||||
if existing:
|
if existing:
|
||||||
|
# Self-heal: pre-existing inspections from before line seeding
|
||||||
|
# was added show up empty. Top them up now if still empty +
|
||||||
|
# the inspection isn't already finalised (don't rewrite history).
|
||||||
|
if not existing.line_ids and existing.state == 'draft':
|
||||||
|
self._fp_seed_racking_lines(existing)
|
||||||
return
|
return
|
||||||
# Phase 6 (Sub 11) — production_id retired; bind by x_fc_job_id only.
|
# Phase 6 (Sub 11) — production_id retired; bind by x_fc_job_id only.
|
||||||
vals = {'x_fc_job_id': self.id}
|
vals = {'x_fc_job_id': self.id}
|
||||||
try:
|
try:
|
||||||
Inspection.create(vals)
|
insp = Inspection.create(vals)
|
||||||
|
self._fp_seed_racking_lines(insp)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.warning(
|
_logger.warning(
|
||||||
"Job %s: failed to auto-create racking inspection: %s",
|
"Job %s: failed to auto-create racking inspection: %s",
|
||||||
self.name, e,
|
self.name, e,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _fp_seed_racking_lines(self, inspection):
|
||||||
|
"""Populate the inspection with one line per SO plating order line.
|
||||||
|
|
||||||
|
Walks sale_order_line_ids (the M2M of SO lines tied to this job),
|
||||||
|
falling back to the linked SO's order_line. Each line carries the
|
||||||
|
part_catalog and the quoted qty as the expected count — the
|
||||||
|
racker confirms or amends on the floor.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
if not inspection or inspection.line_ids:
|
||||||
|
return
|
||||||
|
Line = self.env['fp.racking.inspection.line'].sudo()
|
||||||
|
# Source preference: explicit M2M of plating lines bound to this
|
||||||
|
# job (fast-order multi-part jobs), falling back to the SO header.
|
||||||
|
so_lines = self.sale_order_line_ids
|
||||||
|
if not so_lines and self.sale_order_id:
|
||||||
|
so_lines = self.sale_order_id.order_line
|
||||||
|
plating_lines = so_lines.filtered(
|
||||||
|
lambda l: l.x_fc_part_catalog_id and not l.display_type
|
||||||
|
)
|
||||||
|
if not plating_lines:
|
||||||
|
return
|
||||||
|
seq = 10
|
||||||
|
for sol in plating_lines:
|
||||||
|
try:
|
||||||
|
Line.create({
|
||||||
|
'inspection_id': inspection.id,
|
||||||
|
'sequence': seq,
|
||||||
|
'part_catalog_id': sol.x_fc_part_catalog_id.id,
|
||||||
|
'qty_expected': int(sol.product_uom_qty or 0),
|
||||||
|
'condition': 'ok',
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
_logger.warning(
|
||||||
|
"Job %s: failed to seed racking line for SO line %s: %s",
|
||||||
|
self.name, sol.id, e,
|
||||||
|
)
|
||||||
|
seq += 10
|
||||||
|
|
||||||
def _fp_create_portal_job(self):
|
def _fp_create_portal_job(self):
|
||||||
"""Create the fusion.plating.portal.job mirror record."""
|
"""Create the fusion.plating.portal.job mirror record."""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
|
|||||||
@@ -326,6 +326,98 @@ class FpJobStep(models.Model):
|
|||||||
)) % (step.name, old, new, new - old, self.env.user.name))
|
)) % (step.name, old, new, new - old, self.env.user.name))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def action_finish_and_advance(self):
|
||||||
|
"""Steelhead-style "Finish & Next" — finish this step then auto-
|
||||||
|
start the next pending/ready step in sequence. Single click
|
||||||
|
replaces the prior Finish-then-Move-wizard dance.
|
||||||
|
|
||||||
|
If the step has authored step_input prompts AND none have been
|
||||||
|
captured yet, we route through the simplified Record Inputs
|
||||||
|
wizard first; saving the wizard re-enters here with the
|
||||||
|
`fp_after_inputs=True` context flag so we don't loop.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
if self.state != 'in_progress':
|
||||||
|
raise UserError(_(
|
||||||
|
"Step '%s' is in state '%s' — start it before clicking Finish."
|
||||||
|
) % (self.name, self.state))
|
||||||
|
|
||||||
|
# Prompt-first behaviour: show the Record Inputs dialog when the
|
||||||
|
# recipe step has authored prompts and nothing has been captured
|
||||||
|
# in this run. Bypass when context flag is set (i.e. we're being
|
||||||
|
# called BACK from the wizard's commit), or when the operator
|
||||||
|
# already saved values via the Record Inputs button earlier.
|
||||||
|
if (not self.env.context.get('fp_after_inputs')
|
||||||
|
and self._fp_has_uncaptured_step_inputs()):
|
||||||
|
return self._fp_open_input_wizard(advance_after=True)
|
||||||
|
|
||||||
|
self.button_finish()
|
||||||
|
next_step = self._fp_next_runnable_step()
|
||||||
|
if next_step:
|
||||||
|
next_step.with_context(
|
||||||
|
fp_skip_predecessor_check=True,
|
||||||
|
).button_start()
|
||||||
|
self.job_id.message_post(body=_(
|
||||||
|
'Step "%(prev)s" finished — auto-started next step "%(next)s".'
|
||||||
|
) % {'prev': self.name, 'next': next_step.name})
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _fp_next_runnable_step(self):
|
||||||
|
"""The lowest-sequence step on this job that isn't terminal yet
|
||||||
|
and isn't this one. Used by action_finish_and_advance."""
|
||||||
|
self.ensure_one()
|
||||||
|
candidates = self.job_id.step_ids.filtered(
|
||||||
|
lambda s: s.id != self.id
|
||||||
|
and s.state in ('pending', 'ready', 'paused')
|
||||||
|
).sorted('sequence')
|
||||||
|
return candidates[:1] or self.env['fp.job.step']
|
||||||
|
|
||||||
|
def _fp_has_uncaptured_step_inputs(self):
|
||||||
|
"""True when the recipe step defines step_input prompts AND
|
||||||
|
the user hasn't already saved values for this step's current
|
||||||
|
run via the Record Inputs wizard.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
node = self.recipe_node_id
|
||||||
|
if not node:
|
||||||
|
return False
|
||||||
|
prompts = node.input_ids
|
||||||
|
if 'kind' in prompts._fields:
|
||||||
|
prompts = prompts.filtered(lambda i: i.kind == 'step_input')
|
||||||
|
if not prompts:
|
||||||
|
return False
|
||||||
|
# Has the operator already recorded values during this run?
|
||||||
|
# Heuristic: any in-place fp.job.step.move (transfer_type='step')
|
||||||
|
# for this step since date_started.
|
||||||
|
Move = self.env['fp.job.step.move']
|
||||||
|
already = Move.search_count([
|
||||||
|
('from_step_id', '=', self.id),
|
||||||
|
('transfer_type', '=', 'step'),
|
||||||
|
('move_datetime', '>=', self.date_started or fields.Datetime.now()),
|
||||||
|
])
|
||||||
|
return already == 0
|
||||||
|
|
||||||
|
def _fp_open_input_wizard(self, advance_after=False):
|
||||||
|
"""Open the simplified Record Inputs dialog. When advance_after
|
||||||
|
is True, the wizard's Save button finishes the step and starts
|
||||||
|
the next one as a single atomic flow."""
|
||||||
|
self.ensure_one()
|
||||||
|
action = self.env['ir.actions.act_window']._for_xml_id(
|
||||||
|
'fusion_plating_jobs.action_fp_job_step_input_wizard'
|
||||||
|
)
|
||||||
|
action['context'] = {
|
||||||
|
**dict(self.env.context),
|
||||||
|
'default_step_id': self.id,
|
||||||
|
'active_id': self.id,
|
||||||
|
'fp_advance_after_save': advance_after,
|
||||||
|
}
|
||||||
|
return action
|
||||||
|
|
||||||
|
# NB: action_open_input_wizard is defined further down (line ~829)
|
||||||
|
# — that one stays as the per-row "Record" button entry-point.
|
||||||
|
# _fp_open_input_wizard above adds the advance_after pathway used
|
||||||
|
# only by action_finish_and_advance.
|
||||||
|
|
||||||
def button_finish(self):
|
def button_finish(self):
|
||||||
"""Override to:
|
"""Override to:
|
||||||
1) Auto-spawn a bake.window when a wet plating step finishes
|
1) Auto-spawn a bake.window when a wet plating step finishes
|
||||||
|
|||||||
@@ -18,11 +18,16 @@
|
|||||||
<field name="name">FP Traveller — A4 landscape narrow margins</field>
|
<field name="name">FP Traveller — A4 landscape narrow margins</field>
|
||||||
<field name="format">A4</field>
|
<field name="format">A4</field>
|
||||||
<field name="orientation">Landscape</field>
|
<field name="orientation">Landscape</field>
|
||||||
<field name="margin_top">10</field>
|
<!-- margin_top + header_spacing both reserve room above the body
|
||||||
|
so the H1 / Item Information table doesn't ride into the
|
||||||
|
external_layout's company logo band. The screenshot showed
|
||||||
|
"Work Order / Bon de Travail" overlapping the ENTECH logo
|
||||||
|
with the prior 10 / 5 values; 28 / 22 buys ~1cm clear gap. -->
|
||||||
|
<field name="margin_top">28</field>
|
||||||
<field name="margin_bottom">10</field>
|
<field name="margin_bottom">10</field>
|
||||||
<field name="margin_left">8</field>
|
<field name="margin_left">8</field>
|
||||||
<field name="margin_right">8</field>
|
<field name="margin_right">8</field>
|
||||||
<field name="header_spacing">5</field>
|
<field name="header_spacing">22</field>
|
||||||
<field name="dpi">90</field>
|
<field name="dpi">90</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
|
|||||||
@@ -21,11 +21,17 @@
|
|||||||
<field name="name">FP Work Order Detail — A4 portrait</field>
|
<field name="name">FP Work Order Detail — A4 portrait</field>
|
||||||
<field name="format">A4</field>
|
<field name="format">A4</field>
|
||||||
<field name="orientation">Portrait</field>
|
<field name="orientation">Portrait</field>
|
||||||
<field name="margin_top">15</field>
|
<!-- margin_top + header_spacing both reserve room above the body
|
||||||
|
content. The external_layout puts the company logo + address
|
||||||
|
in that band; without enough space the header overlaps the
|
||||||
|
body's first line (the H1 on page 1, the Certified By table
|
||||||
|
on page 2). 35 / 28 puts a clean ~1cm clear gap below the
|
||||||
|
logo block. -->
|
||||||
|
<field name="margin_top">35</field>
|
||||||
<field name="margin_bottom">15</field>
|
<field name="margin_bottom">15</field>
|
||||||
<field name="margin_left">12</field>
|
<field name="margin_left">12</field>
|
||||||
<field name="margin_right">12</field>
|
<field name="margin_right">12</field>
|
||||||
<field name="header_spacing">8</field>
|
<field name="header_spacing">28</field>
|
||||||
<field name="dpi">90</field>
|
<field name="dpi">90</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
@@ -46,14 +52,42 @@
|
|||||||
<t t-foreach="docs" t-as="job">
|
<t t-foreach="docs" t-as="job">
|
||||||
<t t-call="web.external_layout">
|
<t t-call="web.external_layout">
|
||||||
<t t-set="company" t-value="job.company_id"/>
|
<t t-set="company" t-value="job.company_id"/>
|
||||||
<t t-set="moves" t-value="job.move_ids.sorted('move_datetime')"/>
|
<t t-set="so" t-value="job.sale_order_id"/>
|
||||||
|
<!-- All datetimes in Postgres are naive UTC. QWeb's
|
||||||
|
eval scope exposes neither pytz nor format_datetime,
|
||||||
|
so timestamp formatting happens via job.fp_format_local()
|
||||||
|
on the record itself — record methods are always
|
||||||
|
available in templates. The helper resolves user.tz
|
||||||
|
→ company.x_fc_default_tz → UTC. -->
|
||||||
|
|
||||||
|
<!-- First SO line linked to this job — source of truth
|
||||||
|
for the customer-facing description, serial(s),
|
||||||
|
and part metadata. -->
|
||||||
|
<t t-set="primary_line" t-value="job.sale_order_line_ids[:1]"/>
|
||||||
|
<t t-set="po_number"
|
||||||
|
t-value="(so and (so.client_order_ref or (
|
||||||
|
'x_fc_po_number' in so._fields and so.x_fc_po_number) or ''))
|
||||||
|
or ''"/>
|
||||||
|
<t t-set="customer_desc"
|
||||||
|
t-value="primary_line and primary_line.fp_customer_description() or ''"/>
|
||||||
|
<t t-set="serial_names"
|
||||||
|
t-value="primary_line and 'x_fc_serial_ids' in primary_line._fields
|
||||||
|
and ', '.join(primary_line.x_fc_serial_ids.mapped('name'))
|
||||||
|
or ''"/>
|
||||||
|
<!-- Walk EVERY step in sequence, not just moves. The
|
||||||
|
old report only rendered moves so steps without
|
||||||
|
recorded measurements (just Finish & Next) never
|
||||||
|
appeared on the cert. -->
|
||||||
|
<t t-set="all_steps" t-value="job.step_ids.filtered(
|
||||||
|
lambda s: s.state not in ('cancelled',)
|
||||||
|
).sorted('sequence')"/>
|
||||||
|
|
||||||
<div class="page fp-wo-detail">
|
<div class="page fp-wo-detail">
|
||||||
<style>
|
<style>
|
||||||
.fp-wo-detail { font-family: Arial, sans-serif; font-size: 9pt; color: #000; }
|
.fp-wo-detail { font-family: Arial, sans-serif; font-size: 9pt; color: #000; }
|
||||||
.fp-wo-detail h1 { text-align: center; font-size: 18pt; margin: 0 0 6px 0; font-weight: bold; color: #1a4d80; }
|
.fp-wo-detail h1 { text-align: center; font-size: 18pt; margin: 0 0 14px 0; font-weight: bold; color: #1a4d80; }
|
||||||
.fp-wo-detail h3 { font-size: 11pt; margin: 8px 0 2px 0; font-weight: bold; }
|
.fp-wo-detail h3 { font-size: 11pt; margin: 12px 0 4px 0; font-weight: bold; }
|
||||||
.fp-wo-detail .fp-meta { font-size: 8.5pt; color: #444; margin-bottom: 4px; }
|
.fp-wo-detail .fp-meta { font-size: 8.5pt; color: #444; margin-bottom: 6px; }
|
||||||
.fp-wo-detail table.bordered,
|
.fp-wo-detail table.bordered,
|
||||||
.fp-wo-detail table.bordered th,
|
.fp-wo-detail table.bordered th,
|
||||||
.fp-wo-detail table.bordered td { border: 1px solid #000; border-collapse: collapse; }
|
.fp-wo-detail table.bordered td { border: 1px solid #000; border-collapse: collapse; }
|
||||||
@@ -61,15 +95,16 @@
|
|||||||
.fp-wo-detail table.bordered th { background: #ededed; padding: 4px 6px; font-size: 8.5pt; text-align: left; }
|
.fp-wo-detail table.bordered th { background: #ededed; padding: 4px 6px; font-size: 8.5pt; text-align: left; }
|
||||||
.fp-wo-detail table.bordered td { padding: 4px 6px; vertical-align: top; font-size: 8.5pt; }
|
.fp-wo-detail table.bordered td { padding: 4px 6px; vertical-align: top; font-size: 8.5pt; }
|
||||||
.fp-wo-detail .text-center { text-align: center; }
|
.fp-wo-detail .text-center { text-align: center; }
|
||||||
.fp-wo-detail hr.heavy { border: 0; border-top: 2px solid #000; margin: 8px 0; }
|
.fp-wo-detail hr.heavy { border: 0; border-top: 2px solid #000; margin: 12px 0; }
|
||||||
.fp-wo-detail .fp-spec { font-size: 10pt; font-weight: bold; margin: 8px 0 4px 0; }
|
.fp-wo-detail .fp-spec { font-size: 10pt; font-weight: bold; margin: 10px 0 6px 0; }
|
||||||
.fp-wo-detail .fp-step-block { page-break-inside: avoid; margin-bottom: 6px; }
|
.fp-wo-detail .fp-step-block { page-break-inside: avoid; margin-bottom: 14px; }
|
||||||
|
.fp-wo-detail .fp-prepared { margin-bottom: 14px; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<h1>Work Order Detail</h1>
|
<h1>Work Order Detail</h1>
|
||||||
|
|
||||||
<!-- ===== HEADER — Prepared For + summary table ===== -->
|
<!-- ===== HEADER — Prepared For + summary table ===== -->
|
||||||
<div style="margin-bottom: 8px;">
|
<div class="fp-prepared">
|
||||||
<strong>Prepared For:</strong>
|
<strong>Prepared For:</strong>
|
||||||
<span style="font-size: 11pt;"
|
<span style="font-size: 11pt;"
|
||||||
t-esc="(job.partner_id and job.partner_id.name) or '—'"/>
|
t-esc="(job.partner_id and job.partner_id.name) or '—'"/>
|
||||||
@@ -77,35 +112,41 @@
|
|||||||
|
|
||||||
<table class="bordered">
|
<table class="bordered">
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width: 20%;">Part Number</th>
|
<th style="width: 18%;">Part Number</th>
|
||||||
<th style="width: 30%;">Description</th>
|
<th style="width: 30%;">Description</th>
|
||||||
<th style="width: 8%;">Quantity</th>
|
<th style="width: 7%;">Quantity</th>
|
||||||
<th style="width: 10%;">Work Order</th>
|
<th style="width: 11%;">Work Order</th>
|
||||||
<th style="width: 14%;">PO Number</th>
|
<th style="width: 12%;">PO Number</th>
|
||||||
<th style="width: 8%;">Packing List No</th>
|
<th style="width: 12%;">Serial No</th>
|
||||||
<th style="width: 10%;">Date</th>
|
<th style="width: 10%;">Date</th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<t t-if="'part_catalog_id' in job._fields and job.part_catalog_id">
|
<t t-if="'part_catalog_id' in job._fields and job.part_catalog_id">
|
||||||
<span t-esc="job.part_catalog_id.part_number or '—'"/>
|
<span t-esc="job.part_catalog_id.part_number or '—'"/>
|
||||||
|
<t t-if="'revision' in job.part_catalog_id._fields and job.part_catalog_id.revision">
|
||||||
|
<br/>
|
||||||
|
<span style="font-size: 7.5pt;">Rev <span t-esc="job.part_catalog_id.revision"/></span>
|
||||||
|
</t>
|
||||||
</t>
|
</t>
|
||||||
<t t-else="">
|
<t t-else="">
|
||||||
<span t-esc="(job.product_id and job.product_id.default_code) or '—'"/>
|
<span t-esc="(job.product_id and job.product_id.default_code) or '—'"/>
|
||||||
</t>
|
</t>
|
||||||
</td>
|
</td>
|
||||||
<td style="white-space: pre-wrap;">
|
<td style="vertical-align: top;">
|
||||||
<t t-if="'part_catalog_id' in job._fields and job.part_catalog_id">
|
<!-- Customer-facing description. The
|
||||||
<span t-esc="job.part_catalog_id.name or job.product_id.name or '—'"/>
|
pre-line wrapper lives on an
|
||||||
</t>
|
INNER div, not the <td>: keeping
|
||||||
<t t-else="">
|
pre-line on the cell rendered
|
||||||
<span t-esc="(job.product_id and job.product_id.name) or '—'"/>
|
the indentation between <td>
|
||||||
</t>
|
and <t t-if> as literal blank
|
||||||
<t t-if="'special_requirements' in job._fields and job.special_requirements">
|
lines, pushing the description
|
||||||
<br/>
|
halfway down the cell. The div
|
||||||
<span style="font-size: 7.5pt;"
|
only sees the t-esc'd text, so
|
||||||
t-esc="job.special_requirements"/>
|
pre-line preserves the operator's
|
||||||
</t>
|
intentional \n\n paragraph
|
||||||
|
breaks but nothing else. -->
|
||||||
|
<div style="white-space: pre-line;"><t t-if="customer_desc"><span t-esc="customer_desc.strip()"/></t><t t-elif="'part_catalog_id' in job._fields and job.part_catalog_id"><span t-esc="job.part_catalog_id.name or job.product_id.name or '—'"/></t><t t-else=""><span t-esc="(job.product_id and job.product_id.name) or '—'"/></t></div>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
<span t-esc="job.qty"/>
|
<span t-esc="job.qty"/>
|
||||||
@@ -114,11 +155,15 @@
|
|||||||
<span t-esc="job.name"/>
|
<span t-esc="job.name"/>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span t-esc="(job.sale_order_id and job.sale_order_id.client_order_ref) or '—'"/>
|
<span t-esc="po_number or '—'"/>
|
||||||
</td>
|
</td>
|
||||||
<td/>
|
|
||||||
<td>
|
<td>
|
||||||
<span t-esc="(job.date_finished or job.date_started or job.create_date) and (job.date_finished or job.date_started or job.create_date).strftime('%Y-%m-%d') or ''"/>
|
<span t-esc="serial_names or '—'"/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<t t-set="_hdr_dt"
|
||||||
|
t-value="job.date_finished or job.date_started or job.create_date"/>
|
||||||
|
<span t-esc="job.fp_format_local(_hdr_dt, '%Y-%m-%d')"/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -130,15 +175,39 @@
|
|||||||
|
|
||||||
<hr class="heavy"/>
|
<hr class="heavy"/>
|
||||||
|
|
||||||
<!-- ===== CHAIN-OF-CUSTODY WALK ===== -->
|
<!-- ===== STEPS WALK ===== -->
|
||||||
<t t-foreach="moves" t-as="mv">
|
<t t-foreach="all_steps" t-as="step">
|
||||||
<t t-set="dest" t-value="mv.to_step_id"/>
|
<!-- Aggregate captured input values from any
|
||||||
<t t-set="tank_code" t-value="(mv.to_tank_id and mv.to_tank_id.code) or (dest and dest.tank_id and dest.tank_id.code) or ''"/>
|
move that touches this step (incoming or
|
||||||
|
outgoing — the Record Inputs wizard
|
||||||
|
creates a self-loop move with from=to=step). -->
|
||||||
|
<t t-set="step_moves"
|
||||||
|
t-value="job.move_ids.filtered(
|
||||||
|
lambda m: m.from_step_id == step or m.to_step_id == step
|
||||||
|
).sorted('move_datetime')"/>
|
||||||
|
<t t-set="step_values"
|
||||||
|
t-value="step_moves.mapped('transition_input_value_ids')"/>
|
||||||
|
<!-- Pick a representative "Moved By" / Time:
|
||||||
|
prefer the step's own date_finished, fall
|
||||||
|
back to first move on the step, fall back
|
||||||
|
to date_started. Same for the user. -->
|
||||||
|
<t t-set="display_dt"
|
||||||
|
t-value="step.date_finished or (step_moves and step_moves[-1].move_datetime) or step.date_started or False"/>
|
||||||
|
<t t-set="display_user"
|
||||||
|
t-value="(step.finished_by_user_id and step.finished_by_user_id.name)
|
||||||
|
or (step_moves and step_moves[-1].moved_by_user_id and step_moves[-1].moved_by_user_id.name)
|
||||||
|
or (step.started_by_user_id and step.started_by_user_id.name)
|
||||||
|
or ''"/>
|
||||||
|
|
||||||
<div class="fp-step-block">
|
<div class="fp-step-block">
|
||||||
<h3>
|
<h3>
|
||||||
<span t-esc="(dest and dest.name) or '—'"/>
|
<span t-esc="step.name or '—'"/>
|
||||||
<t t-if="tank_code"> (<span t-esc="tank_code"/>)</t>
|
<t t-if="step.tank_id and step.tank_id.code">
|
||||||
|
(<span t-esc="step.tank_id.code"/>)
|
||||||
|
</t>
|
||||||
|
<t t-if="step.state == 'skipped'">
|
||||||
|
<span style="font-size: 9pt; color: #888; font-weight: normal;">— SKIPPED</span>
|
||||||
|
</t>
|
||||||
</h3>
|
</h3>
|
||||||
<div class="fp-meta">
|
<div class="fp-meta">
|
||||||
<strong>Part Number:</strong>
|
<strong>Part Number:</strong>
|
||||||
@@ -151,69 +220,72 @@
|
|||||||
<t t-else="">
|
<t t-else="">
|
||||||
<span t-esc="(job.product_id and (job.product_id.default_code or job.product_id.name)) or ''"/>
|
<span t-esc="(job.product_id and (job.product_id.default_code or job.product_id.name)) or ''"/>
|
||||||
</t>
|
</t>
|
||||||
<br/>
|
<t t-if="display_user or display_dt">
|
||||||
<strong>Moved By:</strong> <span t-esc="mv.moved_by_user_id.name"/>
|
<br/>
|
||||||
<span> </span>
|
<strong>Moved By:</strong>
|
||||||
<strong>Time:</strong>
|
<span t-esc="display_user or '—'"/>
|
||||||
<span t-esc="mv.move_datetime and mv.move_datetime.strftime('%b %d, %Y %I:%M:%S %p') or ''"/>
|
<span> </span>
|
||||||
|
<strong>Time:</strong>
|
||||||
|
<span t-esc="job.fp_format_local(display_dt, '%b %d, %Y %I:%M:%S %p') or '—'"/>
|
||||||
|
</t>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Captured input values for this move -->
|
<!-- Captured inputs table — only rendered
|
||||||
<t t-set="captured_values_by_input"
|
when this step has at least one
|
||||||
t-value="{v.node_input_id.id: v for v in mv.transition_input_value_ids}"/>
|
value recorded across all its moves. -->
|
||||||
<t t-set="prompts" t-value="False"/>
|
<t t-if="step_values">
|
||||||
<t t-if="dest and dest.recipe_node_id">
|
|
||||||
<t t-set="prompts"
|
|
||||||
t-value="dest.recipe_node_id.input_ids.filtered(lambda i: (i.kind or 'step_input') == 'step_input').sorted('sequence')"/>
|
|
||||||
</t>
|
|
||||||
<t t-if="not prompts and mv.transition_input_value_ids">
|
|
||||||
<t t-set="prompts"
|
|
||||||
t-value="mv.transition_input_value_ids.mapped('node_input_id')"/>
|
|
||||||
</t>
|
|
||||||
|
|
||||||
<t t-if="prompts and mv.transition_input_value_ids">
|
|
||||||
<table class="bordered">
|
<table class="bordered">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width: 24%;">Name</th>
|
<th style="width: 24%;">Name</th>
|
||||||
<th style="width: 30%;">Description</th>
|
<th style="width: 32%;">Description</th>
|
||||||
<th style="width: 18%;">Value</th>
|
<th style="width: 18%;">Value</th>
|
||||||
<th style="width: 28%;">Recorded By</th>
|
<th style="width: 26%;">Recorded By</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<t t-foreach="prompts" t-as="inp">
|
<t t-foreach="step_values" t-as="cv">
|
||||||
<t t-set="cv" t-value="captured_values_by_input.get(inp.id)"/>
|
<t t-set="inp" t-value="cv.node_input_id"/>
|
||||||
<t t-if="cv">
|
<t t-set="prompt_name"
|
||||||
<t t-set="actual_str" t-value="''"/>
|
t-value="(inp and inp.name) or (cv.value_text and cv.value_text.split(':')[0]) or 'Measurement'"/>
|
||||||
<t t-if="cv.value_text">
|
<t t-set="prompt_hint"
|
||||||
<t t-set="actual_str" t-value="cv.value_text"/>
|
t-value="(inp and 'hint' in inp._fields and inp.hint) or ''"/>
|
||||||
|
<t t-set="actual_str" t-value="''"/>
|
||||||
|
<t t-if="cv.value_text">
|
||||||
|
<t t-set="actual_str" t-value="cv.value_text"/>
|
||||||
|
<!-- Strip the leading "Prompt:" prefix that
|
||||||
|
ad-hoc rows store so the Value cell
|
||||||
|
shows just the value, not the prompt
|
||||||
|
twice. -->
|
||||||
|
<t t-if="inp and inp.name and actual_str.startswith(inp.name + ':')">
|
||||||
|
<t t-set="actual_str" t-value="actual_str[len(inp.name)+1:].strip()"/>
|
||||||
</t>
|
</t>
|
||||||
<t t-elif="cv.value_number">
|
|
||||||
<t t-set="actual_str"
|
|
||||||
t-value="('%s %s' % (cv.value_number, (inp.target_unit if 'target_unit' in inp._fields and inp.target_unit else ''))).strip()"/>
|
|
||||||
</t>
|
|
||||||
<t t-elif="cv.value_boolean is not False">
|
|
||||||
<t t-set="actual_str" t-value="'PASS' if cv.value_boolean else 'FAIL'"/>
|
|
||||||
</t>
|
|
||||||
<t t-elif="cv.value_date">
|
|
||||||
<t t-set="actual_str" t-value="cv.value_date.strftime('%Y-%m-%d %H:%M')"/>
|
|
||||||
</t>
|
|
||||||
<tr>
|
|
||||||
<td><span t-esc="inp.name"/></td>
|
|
||||||
<td>
|
|
||||||
<t t-if="'hint' in inp._fields and inp.hint">
|
|
||||||
<span t-esc="inp.hint"/>
|
|
||||||
</t>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<strong t-esc="actual_str"/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span t-esc="(mv.moved_by_user_id and mv.moved_by_user_id.name) or ''"/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</t>
|
</t>
|
||||||
|
<t t-elif="cv.value_number">
|
||||||
|
<t t-set="_unit" t-value="(inp and 'target_unit' in inp._fields and inp.target_unit) or ''"/>
|
||||||
|
<t t-set="actual_str" t-value="('%s %s' % (cv.value_number, _unit)).strip()"/>
|
||||||
|
</t>
|
||||||
|
<t t-elif="cv.value_boolean is not False">
|
||||||
|
<t t-set="actual_str" t-value="'PASS' if cv.value_boolean else 'FAIL'"/>
|
||||||
|
</t>
|
||||||
|
<t t-elif="cv.value_date">
|
||||||
|
<t t-set="actual_str"
|
||||||
|
t-value="job.fp_format_local(cv.value_date, '%Y-%m-%d %H:%M')"/>
|
||||||
|
</t>
|
||||||
|
<tr>
|
||||||
|
<td><span t-esc="prompt_name"/></td>
|
||||||
|
<td>
|
||||||
|
<t t-if="prompt_hint">
|
||||||
|
<span t-esc="prompt_hint"/>
|
||||||
|
</t>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<strong t-esc="actual_str"/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span t-esc="(cv.move_id.moved_by_user_id and cv.move_id.moved_by_user_id.name) or ''"/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</t>
|
</t>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -221,16 +293,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</t>
|
</t>
|
||||||
|
|
||||||
<t t-if="not moves">
|
<t t-if="not all_steps">
|
||||||
<p style="color: #888; font-style: italic;">
|
<p style="color: #888; font-style: italic;">
|
||||||
No move log entries yet — this job hasn't progressed
|
No steps on this job yet — operators progress the
|
||||||
through any steps. Operators move the job forward
|
job via Start / Finish & Next on the form, or
|
||||||
via the tablet or the backend Move wizard.
|
via the tablet.
|
||||||
</p>
|
</p>
|
||||||
</t>
|
</t>
|
||||||
|
|
||||||
<!-- ===== CERTIFIED BY + CERT STATEMENT ===== -->
|
<!-- ===== CERTIFIED BY + CERT STATEMENT ===== -->
|
||||||
<p style="page-break-before: always;"/>
|
<!-- page-break-before is honoured by wkhtmltopdf
|
||||||
|
but the new page starts flush against the
|
||||||
|
header_spacing band; the spacer div below
|
||||||
|
gives the cert table breathing room so it
|
||||||
|
doesn't sit under the company logo. -->
|
||||||
|
<div style="page-break-before: always;"/>
|
||||||
|
<div style="height: 8mm;"/>
|
||||||
|
|
||||||
<t t-set="owner_sig" t-value="False"/>
|
<t t-set="owner_sig" t-value="False"/>
|
||||||
<t t-if="'x_fc_owner_user_id' in company._fields and company.x_fc_owner_user_id">
|
<t t-if="'x_fc_owner_user_id' in company._fields and company.x_fc_owner_user_id">
|
||||||
|
|||||||
@@ -25,8 +25,14 @@
|
|||||||
class="btn-secondary"
|
class="btn-secondary"
|
||||||
icon="fa-sitemap"
|
icon="fa-sitemap"
|
||||||
invisible="state == 'draft'"/>
|
invisible="state == 'draft'"/>
|
||||||
<button name="action_open_move_wizard" type="object"
|
<!-- Steelhead-style "Finish & Next": one click finishes
|
||||||
string="Move to Next Step"
|
whatever's running and auto-starts the next pending
|
||||||
|
step. Falls back to starting the first step if
|
||||||
|
nothing is running yet. The classic Move wizard is
|
||||||
|
still available via the per-row Move button (used
|
||||||
|
for cross-station moves and rework / scrap). -->
|
||||||
|
<button name="action_finish_current_step" type="object"
|
||||||
|
string="Finish & Next"
|
||||||
class="btn-primary"
|
class="btn-primary"
|
||||||
icon="fa-arrow-right"
|
icon="fa-arrow-right"
|
||||||
invisible="state not in ('confirmed', 'in_progress')"/>
|
invisible="state not in ('confirmed', 'in_progress')"/>
|
||||||
@@ -42,6 +48,28 @@
|
|||||||
invisible="state in ('draft', 'cancelled')"/>
|
invisible="state in ('draft', 'cancelled')"/>
|
||||||
</xpath>
|
</xpath>
|
||||||
|
|
||||||
|
<!-- Surface part / coating / recipe on the header so the
|
||||||
|
floor knows WHAT they're plating without diving into
|
||||||
|
Source. The "Reference Product" line in core is just
|
||||||
|
the FP-SERVICE stub from the SO — relabel it so it
|
||||||
|
doesn't compete with the real part identification. -->
|
||||||
|
<xpath expr="//field[@name='product_id']" position="attributes">
|
||||||
|
<attribute name="string">Service Product</attribute>
|
||||||
|
<attribute name="invisible">part_catalog_id</attribute>
|
||||||
|
</xpath>
|
||||||
|
<xpath expr="//field[@name='product_id']" position="after">
|
||||||
|
<field name="part_catalog_id" string="Part"/>
|
||||||
|
<field name="coating_config_id" string="Coating"/>
|
||||||
|
<field name="recipe_id" string="Process Recipe"/>
|
||||||
|
</xpath>
|
||||||
|
<!-- Show qty completed alongside total so the partial-qty
|
||||||
|
picture is visible at a glance without opening Move Log. -->
|
||||||
|
<xpath expr="//field[@name='qty']" position="after">
|
||||||
|
<field name="qty_done" string="Qty Done"/>
|
||||||
|
<field name="qty_scrapped" string="Qty Scrapped"
|
||||||
|
invisible="not qty_scrapped"/>
|
||||||
|
</xpath>
|
||||||
|
|
||||||
<!-- Replace the bare-bones Steps list with the action-rich
|
<!-- Replace the bare-bones Steps list with the action-rich
|
||||||
manager view. Per-row buttons mirror what an operator
|
manager view. Per-row buttons mirror what an operator
|
||||||
sees on the tablet; Running Min ticks on every refresh
|
sees on the tablet; Running Min ticks on every refresh
|
||||||
@@ -67,6 +95,14 @@
|
|||||||
<field name="duration_expected" optional="show"/>
|
<field name="duration_expected" optional="show"/>
|
||||||
<field name="duration_running_minutes" string="Running Min" optional="show"/>
|
<field name="duration_running_minutes" string="Running Min" optional="show"/>
|
||||||
<field name="duration_actual" optional="show"/>
|
<field name="duration_actual" optional="show"/>
|
||||||
|
<!-- Live qty currently parked at this step. Hits
|
||||||
|
zero once everything has moved on; >0 means
|
||||||
|
the floor still has parts to process here. -->
|
||||||
|
<field name="qty_at_step" string="Qty Here" optional="show"/>
|
||||||
|
<!-- Primary action: state-aware. Pending/ready → Start,
|
||||||
|
in_progress → Finish & Next (auto-advance like
|
||||||
|
Steelhead), paused → Resume. Done / skipped /
|
||||||
|
cancelled rows show no primary. -->
|
||||||
<button name="button_start" type="object"
|
<button name="button_start" type="object"
|
||||||
string="Start" icon="fa-play"
|
string="Start" icon="fa-play"
|
||||||
class="btn-link text-success"
|
class="btn-link text-success"
|
||||||
@@ -75,26 +111,32 @@
|
|||||||
string="Resume" icon="fa-play-circle"
|
string="Resume" icon="fa-play-circle"
|
||||||
class="btn-link text-success"
|
class="btn-link text-success"
|
||||||
invisible="state != 'paused'"/>
|
invisible="state != 'paused'"/>
|
||||||
|
<button name="action_finish_and_advance" type="object"
|
||||||
|
string="Finish & Next" icon="fa-check-circle"
|
||||||
|
class="btn-link text-primary"
|
||||||
|
invisible="state != 'in_progress'"/>
|
||||||
|
|
||||||
|
<!-- Secondary actions — small icons only. Pause is
|
||||||
|
only relevant on a running step; Record Inputs
|
||||||
|
stays available so operators can capture
|
||||||
|
measurements without finishing the step;
|
||||||
|
Skip + Move (cross-station) tucked together. -->
|
||||||
<button name="button_pause" type="object"
|
<button name="button_pause" type="object"
|
||||||
string="Pause" icon="fa-pause"
|
string="Pause" icon="fa-pause"
|
||||||
class="btn-link text-warning"
|
class="btn-link text-warning"
|
||||||
invisible="state != 'in_progress'"/>
|
invisible="state != 'in_progress'"/>
|
||||||
<button name="button_finish" type="object"
|
|
||||||
string="Finish" icon="fa-check"
|
|
||||||
class="btn-link text-primary"
|
|
||||||
invisible="state != 'in_progress'"/>
|
|
||||||
<button name="action_open_move_wizard" type="object"
|
|
||||||
string="Move" icon="fa-arrow-right"
|
|
||||||
class="btn-link"
|
|
||||||
invisible="state in ('done', 'cancelled', 'skipped')"/>
|
|
||||||
<button name="action_open_input_wizard" type="object"
|
<button name="action_open_input_wizard" type="object"
|
||||||
string="Record Inputs" icon="fa-pencil-square-o"
|
string="Record" icon="fa-pencil-square-o"
|
||||||
class="btn-link"
|
class="btn-link"
|
||||||
invisible="state in ('cancelled', 'skipped')"/>
|
invisible="state in ('cancelled', 'skipped')"/>
|
||||||
<button name="button_skip" type="object"
|
<button name="button_skip" type="object"
|
||||||
string="Skip" icon="fa-step-forward"
|
string="Skip" icon="fa-step-forward"
|
||||||
class="btn-link text-muted"
|
class="btn-link text-muted"
|
||||||
invisible="state not in ('pending', 'ready')"/>
|
invisible="state not in ('pending', 'ready')"/>
|
||||||
|
<button name="action_open_move_wizard" type="object"
|
||||||
|
string="Move…" icon="fa-exchange"
|
||||||
|
class="btn-link text-muted"
|
||||||
|
invisible="state in ('done', 'cancelled', 'skipped', 'pending')"/>
|
||||||
</list>
|
</list>
|
||||||
</field>
|
</field>
|
||||||
</xpath>
|
</xpath>
|
||||||
|
|||||||
@@ -154,6 +154,14 @@ class FpJobStepInputWizard(models.TransientModel):
|
|||||||
self.step_id.message_post(body=_(
|
self.step_id.message_post(body=_(
|
||||||
'%(n)s step input(s) recorded by %(user)s'
|
'%(n)s step input(s) recorded by %(user)s'
|
||||||
) % {'n': captured, 'user': self.env.user.name})
|
) % {'n': captured, 'user': self.env.user.name})
|
||||||
|
|
||||||
|
# When the wizard was opened from "Finish & Next" we re-enter
|
||||||
|
# the step's finish-and-advance flow with a context flag so it
|
||||||
|
# skips the prompt-for-inputs branch and finishes directly.
|
||||||
|
if self.env.context.get('fp_advance_after_save'):
|
||||||
|
return self.step_id.with_context(
|
||||||
|
fp_after_inputs=True,
|
||||||
|
).action_finish_and_advance()
|
||||||
return {'type': 'ir.actions.act_window_close'}
|
return {'type': 'ir.actions.act_window_close'}
|
||||||
|
|
||||||
|
|
||||||
@@ -207,6 +215,36 @@ class FpJobStepInputWizardLine(models.TransientModel):
|
|||||||
for rec in self:
|
for rec in self:
|
||||||
rec.is_authored = bool(rec.node_input_id)
|
rec.is_authored = bool(rec.node_input_id)
|
||||||
|
|
||||||
|
# ---- Single-column value editor -----------------------------------------
|
||||||
|
# The previous wizard exposed FOUR value columns (text / number /
|
||||||
|
# yes-no / date) — operators saw 9 columns wide and got lost. We
|
||||||
|
# collapse them into one "Value" column whose widget routes to the
|
||||||
|
# right typed field based on input_type. Booleans and dates get
|
||||||
|
# their own dedicated field (still per-row) so the widget behaves
|
||||||
|
# naturally; everything else types into a single value box.
|
||||||
|
|
||||||
|
is_boolean_type = fields.Boolean(
|
||||||
|
compute='_compute_type_flags',
|
||||||
|
)
|
||||||
|
is_date_type = fields.Boolean(
|
||||||
|
compute='_compute_type_flags',
|
||||||
|
)
|
||||||
|
is_numeric_type = fields.Boolean(
|
||||||
|
compute='_compute_type_flags',
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends('input_type')
|
||||||
|
def _compute_type_flags(self):
|
||||||
|
numeric_types = {
|
||||||
|
'number', 'temperature', 'thickness',
|
||||||
|
'time_seconds',
|
||||||
|
}
|
||||||
|
for rec in self:
|
||||||
|
it = rec.input_type or 'text'
|
||||||
|
rec.is_boolean_type = it in ('boolean', 'pass_fail')
|
||||||
|
rec.is_date_type = it == 'date'
|
||||||
|
rec.is_numeric_type = it in numeric_types
|
||||||
|
|
||||||
def _has_value(self):
|
def _has_value(self):
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
return any([
|
return any([
|
||||||
|
|||||||
@@ -11,38 +11,58 @@
|
|||||||
<field name="step_id" readonly="1"/>
|
<field name="step_id" readonly="1"/>
|
||||||
<field name="job_id" readonly="1"/>
|
<field name="job_id" readonly="1"/>
|
||||||
</group>
|
</group>
|
||||||
<separator string="Step Inputs"/>
|
<separator string="Measurements"/>
|
||||||
<p class="text-muted" invisible="line_ids">
|
<p class="text-muted" invisible="line_ids">
|
||||||
No authored prompts on this recipe step. Click
|
Click <strong>Add a line</strong> to record one or
|
||||||
<strong>Add a line</strong> below to record one or
|
more measurements for this step.
|
||||||
more ad-hoc measurements (operator name + value).
|
|
||||||
Authored prompts will appear here automatically once
|
|
||||||
the recipe gets `step_input` rows in the Process
|
|
||||||
Composer.
|
|
||||||
</p>
|
</p>
|
||||||
<field name="line_ids">
|
<field name="line_ids">
|
||||||
<list editable="bottom">
|
<list editable="bottom">
|
||||||
<field name="is_authored" column_invisible="1"/>
|
<field name="is_authored" column_invisible="1"/>
|
||||||
|
<field name="is_boolean_type" column_invisible="1"/>
|
||||||
|
<field name="is_date_type" column_invisible="1"/>
|
||||||
|
<field name="is_numeric_type" column_invisible="1"/>
|
||||||
<field name="name"
|
<field name="name"
|
||||||
|
string="Measurement"
|
||||||
readonly="is_authored"
|
readonly="is_authored"
|
||||||
placeholder="e.g. Oven Temp, Operator Initials, Bath Reading"/>
|
placeholder="e.g. Oven Temp, Bath Reading, Operator Initials"/>
|
||||||
<field name="input_type"
|
<field name="input_type"
|
||||||
|
string="Type"
|
||||||
|
readonly="is_authored"/>
|
||||||
|
<field name="target_unit"
|
||||||
|
string="Unit"
|
||||||
readonly="is_authored"
|
readonly="is_authored"
|
||||||
placeholder="number / text / boolean / date"
|
|
||||||
optional="show"/>
|
optional="show"/>
|
||||||
<field name="target_min" readonly="is_authored" optional="hide"/>
|
<!-- Distinct column labels so the operator
|
||||||
<field name="target_max" readonly="is_authored" optional="hide"/>
|
reads which input matches the row's
|
||||||
<field name="target_unit" readonly="is_authored" optional="show"/>
|
type. List-view columns are static in
|
||||||
<field name="value_text"/>
|
Odoo — labelling each by its purpose
|
||||||
<field name="value_number"/>
|
removes the "four identical Value
|
||||||
<field name="value_boolean" widget="boolean_toggle"/>
|
columns" guesswork from the previous
|
||||||
<field name="value_date"/>
|
layout. Only the cell matching the
|
||||||
|
row's type stays editable; others sit
|
||||||
|
blank. -->
|
||||||
|
<field name="value_number"
|
||||||
|
string="Number"
|
||||||
|
invisible="not is_numeric_type"/>
|
||||||
|
<field name="value_boolean"
|
||||||
|
string="Yes / No"
|
||||||
|
widget="boolean_toggle"
|
||||||
|
invisible="not is_boolean_type"/>
|
||||||
|
<field name="value_date"
|
||||||
|
string="Date / Time"
|
||||||
|
invisible="not is_date_type"/>
|
||||||
|
<field name="value_text"
|
||||||
|
string="Text"
|
||||||
|
invisible="is_numeric_type or is_boolean_type or is_date_type"/>
|
||||||
|
<field name="target_min" optional="hide"/>
|
||||||
|
<field name="target_max" optional="hide"/>
|
||||||
</list>
|
</list>
|
||||||
</field>
|
</field>
|
||||||
</sheet>
|
</sheet>
|
||||||
<footer>
|
<footer>
|
||||||
<button name="action_commit" type="object"
|
<button name="action_commit" type="object"
|
||||||
string="Record" class="btn-primary"/>
|
string="Save" class="btn-primary"/>
|
||||||
<button string="Cancel" class="btn-secondary"
|
<button string="Cancel" class="btn-secondary"
|
||||||
special="cancel"/>
|
special="cancel"/>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -115,7 +115,14 @@ class FpJobStepMoveWizard(models.TransientModel):
|
|||||||
if from_step.exists():
|
if from_step.exists():
|
||||||
defaults['from_step_id'] = from_step.id
|
defaults['from_step_id'] = from_step.id
|
||||||
defaults['job_id'] = from_step.job_id.id
|
defaults['job_id'] = from_step.job_id.id
|
||||||
defaults['qty_moved'] = int(from_step.job_id.qty or 1)
|
# Default to "qty currently here", not "job total". A job
|
||||||
|
# already mid-flight may have parts split across steps;
|
||||||
|
# pre-filling with the full job qty would silently let
|
||||||
|
# the operator move more than is actually parked here.
|
||||||
|
# Fall back to job qty when qty_at_step is 0 (e.g.
|
||||||
|
# opened on a fresh step before any movement).
|
||||||
|
qty_here = int(from_step.qty_at_step or 0)
|
||||||
|
defaults['qty_moved'] = qty_here or int(from_step.job_id.qty or 1)
|
||||||
# Next sequenced step that isn't done/cancelled
|
# Next sequenced step that isn't done/cancelled
|
||||||
next_step = self.env['fp.job.step'].search([
|
next_step = self.env['fp.job.step'].search([
|
||||||
('job_id', '=', from_step.job_id.id),
|
('job_id', '=', from_step.job_id.id),
|
||||||
@@ -222,6 +229,29 @@ class FpJobStepMoveWizard(models.TransientModel):
|
|||||||
if not self.from_step_id or not self.to_step_id:
|
if not self.from_step_id or not self.to_step_id:
|
||||||
raise UserError(_('Pick both From and To steps before moving.'))
|
raise UserError(_('Pick both From and To steps before moving.'))
|
||||||
|
|
||||||
|
# Partial-qty guards. The operator can't move more than is
|
||||||
|
# parked at the from-step, and zero/negative is meaningless.
|
||||||
|
# Self-loop moves (input recording) bypass the upper bound
|
||||||
|
# because they don't move qty.
|
||||||
|
if self.qty_moved <= 0:
|
||||||
|
raise UserError(_(
|
||||||
|
'Qty Moved must be at least 1. Use Skip on the step row '
|
||||||
|
'instead if no parts are being processed.'
|
||||||
|
))
|
||||||
|
is_self_loop = (self.from_step_id == self.to_step_id)
|
||||||
|
if not is_self_loop:
|
||||||
|
qty_here = int(self.from_step_id.qty_at_step or 0)
|
||||||
|
if qty_here > 0 and self.qty_moved > qty_here:
|
||||||
|
raise UserError(_(
|
||||||
|
'Cannot move %(req)s parts — only %(here)s currently '
|
||||||
|
'parked at "%(step)s". Adjust Qty Moved or split '
|
||||||
|
'across multiple moves.'
|
||||||
|
) % {
|
||||||
|
'req': self.qty_moved,
|
||||||
|
'here': qty_here,
|
||||||
|
'step': self.from_step_id.name,
|
||||||
|
})
|
||||||
|
|
||||||
Move = self.env['fp.job.step.move']
|
Move = self.env['fp.job.step.move']
|
||||||
move = Move.create({
|
move = Move.create({
|
||||||
'job_id': self.job_id.id,
|
'job_id': self.job_id.id,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Receiving & Inspection',
|
'name': 'Fusion Plating — Receiving & Inspection',
|
||||||
'version': '19.0.3.7.0',
|
'version': '19.0.3.7.1',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Parts receiving, inspection, damage logging, and manufacturing gate.',
|
'summary': 'Parts receiving, inspection, damage logging, and manufacturing gate.',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -97,7 +97,7 @@
|
|||||||
<separator string="Photos"/>
|
<separator string="Photos"/>
|
||||||
<field name="photo_ids"
|
<field name="photo_ids"
|
||||||
widget="many2many_tags"
|
widget="many2many_tags"
|
||||||
options="{'no_quick_create': True, 'color_field': 'color'}"
|
options="{'no_quick_create': True}"
|
||||||
nolabel="1"
|
nolabel="1"
|
||||||
help="Attach damage / condition photos for this box. Click + to upload, then click any pill to preview."/>
|
help="Attach damage / condition photos for this box. Click + to upload, then click any pill to preview."/>
|
||||||
<separator string="Notes"/>
|
<separator string="Notes"/>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Reports',
|
'name': 'Fusion Plating — Reports',
|
||||||
'version': '19.0.10.1.0',
|
'version': '19.0.10.1.3',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.',
|
'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.',
|
||||||
'depends': [
|
'depends': [
|
||||||
|
|||||||
@@ -66,20 +66,34 @@
|
|||||||
|
|
||||||
<!-- ==========================================================
|
<!-- ==========================================================
|
||||||
customer_line_description — customer-facing description
|
customer_line_description — customer-facing description
|
||||||
plus any populated line metadata (serial, job#, thickness).
|
plus serial + thickness only.
|
||||||
Intended for the "Description" td in customer-facing tables.
|
|
||||||
|
Per client request (2026-04-29): customer-facing reports
|
||||||
|
show ONLY description, serial, and thickness. Job # was
|
||||||
|
previously shown here but is internal-only — it lives on
|
||||||
|
the traveller / WO sticker / packing slip header, not on
|
||||||
|
what the customer sees. Process variant, treatment names,
|
||||||
|
and recipe codes deliberately don't render either.
|
||||||
========================================================== -->
|
========================================================== -->
|
||||||
<template id="customer_line_description">
|
<template id="customer_line_description">
|
||||||
<t t-if="line.x_fc_part_catalog_id">
|
<t t-if="line.x_fc_part_catalog_id">
|
||||||
<span t-esc="line.name"/>
|
<!-- Strip the "[FP-SERVICE] Plating Service" prefix Odoo's
|
||||||
|
_compute_name keeps re-prepending. fp_customer_description
|
||||||
|
lives on sale.order.line + account.move.line; for any
|
||||||
|
other model the macro might be called with we degrade to
|
||||||
|
raw line.name. QWeb's eval context doesn't expose Python
|
||||||
|
builtins like hasattr/getattr, so probe the model's method
|
||||||
|
dict via line._name + env. white-space: pre-line preserves
|
||||||
|
the estimator's line breaks. -->
|
||||||
|
<t t-set="_has_helper"
|
||||||
|
t-value="line._name in ('sale.order.line', 'account.move.line')"/>
|
||||||
|
<t t-set="_desc"
|
||||||
|
t-value="line.fp_customer_description() if _has_helper else line.name"/>
|
||||||
|
<span t-esc="_desc" style="white-space: pre-line;"/>
|
||||||
<t t-if="'x_fc_serial_id' in line._fields and line.x_fc_serial_id">
|
<t t-if="'x_fc_serial_id' in line._fields and line.x_fc_serial_id">
|
||||||
<br/>
|
<br/>
|
||||||
<small>Serial: <span t-esc="line.x_fc_serial_id.name"/></small>
|
<small>Serial: <span t-esc="line.x_fc_serial_id.name"/></small>
|
||||||
</t>
|
</t>
|
||||||
<t t-if="'x_fc_job_number' in line._fields and line.x_fc_job_number">
|
|
||||||
<br/>
|
|
||||||
<small>Job #: <span t-esc="line.x_fc_job_number"/></small>
|
|
||||||
</t>
|
|
||||||
<t t-if="'x_fc_thickness_id' in line._fields and line.x_fc_thickness_id">
|
<t t-if="'x_fc_thickness_id' in line._fields and line.x_fc_thickness_id">
|
||||||
<br/>
|
<br/>
|
||||||
<small>Thickness: <span t-esc="line.x_fc_thickness_id.display_name"/></small>
|
<small>Thickness: <span t-esc="line.x_fc_thickness_id.display_name"/></small>
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
<h2>Hybrid: C's columns + A's card style</h2>
|
||||||
|
<p class="subtitle">Status columns, but each project is a full card with progress bar, %, hours, and task count.</p>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.preview-page { background:#f3f4f6; padding:18px; border-radius:8px; min-height:340px; font-size:12px;}
|
||||||
|
.preview-head { display:flex; justify-content:space-between; align-items:center; margin-bottom:14px; color:#6b7280;}
|
||||||
|
.preview-head .crumbs { display:flex; gap:6px; align-items:center; color:#374151; font-weight:500;}
|
||||||
|
.preview-head .controls { display:flex; gap:6px;}
|
||||||
|
.ctrl { background:white; border:1px solid #d8dadd; padding:3px 10px; border-radius:6px; font-size:11px;}
|
||||||
|
|
||||||
|
.pill { display:inline-block; padding:2px 8px; border-radius:999px; font-size:10px; font-weight:600;}
|
||||||
|
.pill.green { background:#dcfce7; color:#166534;}
|
||||||
|
.pill.blue { background:#dbeafe; color:#1d4ed8;}
|
||||||
|
.pill.amber { background:#fef3c7; color:#92400e;}
|
||||||
|
.pill.gray { background:#e5e7eb; color:#374151;}
|
||||||
|
|
||||||
|
.cols { display:grid; grid-template-columns: 1fr 1fr 1fr; gap:14px;}
|
||||||
|
.col { background:transparent; }
|
||||||
|
.col-head { display:flex; justify-content:space-between; align-items:center; padding:0 4px 8px; border-bottom:2px solid #e5e7eb; margin-bottom:10px;}
|
||||||
|
.col-head .label { font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.06em; color:#374151; display:flex; align-items:center; gap:6px;}
|
||||||
|
.dot { width:8px; height:8px; border-radius:99px;}
|
||||||
|
.dot.green { background:#22c55e;}
|
||||||
|
.dot.amber { background:#f59e0b;}
|
||||||
|
.dot.gray { background:#9ca3af;}
|
||||||
|
.col-count { font-size:10px; font-weight:700; color:#6b7280; background:#e5e7eb; padding:2px 7px; border-radius:99px;}
|
||||||
|
|
||||||
|
/* card pulled from option A */
|
||||||
|
.pcard { background:white; border:1px solid #d8dadd; border-radius:10px; padding:11px; margin-bottom:8px; box-shadow:0 1px 0 rgba(15,23,42,.02);}
|
||||||
|
.pcard:hover { border-color:#93c5fd; box-shadow:0 2px 8px rgba(59,130,246,.08);}
|
||||||
|
.pcard .ptitle { font-weight:600; color:#0f172a; margin-bottom:4px; font-size:12px; line-height:1.3;}
|
||||||
|
.pcard .pmeta-row { display:flex; justify-content:space-between; align-items:center; color:#6b7280; font-size:10px; margin-bottom:6px;}
|
||||||
|
.pcard .pbar { height:5px; background:#e5e7eb; border-radius:99px; overflow:hidden; margin-bottom:7px;}
|
||||||
|
.pcard .pbar > span { display:block; height:100%; background:#22c55e;}
|
||||||
|
.pcard .pbar.amber > span { background:#f59e0b;}
|
||||||
|
.pcard .pfooter { display:flex; justify-content:space-between; align-items:center; font-size:10px; color:#6b7280;}
|
||||||
|
.pcard .pfooter .stats { display:flex; gap:5px;}
|
||||||
|
|
||||||
|
.empty { color:#9ca3af; font-size:11px; text-align:center; padding:24px 0; background:white; border:1px dashed #d8dadd; border-radius:10px;}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="preview-page">
|
||||||
|
<div class="preview-head">
|
||||||
|
<span class="crumbs">🏠 / Projects</span>
|
||||||
|
<div class="controls">
|
||||||
|
<span class="ctrl">🔎 Search…</span>
|
||||||
|
<span class="ctrl">Group: Status ▾</span>
|
||||||
|
<span class="ctrl">Sort: Name ▾</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cols">
|
||||||
|
|
||||||
|
<!-- ACTIVE -->
|
||||||
|
<div class="col">
|
||||||
|
<div class="col-head">
|
||||||
|
<span class="label"><span class="dot green"></span> Active</span>
|
||||||
|
<span class="col-count">2</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pcard">
|
||||||
|
<div class="ptitle">S29824</div>
|
||||||
|
<div class="pmeta-row">
|
||||||
|
<span>Westin Healthcare</span>
|
||||||
|
<span>62%</span>
|
||||||
|
</div>
|
||||||
|
<div class="pbar"><span style="width:62%"></span></div>
|
||||||
|
<div class="pfooter">
|
||||||
|
<span class="stats"><span class="pill blue">50 tasks</span><span class="pill green">31 done</span></span>
|
||||||
|
<span>42.5 / 65h</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pcard">
|
||||||
|
<div class="ptitle">S29824 - Internal</div>
|
||||||
|
<div class="pmeta-row">
|
||||||
|
<span>Internal QA</span>
|
||||||
|
<span>0%</span>
|
||||||
|
</div>
|
||||||
|
<div class="pbar amber"><span style="width:8%"></span></div>
|
||||||
|
<div class="pfooter">
|
||||||
|
<span class="stats"><span class="pill blue">1 task</span></span>
|
||||||
|
<span>—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- IDLE -->
|
||||||
|
<div class="col">
|
||||||
|
<div class="col-head">
|
||||||
|
<span class="label"><span class="dot amber"></span> Idle</span>
|
||||||
|
<span class="col-count">3</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pcard">
|
||||||
|
<div class="ptitle">Customer Care</div>
|
||||||
|
<div class="pmeta-row">
|
||||||
|
<span>No tasks yet</span>
|
||||||
|
<span>—</span>
|
||||||
|
</div>
|
||||||
|
<div class="pbar"><span style="width:0%"></span></div>
|
||||||
|
<div class="pfooter">
|
||||||
|
<span class="stats"><span class="pill gray">0 tasks</span></span>
|
||||||
|
<span>—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pcard">
|
||||||
|
<div class="ptitle">Field Service</div>
|
||||||
|
<div class="pmeta-row">
|
||||||
|
<span>No tasks yet</span>
|
||||||
|
<span>—</span>
|
||||||
|
</div>
|
||||||
|
<div class="pbar"><span style="width:0%"></span></div>
|
||||||
|
<div class="pfooter">
|
||||||
|
<span class="stats"><span class="pill gray">0 tasks</span></span>
|
||||||
|
<span>—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pcard">
|
||||||
|
<div class="ptitle">Internal</div>
|
||||||
|
<div class="pmeta-row">
|
||||||
|
<span>No tasks yet</span>
|
||||||
|
<span>—</span>
|
||||||
|
</div>
|
||||||
|
<div class="pbar"><span style="width:0%"></span></div>
|
||||||
|
<div class="pfooter">
|
||||||
|
<span class="stats"><span class="pill gray">0 tasks</span></span>
|
||||||
|
<span>—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DONE -->
|
||||||
|
<div class="col">
|
||||||
|
<div class="col-head">
|
||||||
|
<span class="label"><span class="dot gray"></span> Done</span>
|
||||||
|
<span class="col-count">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="empty">Completed projects will appear here.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section" style="margin-top:22px">
|
||||||
|
<h3>Open question: how do we decide which column?</h3>
|
||||||
|
<p class="subtitle">Same card design either way; this just determines column placement.</p>
|
||||||
|
|
||||||
|
<div class="options" style="margin-top:12px">
|
||||||
|
|
||||||
|
<div class="option" data-choice="grouping-activity" onclick="toggleSelect(this)">
|
||||||
|
<div class="letter">1</div>
|
||||||
|
<div class="content">
|
||||||
|
<h3>By activity (computed)</h3>
|
||||||
|
<p><b>Active</b> = at least one open task. <b>Idle</b> = no open tasks but project is still open. <b>Done</b> = project is archived/closed. No new fields needed.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="option" data-choice="grouping-status" onclick="toggleSelect(this)">
|
||||||
|
<div class="letter">2</div>
|
||||||
|
<div class="content">
|
||||||
|
<h3>By project status field</h3>
|
||||||
|
<p>Use Odoo's <code>last_update_status</code> (On Track / At Risk / Off Track / On Hold / Done). Five columns is too many for a portal — we'd collapse into 3 buckets.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="option" data-choice="grouping-stage" onclick="toggleSelect(this)">
|
||||||
|
<div class="letter">3</div>
|
||||||
|
<div class="content">
|
||||||
|
<h3>By a custom field on project</h3>
|
||||||
|
<p>Add <code>x_fc_portal_status</code> with three values you control (e.g. Active/Idle/Done). Most flexible, but someone has to maintain it.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="subtitle" style="margin-top:14px">My recommendation: <b>Option 1 — by activity</b>. Zero new fields, columns reflect reality automatically, and it matches what the screenshot already shows ("S29824 has 50 tasks, others have 0").</p>
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
<h2>Pick a layout direction for /my/projects</h2>
|
||||||
|
<p class="subtitle">Click a card to select. We'll iterate on the chosen direction next.</p>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.preview-page { background: #f3f4f6; padding: 14px; border-radius: 6px; min-height: 240px; font-size: 12px; }
|
||||||
|
.preview-head { display:flex; justify-content:space-between; align-items:center; margin-bottom:10px; color:#6b7280;}
|
||||||
|
.preview-head .crumbs { display:flex; gap:6px; align-items:center;}
|
||||||
|
.pill { display:inline-block; padding:2px 8px; border-radius:999px; font-size:10px; font-weight:600;}
|
||||||
|
.pill.green { background:#dcfce7; color:#166534;}
|
||||||
|
.pill.blue { background:#dbeafe; color:#1d4ed8;}
|
||||||
|
.pill.amber { background:#fef3c7; color:#92400e;}
|
||||||
|
.pill.gray { background:#e5e7eb; color:#374151;}
|
||||||
|
|
||||||
|
/* Option A: card grid */
|
||||||
|
.a-grid { display:grid; grid-template-columns: 1fr 1fr; gap:8px;}
|
||||||
|
.a-card { background:white; border:1px solid #d8dadd; border-radius:8px; padding:10px; }
|
||||||
|
.a-card .a-title { font-weight:600; color:#111827; margin-bottom:4px;}
|
||||||
|
.a-card .a-meta { display:flex; justify-content:space-between; align-items:center; color:#6b7280; font-size:10px;}
|
||||||
|
.a-bar { height:4px; background:#e5e7eb; border-radius:99px; margin-top:6px; overflow:hidden;}
|
||||||
|
.a-bar > span { display:block; height:100%; background:#22c55e;}
|
||||||
|
|
||||||
|
/* Option B: enhanced rows */
|
||||||
|
.b-row { display:flex; align-items:center; padding:9px 12px; background:white; border:1px solid #d8dadd; border-radius:6px; margin-bottom:4px;}
|
||||||
|
.b-row .icon { width:28px; height:28px; border-radius:6px; background:#dbeafe; display:flex;align-items:center;justify-content:center; color:#1d4ed8; font-weight:700; margin-right:10px; font-size:11px;}
|
||||||
|
.b-row .name { flex-grow:1; font-weight:600; color:#111827;}
|
||||||
|
.b-row .stats { display:flex; gap:6px;}
|
||||||
|
.b-row .barwrap { width:80px; height:4px; background:#e5e7eb; border-radius:99px; margin-right:8px;}
|
||||||
|
.b-row .barwrap > span { display:block; height:100%; background:#22c55e;}
|
||||||
|
|
||||||
|
/* Option C: kanban columns */
|
||||||
|
.c-cols { display:grid; grid-template-columns: 1fr 1fr 1fr; gap:8px;}
|
||||||
|
.c-col { background:#fafafa; border:1px solid #e5e7eb; border-radius:8px; padding:8px;}
|
||||||
|
.c-col h4 { font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.05em; color:#6b7280; margin:0 0 8px;}
|
||||||
|
.c-card { background:white; border:1px solid #d8dadd; border-radius:6px; padding:8px; margin-bottom:6px; font-size:11px;}
|
||||||
|
.c-card .ct { font-weight:600; color:#111827; margin-bottom:2px;}
|
||||||
|
.c-card .cm { color:#6b7280; font-size:10px;}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="cards">
|
||||||
|
|
||||||
|
<div class="card" data-choice="a" onclick="toggleSelect(this)">
|
||||||
|
<div class="card-image">
|
||||||
|
<div class="preview-page">
|
||||||
|
<div class="preview-head">
|
||||||
|
<span class="crumbs">🏠 / Projects</span>
|
||||||
|
<span>Sort: Name ▾</span>
|
||||||
|
</div>
|
||||||
|
<div class="a-grid">
|
||||||
|
<div class="a-card">
|
||||||
|
<div class="a-title">S29824</div>
|
||||||
|
<div class="a-meta"><span class="pill blue">50 tasks</span><span>62%</span></div>
|
||||||
|
<div class="a-bar"><span style="width:62%"></span></div>
|
||||||
|
</div>
|
||||||
|
<div class="a-card">
|
||||||
|
<div class="a-title">Customer Care</div>
|
||||||
|
<div class="a-meta"><span class="pill gray">0 tasks</span><span>—</span></div>
|
||||||
|
<div class="a-bar"><span style="width:0%"></span></div>
|
||||||
|
</div>
|
||||||
|
<div class="a-card">
|
||||||
|
<div class="a-title">Field Service</div>
|
||||||
|
<div class="a-meta"><span class="pill gray">0 tasks</span><span>—</span></div>
|
||||||
|
<div class="a-bar"><span style="width:0%"></span></div>
|
||||||
|
</div>
|
||||||
|
<div class="a-card">
|
||||||
|
<div class="a-title">S29824 - Internal</div>
|
||||||
|
<div class="a-meta"><span class="pill blue">1 task</span><span>0%</span></div>
|
||||||
|
<div class="a-bar"><span style="width:0%"></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h3>A — Card grid</h3>
|
||||||
|
<p>Each project is a card with name, task count, % complete, and a tiny progress bar. 2-up on desktop, 1-up on mobile. Most "modern dashboard" feel.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" data-choice="b" onclick="toggleSelect(this)">
|
||||||
|
<div class="card-image">
|
||||||
|
<div class="preview-page">
|
||||||
|
<div class="preview-head">
|
||||||
|
<span class="crumbs">🏠 / Projects</span>
|
||||||
|
<span>Sort: Name ▾</span>
|
||||||
|
</div>
|
||||||
|
<div class="b-row">
|
||||||
|
<div class="icon">S2</div>
|
||||||
|
<div class="name">S29824</div>
|
||||||
|
<div class="barwrap"><span style="width:62%"></span></div>
|
||||||
|
<div class="stats"><span class="pill green">62%</span><span class="pill blue">50</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="b-row">
|
||||||
|
<div class="icon" style="background:#fef3c7;color:#92400e">CC</div>
|
||||||
|
<div class="name">Customer Care</div>
|
||||||
|
<div class="barwrap"><span style="width:0%"></span></div>
|
||||||
|
<div class="stats"><span class="pill gray">0%</span><span class="pill gray">0</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="b-row">
|
||||||
|
<div class="icon" style="background:#dcfce7;color:#166534">FS</div>
|
||||||
|
<div class="name">Field Service</div>
|
||||||
|
<div class="barwrap"><span style="width:0%"></span></div>
|
||||||
|
<div class="stats"><span class="pill gray">0%</span><span class="pill gray">0</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="b-row">
|
||||||
|
<div class="icon">S2</div>
|
||||||
|
<div class="name">S29824 - Internal</div>
|
||||||
|
<div class="barwrap"><span style="width:0%"></span></div>
|
||||||
|
<div class="stats"><span class="pill amber">0%</span><span class="pill blue">1</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h3>B — Enhanced rows</h3>
|
||||||
|
<p>Same vertical list, but each row gets an avatar/initials chip, inline progress bar, and badge stats. Closest to the current page; quickest to scan with many projects.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" data-choice="c" onclick="toggleSelect(this)">
|
||||||
|
<div class="card-image">
|
||||||
|
<div class="preview-page">
|
||||||
|
<div class="preview-head">
|
||||||
|
<span class="crumbs">🏠 / Projects</span>
|
||||||
|
<span>Group: Status ▾</span>
|
||||||
|
</div>
|
||||||
|
<div class="c-cols">
|
||||||
|
<div class="c-col">
|
||||||
|
<h4>● Active</h4>
|
||||||
|
<div class="c-card"><div class="ct">S29824</div><div class="cm">50 tasks · 62%</div></div>
|
||||||
|
<div class="c-card"><div class="ct">S29824 - Internal</div><div class="cm">1 task · 0%</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="c-col">
|
||||||
|
<h4>● Idle</h4>
|
||||||
|
<div class="c-card"><div class="ct">Customer Care</div><div class="cm">0 tasks</div></div>
|
||||||
|
<div class="c-card"><div class="ct">Field Service</div><div class="cm">0 tasks</div></div>
|
||||||
|
<div class="c-card"><div class="ct">Internal</div><div class="cm">0 tasks</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="c-col">
|
||||||
|
<h4>● Done</h4>
|
||||||
|
<div style="color:#9ca3af;font-size:10px;text-align:center;padding:16px 0">Nothing here yet</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h3>C — Status columns (kanban-ish)</h3>
|
||||||
|
<p>Projects grouped into columns by activity (Active / Idle / Done). Visually striking but unusual for a customer portal where users mainly browse to drill into one project.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="subtitle" style="margin-top:18px">My recommendation: <b>A — Card grid</b>. It's the most "modern" feel you asked for, scales gracefully to a few or many projects, and gives room for the data we already compute (task counts, % done, hours). B is a strong runner-up if you have lots of projects (20+); C feels overengineered for a portal landing page.</p>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
|
||||||
|
<p class="subtitle">Implementing in terminal — check back here only if I push another mockup.</p>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"reason":"idle timeout","timestamp":1777430092232}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
791
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
'name': 'Fusion Project Portal',
|
'name': 'Fusion Project Portal',
|
||||||
'version': '19.0.3.0.0',
|
'version': '19.0.4.1.1',
|
||||||
'category': 'Project',
|
'category': 'Project',
|
||||||
'summary': 'Customer portal hierarchy + task creation, plus surfaces logged timesheets on the portal',
|
'summary': 'Customer portal hierarchy + task creation, plus surfaces logged timesheets on the portal',
|
||||||
'description': """
|
'description': """
|
||||||
@@ -17,6 +17,12 @@
|
|||||||
'views/portal_templates.xml',
|
'views/portal_templates.xml',
|
||||||
'views/wizard_views.xml',
|
'views/wizard_views.xml',
|
||||||
],
|
],
|
||||||
|
'assets': {
|
||||||
|
'web.assets_frontend': [
|
||||||
|
'fusion_project_portal/static/src/scss/portal_projects.scss',
|
||||||
|
'fusion_project_portal/static/src/js/portal_search_live.js',
|
||||||
|
],
|
||||||
|
},
|
||||||
'installable': True,
|
'installable': True,
|
||||||
'application': False,
|
'application': False,
|
||||||
'license': 'LGPL-3',
|
'license': 'LGPL-3',
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
from odoo import http, _
|
from odoo import http, _
|
||||||
from odoo.exceptions import AccessError, MissingError
|
from odoo.exceptions import AccessError, MissingError
|
||||||
from odoo.http import request
|
from odoo.http import request
|
||||||
@@ -14,6 +16,176 @@ class FusionProjectCustomerPortal(ProjectCustomerPortal):
|
|||||||
}
|
}
|
||||||
return values
|
return values
|
||||||
|
|
||||||
|
def _fp_project_card_data(self, projects):
|
||||||
|
"""Return {project_id: dict} with the rich stats the redesigned card needs.
|
||||||
|
|
||||||
|
Aggregations are done in two grouped reads (tasks + timesheets) so the
|
||||||
|
page stays cheap regardless of how many projects/tasks the user owns.
|
||||||
|
"""
|
||||||
|
if not projects:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
Task = request.env['project.task'].sudo()
|
||||||
|
AAL = request.env['account.analytic.line'].sudo()
|
||||||
|
done_states = ('1_done', '03_approved')
|
||||||
|
has_alloc = 'allocated_hours' in Task._fields
|
||||||
|
has_effective = 'effective_hours' in Task._fields
|
||||||
|
|
||||||
|
task_data = defaultdict(lambda: {
|
||||||
|
'open': 0, 'done': 0, 'total': 0,
|
||||||
|
'alloc': 0.0, 'last_activity': False,
|
||||||
|
'assignees': set(),
|
||||||
|
'task_partners': set(),
|
||||||
|
})
|
||||||
|
|
||||||
|
task_fields = ['project_id', 'state', 'write_date', 'user_ids', 'partner_id']
|
||||||
|
if has_alloc:
|
||||||
|
task_fields.append('allocated_hours')
|
||||||
|
tasks = Task.search_read(
|
||||||
|
[('project_id', 'in', projects.ids), ('is_template', '=', False)],
|
||||||
|
task_fields,
|
||||||
|
)
|
||||||
|
for t in tasks:
|
||||||
|
pid = t['project_id'][0] if t.get('project_id') else False
|
||||||
|
if not pid:
|
||||||
|
continue
|
||||||
|
d = task_data[pid]
|
||||||
|
d['total'] += 1
|
||||||
|
if t['state'] in done_states:
|
||||||
|
d['done'] += 1
|
||||||
|
else:
|
||||||
|
d['open'] += 1
|
||||||
|
if has_alloc and t.get('allocated_hours'):
|
||||||
|
d['alloc'] += t['allocated_hours']
|
||||||
|
wd = t.get('write_date')
|
||||||
|
if wd and (not d['last_activity'] or wd > d['last_activity']):
|
||||||
|
d['last_activity'] = wd
|
||||||
|
for uid in t.get('user_ids') or []:
|
||||||
|
d['assignees'].add(uid)
|
||||||
|
if t.get('partner_id'):
|
||||||
|
d['task_partners'].add((t['partner_id'][0], t['partner_id'][1]))
|
||||||
|
|
||||||
|
# Timesheet hours per project (effective hours come from timesheets).
|
||||||
|
spent_by_project = defaultdict(float)
|
||||||
|
if has_effective:
|
||||||
|
ts_groups = AAL.read_group(
|
||||||
|
[('project_id', 'in', projects.ids)],
|
||||||
|
['project_id', 'unit_amount:sum'],
|
||||||
|
['project_id'],
|
||||||
|
)
|
||||||
|
for g in ts_groups:
|
||||||
|
if g.get('project_id'):
|
||||||
|
spent_by_project[g['project_id'][0]] = g.get('unit_amount', 0.0) or 0.0
|
||||||
|
|
||||||
|
# Resolve assignee names + avatars in one batch.
|
||||||
|
all_user_ids = set()
|
||||||
|
for d in task_data.values():
|
||||||
|
all_user_ids |= d['assignees']
|
||||||
|
users_by_id = {}
|
||||||
|
if all_user_ids:
|
||||||
|
for u in request.env['res.users'].sudo().browse(list(all_user_ids)):
|
||||||
|
users_by_id[u.id] = {
|
||||||
|
'id': u.id,
|
||||||
|
'name': u.name,
|
||||||
|
'initials': ''.join([p[:1].upper() for p in (u.name or '?').split()[:2]]) or '?',
|
||||||
|
}
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
for project in projects:
|
||||||
|
d = task_data.get(project.id, {
|
||||||
|
'open': 0, 'done': 0, 'total': 0,
|
||||||
|
'alloc': 0.0, 'last_activity': False,
|
||||||
|
'assignees': set(), 'task_partners': set(),
|
||||||
|
})
|
||||||
|
spent = spent_by_project.get(project.id, 0.0)
|
||||||
|
pct = round(100.0 * d['done'] / d['total'], 0) if d['total'] else 0
|
||||||
|
if not project.active:
|
||||||
|
bucket = 'done'
|
||||||
|
elif d['open'] > 0:
|
||||||
|
bucket = 'active'
|
||||||
|
else:
|
||||||
|
bucket = 'idle'
|
||||||
|
assignees = [users_by_id[uid] for uid in d['assignees'] if uid in users_by_id]
|
||||||
|
assignees.sort(key=lambda u: u['name'])
|
||||||
|
|
||||||
|
# Customer label: prefer the project's customer; fall back to task
|
||||||
|
# customers when the project has none. If multiple distinct task
|
||||||
|
# customers, show a count rather than picking one arbitrarily.
|
||||||
|
if project.partner_id:
|
||||||
|
partner_name = project.partner_id.display_name
|
||||||
|
elif len(d['task_partners']) == 1:
|
||||||
|
partner_name = next(iter(d['task_partners']))[1]
|
||||||
|
elif len(d['task_partners']) > 1:
|
||||||
|
partner_name = _('%s customers') % len(d['task_partners'])
|
||||||
|
else:
|
||||||
|
partner_name = ''
|
||||||
|
|
||||||
|
result[project.id] = {
|
||||||
|
'open_count': d['open'],
|
||||||
|
'done_count': d['done'],
|
||||||
|
'total_count': d['total'],
|
||||||
|
'pct': pct,
|
||||||
|
'alloc_hours': d['alloc'],
|
||||||
|
'spent_hours': spent,
|
||||||
|
'last_activity': d['last_activity'] or project.write_date,
|
||||||
|
'assignees': assignees,
|
||||||
|
'bucket': bucket,
|
||||||
|
'partner_name': partner_name,
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _prepare_searchbar_sortings(self):
|
||||||
|
sortings = super()._prepare_searchbar_sortings()
|
||||||
|
# Add an "activity" SQL sort. We don't add task_count / pct here because
|
||||||
|
# those are non-stored compute fields and would break the SQL ORDER BY.
|
||||||
|
# The redesigned page sorts those client-side.
|
||||||
|
sortings['activity'] = {'label': _('Last Activity'), 'order': 'write_date desc'}
|
||||||
|
return sortings
|
||||||
|
|
||||||
|
@http.route(['/my/projects', '/my/projects/page/<int:page>'],
|
||||||
|
type='http', auth='user', website=True)
|
||||||
|
def portal_my_projects(self, page=1, date_begin=None, date_end=None, sortby=None, **kw):
|
||||||
|
from odoo.addons.portal.controllers.portal import pager as portal_pager
|
||||||
|
|
||||||
|
Project = request.env['project.project']
|
||||||
|
values = self._prepare_portal_layout_values()
|
||||||
|
domain = self._prepare_project_domain()
|
||||||
|
|
||||||
|
searchbar_sortings = self._prepare_searchbar_sortings()
|
||||||
|
if not sortby or sortby not in searchbar_sortings:
|
||||||
|
sortby = 'name'
|
||||||
|
order = searchbar_sortings[sortby]['order']
|
||||||
|
|
||||||
|
if date_begin and date_end:
|
||||||
|
domain += [('create_date', '>', date_begin), ('create_date', '<=', date_end)]
|
||||||
|
|
||||||
|
project_count = Project.search_count(domain)
|
||||||
|
# Bigger page size than core's default — the redesign is meant to scan
|
||||||
|
# everything you have at a glance, not paginate two-at-a-time.
|
||||||
|
per_page = max(self._items_per_page, 40)
|
||||||
|
pager = portal_pager(
|
||||||
|
url='/my/projects',
|
||||||
|
url_args={'date_begin': date_begin, 'date_end': date_end, 'sortby': sortby},
|
||||||
|
total=project_count, page=page, step=per_page,
|
||||||
|
)
|
||||||
|
|
||||||
|
projects = Project.search(domain, order=order, limit=per_page, offset=pager['offset'])
|
||||||
|
request.session['my_projects_history'] = projects.ids[:100]
|
||||||
|
|
||||||
|
values.update({
|
||||||
|
'date': date_begin,
|
||||||
|
'date_end': date_end,
|
||||||
|
'projects': projects,
|
||||||
|
'page_name': 'project',
|
||||||
|
'default_url': '/my/projects',
|
||||||
|
'pager': pager,
|
||||||
|
'searchbar_sortings': searchbar_sortings,
|
||||||
|
'sortby': sortby,
|
||||||
|
'fp_project_cards': self._fp_project_card_data(projects),
|
||||||
|
'fp_groupby': (kw.get('groupby') or 'status').lower(),
|
||||||
|
})
|
||||||
|
return request.render('fusion_project_portal.portal_my_projects', values)
|
||||||
|
|
||||||
def _project_get_page_view_values(self, project, access_token, page=1, date_begin=None,
|
def _project_get_page_view_values(self, project, access_token, page=1, date_begin=None,
|
||||||
date_end=None, sortby=None, search=None,
|
date_end=None, sortby=None, search=None,
|
||||||
search_in='content', groupby=None, **kwargs):
|
search_in='content', groupby=None, **kwargs):
|
||||||
@@ -81,10 +253,37 @@ class FusionProjectCustomerPortal(ProjectCustomerPortal):
|
|||||||
walk(c.id, depth + 1)
|
walk(c.id, depth + 1)
|
||||||
|
|
||||||
walk(task.id, 0)
|
walk(task.id, 0)
|
||||||
|
|
||||||
|
done_states = ('1_done', '03_approved')
|
||||||
|
total = len(descendants)
|
||||||
|
done = sum(1 for d in descendants if d.state in done_states)
|
||||||
|
allocated_field = 'allocated_hours' if 'allocated_hours' in Task._fields else (
|
||||||
|
'planned_hours' if 'planned_hours' in Task._fields else None
|
||||||
|
)
|
||||||
|
has_effective = 'effective_hours' in Task._fields
|
||||||
|
|
||||||
|
alloc_by_id = {}
|
||||||
|
spent_by_id = {}
|
||||||
|
for d in descendants:
|
||||||
|
alloc_by_id[d.id] = float(getattr(d, allocated_field, 0.0) or 0.0) if allocated_field else 0.0
|
||||||
|
spent_by_id[d.id] = float(d.effective_hours or 0.0) if has_effective else 0.0
|
||||||
|
|
||||||
|
total_alloc = sum(alloc_by_id.values())
|
||||||
|
total_spent = sum(spent_by_id.values())
|
||||||
|
|
||||||
values['fp_task_descendants'] = descendants
|
values['fp_task_descendants'] = descendants
|
||||||
values['fp_task_depth_inside'] = depth_map
|
values['fp_task_depth_inside'] = depth_map
|
||||||
values['fp_can_create_task'] = self._fp_can_create_task(task.project_id)
|
values['fp_can_create_task'] = self._fp_can_create_task(task.project_id)
|
||||||
|
values['fp_can_change_state'] = self._fp_can_change_state(task)
|
||||||
values['fp_priority_options'] = request.env['project.task']._fields['priority']._description_selection(request.env)
|
values['fp_priority_options'] = request.env['project.task']._fields['priority']._description_selection(request.env)
|
||||||
|
values['fp_done_states'] = done_states
|
||||||
|
values['fp_descendant_total'] = total
|
||||||
|
values['fp_descendant_done'] = done
|
||||||
|
values['fp_descendant_pct'] = round(100.0 * done / total, 1) if total else 0.0
|
||||||
|
values['fp_descendant_alloc_hours'] = total_alloc
|
||||||
|
values['fp_descendant_spent_hours'] = total_spent
|
||||||
|
values['fp_alloc_by_id'] = alloc_by_id
|
||||||
|
values['fp_spent_by_id'] = spent_by_id
|
||||||
timesheets = request.env['account.analytic.line'].sudo().search(
|
timesheets = request.env['account.analytic.line'].sudo().search(
|
||||||
[('task_id', '=', task.id)],
|
[('task_id', '=', task.id)],
|
||||||
order='date desc, id desc',
|
order='date desc, id desc',
|
||||||
@@ -119,6 +318,43 @@ class FusionProjectCustomerPortal(ProjectCustomerPortal):
|
|||||||
], limit=1)
|
], limit=1)
|
||||||
return bool(follower)
|
return bool(follower)
|
||||||
|
|
||||||
|
def _fp_can_change_state(self, task):
|
||||||
|
"""Permissive gate for the customer status actions on a task page.
|
||||||
|
|
||||||
|
A portal user who can VIEW the task should be able to flip its state
|
||||||
|
(Request Changes / Approve / Mark Done). _fp_can_create_task is too
|
||||||
|
strict — it only passes the project's customer or a project follower.
|
||||||
|
Customers are often added to a single task as the task partner or as a
|
||||||
|
task follower, never on the project.
|
||||||
|
"""
|
||||||
|
user = request.env.user
|
||||||
|
if not task or not task.exists():
|
||||||
|
return False
|
||||||
|
if not user or user.id == request.env.ref('base.public_user').id:
|
||||||
|
return False
|
||||||
|
if user._is_internal():
|
||||||
|
return True
|
||||||
|
# Project-level checks (covers most customers)
|
||||||
|
if self._fp_can_create_task(task.project_id):
|
||||||
|
return True
|
||||||
|
partner = user.partner_id
|
||||||
|
if not partner:
|
||||||
|
return False
|
||||||
|
# Task-level partner / commercial-partner family
|
||||||
|
if task.partner_id and (
|
||||||
|
partner == task.partner_id
|
||||||
|
or partner.parent_id == task.partner_id
|
||||||
|
or partner.commercial_partner_id == task.partner_id.commercial_partner_id
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
# Task followers
|
||||||
|
follower = request.env['mail.followers'].sudo().search_count([
|
||||||
|
('res_model', '=', 'project.task'),
|
||||||
|
('res_id', '=', task.id),
|
||||||
|
('partner_id', '=', partner.id),
|
||||||
|
], limit=1)
|
||||||
|
return bool(follower)
|
||||||
|
|
||||||
@http.route(
|
@http.route(
|
||||||
['/my/projects/<int:project_id>/task/<int:task_id>/state'],
|
['/my/projects/<int:project_id>/task/<int:task_id>/state'],
|
||||||
type='http', auth='user', website=True, methods=['POST'],
|
type='http', auth='user', website=True, methods=['POST'],
|
||||||
@@ -130,9 +366,6 @@ class FusionProjectCustomerPortal(ProjectCustomerPortal):
|
|||||||
except (AccessError, MissingError):
|
except (AccessError, MissingError):
|
||||||
return request.redirect('/my')
|
return request.redirect('/my')
|
||||||
|
|
||||||
if not self._fp_can_create_task(project_sudo):
|
|
||||||
return request.redirect(f'/my/projects/{project_id}/task/{task_id}')
|
|
||||||
|
|
||||||
task = request.env['project.task'].sudo().search([
|
task = request.env['project.task'].sudo().search([
|
||||||
('id', '=', task_id),
|
('id', '=', task_id),
|
||||||
('project_id', '=', project_id),
|
('project_id', '=', project_id),
|
||||||
@@ -140,6 +373,9 @@ class FusionProjectCustomerPortal(ProjectCustomerPortal):
|
|||||||
if not task:
|
if not task:
|
||||||
return request.redirect(f'/my/projects/{project_id}')
|
return request.redirect(f'/my/projects/{project_id}')
|
||||||
|
|
||||||
|
if not self._fp_can_change_state(task):
|
||||||
|
return request.redirect(f'/my/projects/{project_id}/task/{task_id}')
|
||||||
|
|
||||||
new_state = post.get('state')
|
new_state = post.get('state')
|
||||||
allowed = ['01_in_progress', '02_changes_requested', '03_approved', '1_done']
|
allowed = ['01_in_progress', '02_changes_requested', '03_approved', '1_done']
|
||||||
if new_state not in allowed:
|
if new_state not in allowed:
|
||||||
|
|||||||
@@ -0,0 +1,392 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
import { Interaction } from "@web/public/interaction";
|
||||||
|
import { registry } from "@web/core/registry";
|
||||||
|
|
||||||
|
export class PortalSearchLive extends Interaction {
|
||||||
|
static selector = ".o_portal_wrap";
|
||||||
|
|
||||||
|
dynamicContent = {
|
||||||
|
".fp_subtask_search": {
|
||||||
|
"t-on-input": this.debounced(this.onSubtaskInput, 80),
|
||||||
|
"t-on-keydown": this.onSubtaskKeydown,
|
||||||
|
},
|
||||||
|
".o_portal_search_panel input[name='search']": {
|
||||||
|
"t-on-input": this.debounced(this.onGlobalInput, 120),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
this._lastQ = new WeakMap();
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
// Apply once on load (handles server-rendered ?search=... and the empty initial state)
|
||||||
|
for (const input of this.el.querySelectorAll(".fp_subtask_search")) {
|
||||||
|
this.filterCard(input);
|
||||||
|
}
|
||||||
|
const g = this.el.querySelector(".o_portal_search_panel input[name='search']");
|
||||||
|
if (g && g.value) {
|
||||||
|
this.filterTables(g);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubtaskInput(ev) {
|
||||||
|
this.filterCard(ev.target);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubtaskKeydown(ev) {
|
||||||
|
if (ev.key === "Enter") {
|
||||||
|
ev.preventDefault();
|
||||||
|
this.filterCard(ev.target);
|
||||||
|
} else if (ev.key === "Escape") {
|
||||||
|
ev.target.value = "";
|
||||||
|
this.filterCard(ev.target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onGlobalInput(ev) {
|
||||||
|
this.filterTables(ev.target);
|
||||||
|
}
|
||||||
|
|
||||||
|
filterCard(input) {
|
||||||
|
const card = input.closest(".card");
|
||||||
|
if (!card) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const list = card.querySelector("ul.list-group");
|
||||||
|
if (!list) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const q = (input.value || "").trim().toLowerCase();
|
||||||
|
if (this._lastQ.get(input) === q) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._lastQ.set(input, q);
|
||||||
|
let visible = 0;
|
||||||
|
for (const li of list.children) {
|
||||||
|
if (li.querySelector("form")) {
|
||||||
|
// Skip inline "+ subtask" forms; show/hide them with their parent row.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!q) {
|
||||||
|
li.style.display = "";
|
||||||
|
li.classList.remove("d-none");
|
||||||
|
visible += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const text = (li.textContent || "").toLowerCase();
|
||||||
|
const match = text.indexOf(q) !== -1;
|
||||||
|
li.style.display = match ? "" : "none";
|
||||||
|
li.classList.toggle("d-none", !match);
|
||||||
|
if (match) {
|
||||||
|
visible += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Toggle a "no matches" empty hint
|
||||||
|
let hint = card.querySelector(".fp_subtask_no_match");
|
||||||
|
if (q && visible === 0) {
|
||||||
|
if (!hint) {
|
||||||
|
hint = document.createElement("div");
|
||||||
|
hint.className = "fp_subtask_no_match card-body text-muted small";
|
||||||
|
hint.textContent = "No sub-tasks match your search.";
|
||||||
|
list.insertAdjacentElement("afterend", hint);
|
||||||
|
}
|
||||||
|
hint.style.display = "";
|
||||||
|
} else if (hint) {
|
||||||
|
hint.style.display = "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filterTables(input) {
|
||||||
|
const q = (input.value || "").trim().toLowerCase();
|
||||||
|
const tables = this.el.querySelectorAll("table.table, table.o_portal_my_doc_table");
|
||||||
|
tables.forEach((table) => {
|
||||||
|
table.querySelectorAll("tbody").forEach((tbody) => {
|
||||||
|
let visibleTaskRows = 0;
|
||||||
|
let groupHeaderRow = null;
|
||||||
|
tbody.querySelectorAll("tr").forEach((row) => {
|
||||||
|
const isGroupHeader =
|
||||||
|
row.getAttribute("name") === "grouped_tasks_groupby_columns" ||
|
||||||
|
row.classList.contains("table-light");
|
||||||
|
if (isGroupHeader) {
|
||||||
|
if (groupHeaderRow !== null) {
|
||||||
|
groupHeaderRow.style.display = visibleTaskRows ? "" : "none";
|
||||||
|
}
|
||||||
|
groupHeaderRow = row;
|
||||||
|
visibleTaskRows = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!q) {
|
||||||
|
row.style.display = "";
|
||||||
|
visibleTaskRows += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = (row.textContent || "").toLowerCase();
|
||||||
|
const match = text.indexOf(q) !== -1;
|
||||||
|
row.style.display = match ? "" : "none";
|
||||||
|
if (match) {
|
||||||
|
visibleTaskRows += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (groupHeaderRow !== null) {
|
||||||
|
groupHeaderRow.style.display = visibleTaskRows ? "" : "none";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.category("public.interactions").add("fusion_project_portal.portal_search_live", PortalSearchLive);
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// /my/projects: client-side search + sort + group on the redesigned card grid.
|
||||||
|
// All projects are server-rendered into the page; this just toggles visibility
|
||||||
|
// and re-orders existing DOM nodes — no RPC.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const SORT_KEYS = {
|
||||||
|
name: (el) => (el.dataset.name || "").toLowerCase(),
|
||||||
|
pct: (el) => -parseFloat(el.dataset.pct || "0"),
|
||||||
|
tasks: (el) => -parseInt(el.dataset.tasks || "0", 10),
|
||||||
|
activity: (el) => {
|
||||||
|
const v = el.dataset.activity || "";
|
||||||
|
// sort newest first; missing values go last
|
||||||
|
return v ? `0${v.split("").reverse().join("")}` : "z";
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const SORT_LABELS = {
|
||||||
|
name: "Name",
|
||||||
|
pct: "% Complete",
|
||||||
|
tasks: "Task Count",
|
||||||
|
activity: "Last Activity",
|
||||||
|
};
|
||||||
|
|
||||||
|
export class FusionProjectsPage extends Interaction {
|
||||||
|
static selector = ".fp_projects_page";
|
||||||
|
|
||||||
|
dynamicContent = {
|
||||||
|
".fp_projects_search": {
|
||||||
|
"t-on-input": this.debounced(this.onSearchInput, 80),
|
||||||
|
"t-on-keydown": this.onSearchKeydown,
|
||||||
|
},
|
||||||
|
".fp_projects_group_picker .btn": {
|
||||||
|
"t-on-click.prevent.withTarget": this.onGroupClick,
|
||||||
|
},
|
||||||
|
".fp_projects_sort_picker .dropdown-item": {
|
||||||
|
"t-on-click.prevent.withTarget": this.onSortClick,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
this._sortKey = "name";
|
||||||
|
this._group = this.el.dataset.defaultGroup || "status";
|
||||||
|
this._cards = Array.from(this.el.querySelectorAll(".fp_project_card"));
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
this._applyGroup(this._group, /*initial=*/ true);
|
||||||
|
this._applySort();
|
||||||
|
}
|
||||||
|
|
||||||
|
onSearchInput(ev) {
|
||||||
|
this._applyFilter((ev.target.value || "").trim().toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
onSearchKeydown(ev) {
|
||||||
|
if (ev.key === "Escape") {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.target.value = "";
|
||||||
|
this._applyFilter("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onGroupClick(ev, currentTargetEl) {
|
||||||
|
const group = currentTargetEl.dataset.group;
|
||||||
|
if (!group || group === this._group) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.el.querySelectorAll(".fp_projects_group_picker .btn").forEach((b) =>
|
||||||
|
b.classList.toggle("active", b === currentTargetEl)
|
||||||
|
);
|
||||||
|
this._applyGroup(group);
|
||||||
|
this._applySort();
|
||||||
|
// Re-apply the active search after re-grouping
|
||||||
|
const searchEl = this.el.querySelector(".fp_projects_search");
|
||||||
|
this._applyFilter((searchEl && searchEl.value || "").trim().toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
onSortClick(ev, currentTargetEl) {
|
||||||
|
const key = currentTargetEl.dataset.sort;
|
||||||
|
if (!key || !(key in SORT_KEYS)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._sortKey = key;
|
||||||
|
const labelEl = this.el.querySelector(".fp_sort_label");
|
||||||
|
if (labelEl) {
|
||||||
|
labelEl.textContent = SORT_LABELS[key];
|
||||||
|
}
|
||||||
|
this.el.querySelectorAll(".fp_projects_sort_picker .dropdown-item").forEach((item) =>
|
||||||
|
item.classList.toggle("active", item === currentTargetEl)
|
||||||
|
);
|
||||||
|
this._applySort();
|
||||||
|
}
|
||||||
|
|
||||||
|
_applyFilter(q) {
|
||||||
|
let visibleTotal = 0;
|
||||||
|
const perColumn = new Map();
|
||||||
|
for (const card of this._cards) {
|
||||||
|
const matches =
|
||||||
|
!q ||
|
||||||
|
(card.dataset.name || "").toLowerCase().includes(q) ||
|
||||||
|
(card.dataset.customer || "").toLowerCase().includes(q);
|
||||||
|
card.classList.toggle("d-none", !matches);
|
||||||
|
if (matches) {
|
||||||
|
visibleTotal += 1;
|
||||||
|
const col = card.closest(".fp_projects_col");
|
||||||
|
const key = col ? (col.dataset.bucket || col.dataset.group || "") : "";
|
||||||
|
perColumn.set(key, (perColumn.get(key) || 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Update column counts and toggle column-empty fallback
|
||||||
|
for (const col of this.el.querySelectorAll(".fp_projects_col")) {
|
||||||
|
const key = col.dataset.bucket || col.dataset.group || "";
|
||||||
|
const n = perColumn.get(key) || 0;
|
||||||
|
const countEl = col.querySelector(".fp_col_count");
|
||||||
|
if (countEl) {
|
||||||
|
countEl.textContent = String(n);
|
||||||
|
}
|
||||||
|
const empty = col.querySelector(".fp_col_empty");
|
||||||
|
if (empty) {
|
||||||
|
empty.classList.toggle("d-none", n > 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const noMatch = this.el.querySelector(".fp_projects_no_match");
|
||||||
|
if (noMatch) {
|
||||||
|
noMatch.classList.toggle("d-none", visibleTotal !== 0 || !q);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_applyGroup(group, initial = false) {
|
||||||
|
this._group = group;
|
||||||
|
this.el.dataset.currentGroup = group;
|
||||||
|
const cols = this.el.querySelector(".fp_projects_cols");
|
||||||
|
if (!cols) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group === "status") {
|
||||||
|
// Restore the original 3 status columns rendered server-side
|
||||||
|
this._restoreStatusColumns(cols);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (group === "none") {
|
||||||
|
this._renderFlat(cols);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (group === "customer") {
|
||||||
|
this._renderByCustomer(cols);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_restoreStatusColumns(cols) {
|
||||||
|
// Cache the original markup once so we can flip back without RPC
|
||||||
|
if (!this._originalCols) {
|
||||||
|
this._originalCols = cols.innerHTML;
|
||||||
|
} else {
|
||||||
|
cols.innerHTML = this._originalCols;
|
||||||
|
this._cards = Array.from(this.el.querySelectorAll(".fp_project_card"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderFlat(cols) {
|
||||||
|
if (!this._originalCols) {
|
||||||
|
this._originalCols = cols.innerHTML;
|
||||||
|
}
|
||||||
|
const cards = Array.from(this.el.querySelectorAll(".fp_project_card"));
|
||||||
|
cols.innerHTML = "";
|
||||||
|
const col = this._buildColumn("All projects", "gray", cards.length);
|
||||||
|
col.dataset.group = "all";
|
||||||
|
const body = col.querySelector(".fp_projects_col_body");
|
||||||
|
for (const card of cards) {
|
||||||
|
body.appendChild(card);
|
||||||
|
}
|
||||||
|
cols.appendChild(col);
|
||||||
|
this._cards = cards;
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderByCustomer(cols) {
|
||||||
|
if (!this._originalCols) {
|
||||||
|
this._originalCols = cols.innerHTML;
|
||||||
|
}
|
||||||
|
const cards = Array.from(this.el.querySelectorAll(".fp_project_card"));
|
||||||
|
const groups = new Map();
|
||||||
|
for (const card of cards) {
|
||||||
|
const key = (card.dataset.customer || "Unassigned").trim() || "Unassigned";
|
||||||
|
if (!groups.has(key)) {
|
||||||
|
groups.set(key, []);
|
||||||
|
}
|
||||||
|
groups.get(key).push(card);
|
||||||
|
}
|
||||||
|
const orderedKeys = Array.from(groups.keys()).sort((a, b) => {
|
||||||
|
if (a === "Unassigned") return 1;
|
||||||
|
if (b === "Unassigned") return -1;
|
||||||
|
return a.localeCompare(b);
|
||||||
|
});
|
||||||
|
cols.innerHTML = "";
|
||||||
|
for (const key of orderedKeys) {
|
||||||
|
const items = groups.get(key);
|
||||||
|
const col = this._buildColumn(key, "gray", items.length);
|
||||||
|
col.dataset.group = key;
|
||||||
|
const body = col.querySelector(".fp_projects_col_body");
|
||||||
|
for (const card of items) {
|
||||||
|
body.appendChild(card);
|
||||||
|
}
|
||||||
|
cols.appendChild(col);
|
||||||
|
}
|
||||||
|
this._cards = cards;
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildColumn(label, dot, count) {
|
||||||
|
const col = document.createElement("div");
|
||||||
|
col.className = "fp_projects_col";
|
||||||
|
col.innerHTML = `
|
||||||
|
<div class="fp_projects_col_head">
|
||||||
|
<span class="fp_col_label"><span class="fp_dot fp_dot_${dot}"></span>${this._escape(label)}</span>
|
||||||
|
<span class="fp_col_count">${count}</span>
|
||||||
|
</div>
|
||||||
|
<div class="fp_projects_col_body"></div>
|
||||||
|
`;
|
||||||
|
return col;
|
||||||
|
}
|
||||||
|
|
||||||
|
_escape(s) {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.textContent = s;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
_applySort() {
|
||||||
|
const fn = SORT_KEYS[this._sortKey] || SORT_KEYS.name;
|
||||||
|
for (const col of this.el.querySelectorAll(".fp_projects_col")) {
|
||||||
|
const body = col.querySelector(".fp_projects_col_body");
|
||||||
|
if (!body) continue;
|
||||||
|
const cards = Array.from(body.querySelectorAll(".fp_project_card"));
|
||||||
|
cards.sort((a, b) => {
|
||||||
|
const av = fn(a);
|
||||||
|
const bv = fn(b);
|
||||||
|
if (av < bv) return -1;
|
||||||
|
if (av > bv) return 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
for (const c of cards) {
|
||||||
|
body.appendChild(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.category("public.interactions").add("fusion_project_portal.projects_page", FusionProjectsPage);
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
// Fusion portal: redesigned /my/projects page
|
||||||
|
// Tokens are wrapped in CSS custom properties so a future portal dark-mode
|
||||||
|
// pass can flip the surface colors without rewriting any rules.
|
||||||
|
|
||||||
|
$fp-page-bg: var(--fp-page-bg, #f3f4f6);
|
||||||
|
$fp-card-bg: var(--fp-card-bg, #ffffff);
|
||||||
|
$fp-border: var(--fp-border, #d8dadd);
|
||||||
|
$fp-text: var(--fp-text, #0f172a);
|
||||||
|
$fp-muted: var(--fp-muted, #6b7280);
|
||||||
|
$fp-track: var(--fp-track, #e5e7eb);
|
||||||
|
|
||||||
|
$fp-blue-bg: var(--fp-blue-bg, #dbeafe);
|
||||||
|
$fp-blue-fg: var(--fp-blue-fg, #1d4ed8);
|
||||||
|
$fp-green-bg: var(--fp-green-bg, #dcfce7);
|
||||||
|
$fp-green-fg: var(--fp-green-fg, #166534);
|
||||||
|
$fp-amber-bg: var(--fp-amber-bg, #fef3c7);
|
||||||
|
$fp-amber-fg: var(--fp-amber-fg, #92400e);
|
||||||
|
$fp-cyan-bg: var(--fp-cyan-bg, #cffafe);
|
||||||
|
$fp-cyan-fg: var(--fp-cyan-fg, #155e75);
|
||||||
|
$fp-gray-bg: var(--fp-gray-bg, #e5e7eb);
|
||||||
|
$fp-gray-fg: var(--fp-gray-fg, #374151);
|
||||||
|
|
||||||
|
$fp-bar-high: var(--fp-bar-high, #22c55e);
|
||||||
|
$fp-bar-mid: var(--fp-bar-mid, #3b82f6);
|
||||||
|
$fp-bar-low: var(--fp-bar-low, #f59e0b);
|
||||||
|
|
||||||
|
$fp-radius: 10px;
|
||||||
|
$fp-radius-sm: 6px;
|
||||||
|
|
||||||
|
.fp_projects_page {
|
||||||
|
.fp_projects_header {
|
||||||
|
h3 {
|
||||||
|
color: $fp-text;
|
||||||
|
}
|
||||||
|
.fp_projects_search_wrap {
|
||||||
|
min-width: 220px;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3-column status grid
|
||||||
|
.fp_projects_cols {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flat / customer-grouped variants reuse a single column
|
||||||
|
&[data-current-group="none"] .fp_projects_cols,
|
||||||
|
&[data-current-group="customer"] .fp_projects_cols {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fp_projects_col {
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.fp_projects_col_head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 4px 8px;
|
||||||
|
border-bottom: 2px solid $fp-track;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
|
||||||
|
.fp_col_label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: $fp-gray-fg;
|
||||||
|
}
|
||||||
|
.fp_col_count {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $fp-muted;
|
||||||
|
background: $fp-gray-bg;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fp_projects_col_body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fp_col_empty {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 22px 8px;
|
||||||
|
background: $fp-card-bg;
|
||||||
|
border: 1px dashed $fp-border;
|
||||||
|
border-radius: $fp-radius;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fp_dot {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: inline-block;
|
||||||
|
&.fp_dot_green { background: #22c55e; }
|
||||||
|
&.fp_dot_amber { background: #f59e0b; }
|
||||||
|
&.fp_dot_gray { background: #9ca3af; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card
|
||||||
|
.fp_project_card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 12px 13px 11px;
|
||||||
|
background: $fp-card-bg;
|
||||||
|
border: 1px solid $fp-border;
|
||||||
|
border-radius: $fp-radius;
|
||||||
|
text-decoration: none;
|
||||||
|
color: $fp-text;
|
||||||
|
transition: border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #93c5fd;
|
||||||
|
box-shadow: 0 4px 14px rgba(59, 130, 246, 0.10);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
text-decoration: none;
|
||||||
|
color: $fp-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fp_card_top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
.fp_card_title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.25;
|
||||||
|
color: $fp-text;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.fp_card_pct {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
&.fp_pct_high { color: #15803d; }
|
||||||
|
&.fp_pct_mid { color: #1d4ed8; }
|
||||||
|
&.fp_pct_low { color: #b45309; }
|
||||||
|
&.fp_card_pct_muted { color: #9ca3af; font-weight: 500; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fp_card_sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: $fp-muted;
|
||||||
|
min-height: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fp_card_bar {
|
||||||
|
height: 5px;
|
||||||
|
background: $fp-track;
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
> span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: $fp-bar-mid;
|
||||||
|
transition: width 200ms ease;
|
||||||
|
}
|
||||||
|
&.fp_bar_high > span { background: $fp-bar-high; }
|
||||||
|
&.fp_bar_mid > span { background: $fp-bar-mid; }
|
||||||
|
&.fp_bar_low > span { background: $fp-bar-low; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.fp_card_stats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.fp_chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&.fp_chip_blue { background: $fp-blue-bg; color: $fp-blue-fg; }
|
||||||
|
&.fp_chip_green { background: $fp-green-bg; color: $fp-green-fg; }
|
||||||
|
&.fp_chip_amber { background: $fp-amber-bg; color: $fp-amber-fg; }
|
||||||
|
&.fp_chip_cyan { background: $fp-cyan-bg; color: $fp-cyan-fg; }
|
||||||
|
&.fp_chip_gray { background: $fp-gray-bg; color: $fp-gray-fg; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fp_card_footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 2px;
|
||||||
|
|
||||||
|
.fp_avatars {
|
||||||
|
display: inline-flex;
|
||||||
|
.fp_avatar {
|
||||||
|
position: relative;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #e0e7ff;
|
||||||
|
color: #3730a3;
|
||||||
|
font-size: 9.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-left: -6px;
|
||||||
|
border: 2px solid $fp-card-bg;
|
||||||
|
|
||||||
|
&:first-child { margin-left: 0; }
|
||||||
|
img {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.fp_avatar_text {
|
||||||
|
// hidden when image loads; shown when image fails (handled in JS via classList)
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
&.fp_avatar_initials .fp_avatar_text { display: inline; }
|
||||||
|
&.fp_avatar_initials img { display: none; }
|
||||||
|
|
||||||
|
&.fp_avatar_more {
|
||||||
|
background: $fp-gray-bg;
|
||||||
|
color: $fp-gray-fg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty page state
|
||||||
|
.fp_projects_empty {
|
||||||
|
background: $fp-card-bg;
|
||||||
|
border: 1px dashed $fp-border;
|
||||||
|
border-radius: $fp-radius;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,194 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<odoo>
|
<odoo>
|
||||||
|
|
||||||
|
<!-- Redesigned /my/projects page: status columns + rich cards -->
|
||||||
|
<template id="portal_my_projects" name="Fusion: My Projects (cards)">
|
||||||
|
<t t-call="portal.portal_layout">
|
||||||
|
<t t-set="breadcrumbs_searchbar" t-value="True"/>
|
||||||
|
|
||||||
|
<t t-set="_active_ids" t-value="[p.id for p in projects if (fp_project_cards or {}).get(p.id, {}).get('bucket') == 'active']"/>
|
||||||
|
<t t-set="_idle_ids" t-value="[p.id for p in projects if (fp_project_cards or {}).get(p.id, {}).get('bucket') == 'idle']"/>
|
||||||
|
<t t-set="_done_ids" t-value="[p.id for p in projects if (fp_project_cards or {}).get(p.id, {}).get('bucket') == 'done']"/>
|
||||||
|
|
||||||
|
<div class="fp_projects_page" t-att-data-default-group="fp_groupby or 'status'">
|
||||||
|
|
||||||
|
<!-- Page header / control bar -->
|
||||||
|
<div class="fp_projects_header d-flex flex-wrap align-items-center gap-2 mb-3">
|
||||||
|
<h3 class="mb-0 me-2">
|
||||||
|
<i class="fa fa-folder-open-o me-2"/>Projects
|
||||||
|
<span class="badge bg-secondary ms-2"><t t-esc="len(projects)"/></span>
|
||||||
|
</h3>
|
||||||
|
<div class="ms-auto d-flex flex-wrap align-items-center gap-2">
|
||||||
|
<div class="input-group input-group-sm fp_projects_search_wrap">
|
||||||
|
<span class="input-group-text bg-white"><i class="fa fa-search"/></span>
|
||||||
|
<input type="search"
|
||||||
|
class="form-control form-control-sm fp_projects_search"
|
||||||
|
placeholder="Search projects..."/>
|
||||||
|
</div>
|
||||||
|
<div class="btn-group btn-group-sm fp_projects_group_picker" role="group" aria-label="Group by">
|
||||||
|
<button type="button" class="btn btn-outline-secondary active" data-group="status">By Status</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-group="customer">By Customer</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-group="none">Flat</button>
|
||||||
|
</div>
|
||||||
|
<div class="dropdown fp_projects_sort_picker">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
Sort: <span class="fp_sort_label">Name</span>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
|
<li><a class="dropdown-item active" href="#" data-sort="name">Name</a></li>
|
||||||
|
<li><a class="dropdown-item" href="#" data-sort="pct">% Complete</a></li>
|
||||||
|
<li><a class="dropdown-item" href="#" data-sort="tasks">Task Count</a></li>
|
||||||
|
<li><a class="dropdown-item" href="#" data-sort="activity">Last Activity</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty: no projects at all -->
|
||||||
|
<t t-if="not projects">
|
||||||
|
<div class="fp_projects_empty text-center py-5">
|
||||||
|
<div class="fp_empty_glyph mb-3"><i class="fa fa-folder-open-o fa-3x text-muted"/></div>
|
||||||
|
<h5 class="text-muted mb-1">You don't have any projects yet</h5>
|
||||||
|
<p class="text-muted small mb-0">Once a project is shared with you, it will appear here.</p>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- Columns + cards -->
|
||||||
|
<t t-if="projects">
|
||||||
|
<div class="fp_projects_cols">
|
||||||
|
<t t-call="fusion_project_portal.fp_project_column">
|
||||||
|
<t t-set="bucket_key" t-value="'active'"/>
|
||||||
|
<t t-set="bucket_label" t-value="'Active'"/>
|
||||||
|
<t t-set="bucket_dot" t-value="'green'"/>
|
||||||
|
<t t-set="bucket_empty" t-value="'No active projects.'"/>
|
||||||
|
<t t-set="bucket_ids" t-value="_active_ids"/>
|
||||||
|
</t>
|
||||||
|
<t t-call="fusion_project_portal.fp_project_column">
|
||||||
|
<t t-set="bucket_key" t-value="'idle'"/>
|
||||||
|
<t t-set="bucket_label" t-value="'Idle'"/>
|
||||||
|
<t t-set="bucket_dot" t-value="'amber'"/>
|
||||||
|
<t t-set="bucket_empty" t-value="'No idle projects.'"/>
|
||||||
|
<t t-set="bucket_ids" t-value="_idle_ids"/>
|
||||||
|
</t>
|
||||||
|
<t t-call="fusion_project_portal.fp_project_column">
|
||||||
|
<t t-set="bucket_key" t-value="'done'"/>
|
||||||
|
<t t-set="bucket_label" t-value="'Done'"/>
|
||||||
|
<t t-set="bucket_dot" t-value="'gray'"/>
|
||||||
|
<t t-set="bucket_empty" t-value="'Completed projects will appear here.'"/>
|
||||||
|
<t t-set="bucket_ids" t-value="_done_ids"/>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
<div class="fp_projects_no_match d-none text-center py-4 text-muted small">
|
||||||
|
No projects match your search.
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<div t-if="pager and pager.get('page_count', 1) > 1" class="mt-3">
|
||||||
|
<t t-call="portal.pager"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Single column with its cards -->
|
||||||
|
<template id="fp_project_column" name="Fusion: project status column">
|
||||||
|
<div class="fp_projects_col" t-att-data-bucket="bucket_key">
|
||||||
|
<div class="fp_projects_col_head">
|
||||||
|
<span class="fp_col_label">
|
||||||
|
<span t-attf-class="fp_dot fp_dot_{{ bucket_dot }}"/>
|
||||||
|
<t t-esc="bucket_label"/>
|
||||||
|
</span>
|
||||||
|
<span class="fp_col_count" t-esc="len(bucket_ids or [])"/>
|
||||||
|
</div>
|
||||||
|
<div class="fp_projects_col_body">
|
||||||
|
<t t-if="not bucket_ids">
|
||||||
|
<div class="fp_col_empty"><t t-esc="bucket_empty"/></div>
|
||||||
|
</t>
|
||||||
|
<t t-foreach="projects" t-as="project">
|
||||||
|
<t t-if="project.id in (bucket_ids or [])">
|
||||||
|
<t t-call="fusion_project_portal.fp_project_card"/>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Rich project card (used inside columns and flat list) -->
|
||||||
|
<template id="fp_project_card" name="Fusion: project card">
|
||||||
|
<t t-set="_d" t-value="(fp_project_cards or {}).get(project.id, {})"/>
|
||||||
|
<t t-set="_pct" t-value="_d.get('pct') or 0"/>
|
||||||
|
<t t-set="_bucket" t-value="_d.get('bucket') or 'idle'"/>
|
||||||
|
<t t-set="_total" t-value="_d.get('total_count') or 0"/>
|
||||||
|
<t t-set="_done" t-value="_d.get('done_count') or 0"/>
|
||||||
|
<t t-set="_open" t-value="_d.get('open_count') or 0"/>
|
||||||
|
<t t-set="_alloc" t-value="_d.get('alloc_hours') or 0.0"/>
|
||||||
|
<t t-set="_spent" t-value="_d.get('spent_hours') or 0.0"/>
|
||||||
|
<t t-set="_assignees" t-value="_d.get('assignees') or []"/>
|
||||||
|
<t t-set="_customer" t-value="_d.get('partner_name') or ''"/>
|
||||||
|
<t t-set="_last" t-value="_d.get('last_activity')"/>
|
||||||
|
|
||||||
|
<a t-attf-href="/my/projects/{{ project.id }}"
|
||||||
|
class="fp_project_card"
|
||||||
|
t-att-data-bucket="_bucket"
|
||||||
|
t-att-data-name="project.name"
|
||||||
|
t-att-data-customer="_customer"
|
||||||
|
t-att-data-pct="_pct"
|
||||||
|
t-att-data-tasks="_total"
|
||||||
|
t-att-data-activity="(_last and _last.isoformat()) or ''">
|
||||||
|
<div class="fp_card_top">
|
||||||
|
<span class="fp_card_title"><t t-esc="project.name"/></span>
|
||||||
|
<span t-if="_pct" t-attf-class="fp_card_pct fp_pct_{{ 'high' if _pct >= 80 else ('mid' if _pct >= 40 else 'low') }}">
|
||||||
|
<t t-esc="str(int(_pct)) + '%'"/>
|
||||||
|
</span>
|
||||||
|
<span t-if="not _pct" class="fp_card_pct fp_card_pct_muted">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="fp_card_sub">
|
||||||
|
<t t-if="_customer"><i class="fa fa-user-o me-1 text-muted"/><t t-esc="_customer"/></t>
|
||||||
|
<t t-else=""><span class="text-muted">No customer set</span></t>
|
||||||
|
</div>
|
||||||
|
<div t-attf-class="fp_card_bar fp_bar_{{ 'high' if _pct >= 80 else ('mid' if _pct >= 40 else 'low') }}">
|
||||||
|
<span t-attf-style="width: {{ _pct }}%;"/>
|
||||||
|
</div>
|
||||||
|
<div class="fp_card_stats">
|
||||||
|
<span t-if="_total" class="fp_chip fp_chip_blue">
|
||||||
|
<i class="fa fa-list-ul me-1"/><t t-esc="_total"/> task<t t-if="_total != 1">s</t>
|
||||||
|
</span>
|
||||||
|
<span t-if="not _total" class="fp_chip fp_chip_gray">No tasks</span>
|
||||||
|
<span t-if="_done" class="fp_chip fp_chip_green">
|
||||||
|
<i class="fa fa-check me-1"/><t t-esc="_done"/> done
|
||||||
|
</span>
|
||||||
|
<span t-if="_alloc or _spent" class="fp_chip fp_chip_cyan ms-auto">
|
||||||
|
<i class="fa fa-clock-o me-1"/>
|
||||||
|
<t t-esc="'%.1fh' % _spent"/>
|
||||||
|
<t t-if="_alloc"> / <t t-esc="'%.1fh' % _alloc"/></t>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="fp_card_footer">
|
||||||
|
<div class="fp_avatars">
|
||||||
|
<t t-foreach="_assignees[:4]" t-as="u">
|
||||||
|
<span class="fp_avatar" t-att-title="u['name']">
|
||||||
|
<img t-attf-src="/web/image/res.users/{{ u['id'] }}/avatar_128"
|
||||||
|
t-att-alt="u['name']"
|
||||||
|
onerror="this.style.display='none';this.parentElement.classList.add('fp_avatar_initials')"/>
|
||||||
|
<span class="fp_avatar_text"><t t-esc="u['initials']"/></span>
|
||||||
|
</span>
|
||||||
|
</t>
|
||||||
|
<span t-if="len(_assignees) > 4" class="fp_avatar fp_avatar_more">+<t t-esc="len(_assignees) - 4"/></span>
|
||||||
|
</div>
|
||||||
|
<span t-if="_last" class="fp_card_activity small text-muted">
|
||||||
|
<i class="fa fa-history me-1"/><t t-esc="_last.strftime('%b %-d')" t-translation="off"/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Status actions: Request Changes / Approve / Mark Done from the portal task page -->
|
<!-- Status actions: Request Changes / Approve / Mark Done from the portal task page -->
|
||||||
<template id="portal_my_task_inherit_state_actions"
|
<template id="portal_my_task_inherit_state_actions"
|
||||||
inherit_id="project.portal_my_task"
|
inherit_id="project.portal_my_task"
|
||||||
name="Fusion: Status actions on portal task page"
|
name="Fusion: Status actions on portal task page"
|
||||||
priority="55">
|
priority="55">
|
||||||
<xpath expr="//div[@id='task_chat']" position="before">
|
<xpath expr="//div[@id='task_chat']" position="before">
|
||||||
<div t-if="fp_can_create_task" class="card mt-4 mb-4 fp_state_card">
|
<div t-if="fp_can_change_state" class="card mt-4 mb-4 fp_state_card">
|
||||||
<div class="card-header bg-light d-flex align-items-center">
|
<div class="card-header bg-light d-flex align-items-center">
|
||||||
<h5 class="mb-0"><i class="fa fa-flag me-2"/> Status</h5>
|
<h5 class="mb-0"><i class="fa fa-flag me-2"/> Status</h5>
|
||||||
<span class="ms-auto badge"
|
<span class="ms-auto badge"
|
||||||
@@ -163,16 +344,35 @@
|
|||||||
name="Fusion: Sub-tasks list inside parent task page">
|
name="Fusion: Sub-tasks list inside parent task page">
|
||||||
<xpath expr="//div[@id='task_chat']" position="before">
|
<xpath expr="//div[@id='task_chat']" position="before">
|
||||||
<div class="card mt-4 mb-4 fp_subtasks_card">
|
<div class="card mt-4 mb-4 fp_subtasks_card">
|
||||||
<div class="card-header bg-light d-flex align-items-center">
|
<div class="card-header bg-light d-flex align-items-center flex-wrap gap-2">
|
||||||
<h5 class="mb-0">
|
<h5 class="mb-0">
|
||||||
<i class="fa fa-sitemap me-2"/>
|
<i class="fa fa-sitemap me-2"/>
|
||||||
Sub-tasks
|
Sub-tasks
|
||||||
<span class="badge bg-secondary ms-2">
|
<span class="badge bg-secondary ms-2">
|
||||||
<t t-esc="len(fp_task_descendants or [])"/>
|
<t t-esc="len(fp_task_descendants or [])"/>
|
||||||
</span>
|
</span>
|
||||||
|
<span t-if="fp_descendant_total" class="badge bg-success ms-2"
|
||||||
|
t-attf-title="{{ fp_descendant_done }} of {{ fp_descendant_total }} sub-tasks done">
|
||||||
|
<i class="fa fa-check-circle me-1"/>
|
||||||
|
<t t-esc="'%.0f' % (fp_descendant_pct or 0)"/>%
|
||||||
|
</span>
|
||||||
|
<span t-if="fp_descendant_alloc_hours or fp_descendant_spent_hours"
|
||||||
|
class="badge bg-info text-dark ms-2"
|
||||||
|
title="Spent / Allocated hours across sub-tasks">
|
||||||
|
<i class="fa fa-clock-o me-1"/>
|
||||||
|
<t t-esc="'%.1fh' % (fp_descendant_spent_hours or 0)"/>
|
||||||
|
<span t-if="fp_descendant_alloc_hours">
|
||||||
|
/ <t t-esc="'%.1fh' % fp_descendant_alloc_hours"/>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
</h5>
|
</h5>
|
||||||
|
<input t-if="fp_task_descendants"
|
||||||
|
type="search"
|
||||||
|
class="form-control form-control-sm fp_subtask_search ms-auto"
|
||||||
|
style="max-width: 260px;"
|
||||||
|
placeholder="Search sub-tasks..."/>
|
||||||
<button t-if="fp_can_create_task"
|
<button t-if="fp_can_create_task"
|
||||||
class="btn btn-sm btn-primary ms-auto"
|
class="btn btn-sm btn-primary"
|
||||||
type="button"
|
type="button"
|
||||||
data-bs-toggle="collapse"
|
data-bs-toggle="collapse"
|
||||||
t-attf-data-bs-target="#fp_form_root_{{ task.id }}"
|
t-attf-data-bs-target="#fp_form_root_{{ task.id }}"
|
||||||
@@ -196,7 +396,7 @@
|
|||||||
<span t-attf-style="display:inline-block;width:{{ _d * 1.4 }}rem;"/>
|
<span t-attf-style="display:inline-block;width:{{ _d * 1.4 }}rem;"/>
|
||||||
<i t-if="_d" class="fa fa-level-up fa-rotate-90 me-2 text-muted small"/>
|
<i t-if="_d" class="fa fa-level-up fa-rotate-90 me-2 text-muted small"/>
|
||||||
<a t-attf-href="/my/projects/{{ task.project_id.id }}/task/{{ sub.id }}"
|
<a t-attf-href="/my/projects/{{ task.project_id.id }}/task/{{ sub.id }}"
|
||||||
class="flex-grow-1">
|
t-attf-class="flex-grow-1 #{'text-decoration-line-through text-muted' if sub.state in (fp_done_states or ()) else ''}">
|
||||||
<span t-esc="sub.name"/>
|
<span t-esc="sub.name"/>
|
||||||
</a>
|
</a>
|
||||||
<button t-if="fp_can_create_task"
|
<button t-if="fp_can_create_task"
|
||||||
@@ -208,7 +408,23 @@
|
|||||||
title="Add sub-task here">
|
title="Add sub-task here">
|
||||||
<i class="fa fa-plus"/>
|
<i class="fa fa-plus"/>
|
||||||
</button>
|
</button>
|
||||||
<span t-if="sub.stage_id" class="badge bg-light text-dark border ms-2">
|
<t t-set="_fp_alloc" t-value="(fp_alloc_by_id or {}).get(sub.id, 0.0)"/>
|
||||||
|
<t t-set="_fp_spent" t-value="(fp_spent_by_id or {}).get(sub.id, 0.0)"/>
|
||||||
|
<span t-if="_fp_alloc or _fp_spent" class="badge bg-info text-dark ms-2 small"
|
||||||
|
title="Spent / Allocated">
|
||||||
|
<i class="fa fa-clock-o me-1"/>
|
||||||
|
<t t-esc="'%.1fh' % (_fp_spent or 0)"/>
|
||||||
|
<t t-if="_fp_alloc"> / <t t-esc="'%.1fh' % _fp_alloc"/></t>
|
||||||
|
</span>
|
||||||
|
<span t-if="sub.state in (fp_done_states or ())"
|
||||||
|
class="badge bg-success ms-2">
|
||||||
|
<i class="fa fa-check me-1"/> Done
|
||||||
|
</span>
|
||||||
|
<span t-elif="sub.state == '02_changes_requested'"
|
||||||
|
class="badge bg-warning text-dark ms-2">
|
||||||
|
<i class="fa fa-exclamation-circle me-1"/> Changes
|
||||||
|
</span>
|
||||||
|
<span t-elif="sub.stage_id" class="badge bg-light text-dark border ms-2">
|
||||||
<t t-esc="sub.stage_id.name"/>
|
<t t-esc="sub.stage_id.name"/>
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
{
|
{
|
||||||
"name": "Fusion Whitelabels",
|
"name": "Fusion Whitelabels",
|
||||||
"version": "19.0.1.3.0",
|
"version": "19.0.1.4.3",
|
||||||
"category": "Website",
|
"category": "Website",
|
||||||
"summary": "Remove Odoo frontend promotional branding for portal and website pages.",
|
"summary": "Replace Odoo frontend promotional branding with Nexa Systems whitelabeling.",
|
||||||
"description": """
|
"description": """
|
||||||
Fusion Whitelabels
|
Fusion Whitelabels
|
||||||
==================
|
==================
|
||||||
|
|
||||||
Persistent Odoo 19 whitelabel customizations:
|
Persistent Odoo 19 whitelabel customizations:
|
||||||
- Removes "Connect with your software" portal promotions.
|
- Removes "Connect with your software" portal promotions.
|
||||||
- Removes global "Powered by Odoo" website/footer promotions.
|
- Replaces global "Powered by Odoo" website/footer promotions with Nexa Systems credit.
|
||||||
- Removes login-page "Powered by Odoo" footer link.
|
- Removes login-page "Powered by Odoo" footer link.
|
||||||
""",
|
""",
|
||||||
"author": "Fusion",
|
"author": "Fusion",
|
||||||
|
|||||||
@@ -1,3 +1,91 @@
|
|||||||
.o-mail-Thread-jumpPresent {
|
.o-mail-Thread-jumpPresent {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.o_fusion_nexa_brand_promotion {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.35rem;
|
||||||
|
width: 100%;
|
||||||
|
color: inherit;
|
||||||
|
text-align: right;
|
||||||
|
opacity: 0.95;
|
||||||
|
animation: o-fusion-nexa-credit-in 700ms ease-out both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fusion_nexa_brand_label {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fusion_nexa_brand_link {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
color: #1f6feb;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none !important;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: color 180ms ease, text-shadow 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fusion_nexa_brand_link::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: -0.16em;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(90deg, #16a085, #1f6feb, #16a085);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
transform: scaleX(0.36);
|
||||||
|
transform-origin: left center;
|
||||||
|
opacity: 0.72;
|
||||||
|
transition: transform 220ms ease, opacity 220ms ease;
|
||||||
|
animation: o-fusion-nexa-underline-flow 2.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fusion_nexa_brand_link:hover,
|
||||||
|
.o_fusion_nexa_brand_link:focus {
|
||||||
|
color: #1f6feb;
|
||||||
|
text-decoration: none !important;
|
||||||
|
text-shadow: 0 0 12px rgba(31, 111, 235, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fusion_nexa_brand_link:hover::after,
|
||||||
|
.o_fusion_nexa_brand_link:focus::after {
|
||||||
|
transform: scaleX(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes o-fusion-nexa-credit-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(6px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0.95;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes o-fusion-nexa-underline-flow {
|
||||||
|
from {
|
||||||
|
background-position: 0 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.o_fusion_nexa_brand_promotion,
|
||||||
|
.o_fusion_nexa_brand_link::after {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fusion_nexa_brand_link,
|
||||||
|
.o_fusion_nexa_brand_link::after {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,9 +18,23 @@
|
|||||||
<xpath expr="//div[hasclass('d-none','d-lg-block','mt-5','small','text-center','text-muted')]" position="replace"/>
|
<xpath expr="//div[hasclass('d-none','d-lg-block','mt-5','small','text-center','text-muted')]" position="replace"/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template id="fusion_whitelabels_hide_brand_promotion" inherit_id="web.brand_promotion" priority="999">
|
<template id="fusion_whitelabels_nexa_brand_promotion" inherit_id="web.brand_promotion" priority="999">
|
||||||
<xpath expr="//div[hasclass('o_brand_promotion')]" position="attributes">
|
<xpath expr="//div[hasclass('o_brand_promotion')]" position="replace">
|
||||||
<attribute name="style">display:none !important;</attribute>
|
<div class="o_brand_promotion d-none"/>
|
||||||
|
</xpath>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="fusion_whitelabels_footer_right_credit" inherit_id="web.frontend_layout" priority="999">
|
||||||
|
<xpath expr="//footer[@id='bottom']//div[hasclass('o_footer_copyright')]//t[@t-call='web.brand_promotion']/.." position="replace">
|
||||||
|
<div class="text-center col-md small mt-auto mb-0 text-md-end o_fusion_nexa_footer_column">
|
||||||
|
<div class="o_fusion_nexa_brand_promotion">
|
||||||
|
<span class="o_fusion_nexa_brand_label">Designed by</span>
|
||||||
|
<a class="o_fusion_nexa_brand_link"
|
||||||
|
href="https://nexasystems.ca"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer">Nexa Systems</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</xpath>
|
</xpath>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user