feat: Infor-style split-panel layout, projects page, fuzzy search, Odoo scaffold
Web UI - Infor CloudSuite-style split-panel layout (items.html rewrite): - Replace modal-based item detail with inline split-panel workspace - Horizontal mode: item list on left, tabbed detail panel on right - Vertical mode: detail panel on top, item list below - Detail tabs: Main, Properties, Revisions, BOM, Where Used - Ctrl+F opens in-page filter overlay with fuzzy search - Column config gear icon with per-layout-mode persistence - Search scope toggle pills (All / Part Number / Description) - Selected row highlight with accent border - Responsive breakpoint forces vertical below 900px - Create/Edit/Delete remain as modal dialogs Web UI - Projects page: - New projects.html template with full CRUD - Project table: Code, Name, Description, Item count, Created, Actions - Create/Edit/Delete modals - Click project code navigates to items filtered by project - 3-tab navigation in base.html: Items, Projects, Schemas Fuzzy search: - Add sahilm/fuzzy dependency for ranked text matching - New internal/api/search.go with SearchableItems fuzzy.Source - GET /api/items/search endpoint with field scope and type/project filters - Frontend routes to fuzzy endpoint when search input is non-empty Odoo ERP integration scaffold: - Migration 008: integrations and sync_log tables - internal/odoo/ package: types, client stubs, sync stubs - internal/db/integrations.go: IntegrationRepository - internal/config/config.go: OdooConfig struct - 6 API endpoints for config CRUD, sync log, test, push, pull - All sync operations return stub responses Documentation: - docs/REPOSITORY_STATUS.md: comprehensive repository state report with architecture overview, API surface, feature stubs, and potential issues analysis
This commit is contained in:
285
docs/REPOSITORY_STATUS.md
Normal file
285
docs/REPOSITORY_STATUS.md
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
# Repository Status Report
|
||||||
|
|
||||||
|
**Generated:** 2026-01-31
|
||||||
|
**Branch:** main
|
||||||
|
**Last Build:** `go build ./...` and `go vet ./...` pass clean
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Codebase Summary
|
||||||
|
|
||||||
|
| Category | Lines | Files |
|
||||||
|
|----------|-------|-------|
|
||||||
|
| Go source | ~6,644 | 20 |
|
||||||
|
| HTML templates | ~4,923 | 4 |
|
||||||
|
| Python (FreeCAD) | ~2,499 | 7 |
|
||||||
|
| SQL migrations | ~464 | 8 |
|
||||||
|
| **Total** | **~14,730** | **39** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
cmd/
|
||||||
|
silo/ CLI client (313 lines)
|
||||||
|
silod/ API server (126 lines)
|
||||||
|
internal/
|
||||||
|
api/ HTTP handlers, routes, middleware, templates (3,491 Go + 4,923 HTML)
|
||||||
|
config/ YAML config loading (132 lines)
|
||||||
|
db/ PostgreSQL repositories (1,634 lines)
|
||||||
|
migration/ Property migration framework (211 lines)
|
||||||
|
odoo/ Odoo ERP integration stubs (201 lines)
|
||||||
|
partnum/ Part number generator (180 lines)
|
||||||
|
schema/ YAML schema parser (235 lines)
|
||||||
|
storage/ MinIO S3 client (121 lines)
|
||||||
|
pkg/freecad/ FreeCAD workbench plugin (2,499 Python)
|
||||||
|
migrations/ Database DDL (8 files)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Dependencies
|
||||||
|
|
||||||
|
- `go-chi/chi/v5` -- HTTP router
|
||||||
|
- `jackc/pgx/v5` -- PostgreSQL driver
|
||||||
|
- `minio/minio-go/v7` -- S3-compatible storage
|
||||||
|
- `rs/zerolog` -- Structured logging
|
||||||
|
- `sahilm/fuzzy` -- Fuzzy text matching
|
||||||
|
- `gopkg.in/yaml.v3` -- YAML parsing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Surface (38 Routes)
|
||||||
|
|
||||||
|
### Web UI
|
||||||
|
| Method | Path | Handler |
|
||||||
|
|--------|------|---------|
|
||||||
|
| GET | `/` | Items page |
|
||||||
|
| GET | `/projects` | Projects page |
|
||||||
|
| GET | `/schemas` | Schemas page |
|
||||||
|
|
||||||
|
### Items
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/api/items` | List with filtering and pagination |
|
||||||
|
| POST | `/api/items` | Create item |
|
||||||
|
| GET | `/api/items/search` | Fuzzy search |
|
||||||
|
| GET | `/api/items/export.csv` | CSV export |
|
||||||
|
| POST | `/api/items/import` | CSV import |
|
||||||
|
| GET | `/api/items/template.csv` | CSV template |
|
||||||
|
| GET | `/api/items/{partNumber}` | Get item |
|
||||||
|
| PUT | `/api/items/{partNumber}` | Update item |
|
||||||
|
| DELETE | `/api/items/{partNumber}` | Archive item |
|
||||||
|
|
||||||
|
### Revisions
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/api/items/{pn}/revisions` | List revisions |
|
||||||
|
| POST | `/api/items/{pn}/revisions` | Create revision |
|
||||||
|
| GET | `/api/items/{pn}/revisions/compare` | Compare two revisions |
|
||||||
|
| GET | `/api/items/{pn}/revisions/{rev}` | Get revision |
|
||||||
|
| PATCH | `/api/items/{pn}/revisions/{rev}` | Update status/label |
|
||||||
|
| POST | `/api/items/{pn}/revisions/{rev}/rollback` | Rollback |
|
||||||
|
|
||||||
|
### Files
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| POST | `/api/items/{pn}/file` | Upload file |
|
||||||
|
| GET | `/api/items/{pn}/file` | Download latest |
|
||||||
|
| GET | `/api/items/{pn}/file/{rev}` | Download at revision |
|
||||||
|
|
||||||
|
### BOM
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/api/items/{pn}/bom` | List children |
|
||||||
|
| POST | `/api/items/{pn}/bom` | Add child |
|
||||||
|
| GET | `/api/items/{pn}/bom/expanded` | Multi-level BOM |
|
||||||
|
| GET | `/api/items/{pn}/bom/where-used` | Where-used lookup |
|
||||||
|
| GET | `/api/items/{pn}/bom/export.csv` | BOM CSV export |
|
||||||
|
| POST | `/api/items/{pn}/bom/import` | BOM CSV import |
|
||||||
|
| PUT | `/api/items/{pn}/bom/{child}` | Update quantity/ref |
|
||||||
|
| DELETE | `/api/items/{pn}/bom/{child}` | Remove child |
|
||||||
|
|
||||||
|
### Projects
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/api/projects` | List projects |
|
||||||
|
| POST | `/api/projects` | Create project |
|
||||||
|
| GET | `/api/projects/{code}` | Get project |
|
||||||
|
| PUT | `/api/projects/{code}` | Update project |
|
||||||
|
| DELETE | `/api/projects/{code}` | Delete project |
|
||||||
|
| GET | `/api/projects/{code}/items` | Items in project |
|
||||||
|
|
||||||
|
### Item-Project Associations
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/api/items/{pn}/projects` | Item's projects |
|
||||||
|
| POST | `/api/items/{pn}/projects` | Tag item to project |
|
||||||
|
| DELETE | `/api/items/{pn}/projects/{code}` | Remove tag |
|
||||||
|
|
||||||
|
### Schemas
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/api/schemas` | List schemas |
|
||||||
|
| GET | `/api/schemas/{name}` | Get schema |
|
||||||
|
| GET | `/api/schemas/{name}/properties` | Property definitions |
|
||||||
|
| POST | `/api/schemas/{name}/segments/{seg}/values` | Add enum value |
|
||||||
|
| PUT | `/api/schemas/{name}/segments/{seg}/values/{code}` | Update enum value |
|
||||||
|
| DELETE | `/api/schemas/{name}/segments/{seg}/values/{code}` | Delete enum value |
|
||||||
|
|
||||||
|
### Odoo Integration (Stubs)
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/api/integrations/odoo/config` | Get config |
|
||||||
|
| PUT | `/api/integrations/odoo/config` | Update config |
|
||||||
|
| GET | `/api/integrations/odoo/sync-log` | Sync history |
|
||||||
|
| POST | `/api/integrations/odoo/test-connection` | Test connection |
|
||||||
|
| POST | `/api/integrations/odoo/sync/push/{pn}` | Push to Odoo |
|
||||||
|
| POST | `/api/integrations/odoo/sync/pull/{id}` | Pull from Odoo |
|
||||||
|
|
||||||
|
### Other
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/health` | Health probe |
|
||||||
|
| GET | `/ready` | Readiness probe (DB + MinIO) |
|
||||||
|
| POST | `/api/generate-part-number` | Generate next PN |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Migrations
|
||||||
|
|
||||||
|
| # | File | Purpose |
|
||||||
|
|---|------|---------|
|
||||||
|
| 1 | `001_initial.sql` | Core schema: items, revisions, relationships, locations, inventory, sequences, projects, schemas |
|
||||||
|
| 2 | `002_sequence_by_name.sql` | Sequence naming changes |
|
||||||
|
| 3 | `003_remove_material.sql` | Schema cleanup |
|
||||||
|
| 4 | `004_cad_sync_state.sql` | CAD synchronization tracking |
|
||||||
|
| 5 | `005_property_schema_version.sql` | Property versioning framework |
|
||||||
|
| 6 | `006_project_tags.sql` | Many-to-many project-item relationships |
|
||||||
|
| 7 | `007_revision_status.sql` | Revision status and labels |
|
||||||
|
| 8 | `008_odoo_integration.sql` | Integrations + sync_log tables |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Web UI Architecture
|
||||||
|
|
||||||
|
The web UI uses server-rendered Go templates with vanilla JavaScript (no framework).
|
||||||
|
|
||||||
|
### Items Page (`items.html`, 3718 lines)
|
||||||
|
|
||||||
|
Infor CloudSuite-style split-panel layout:
|
||||||
|
- **Horizontal mode** (default): item list on left, tabbed detail panel on right
|
||||||
|
- **Vertical mode**: tabbed detail panel on top, item list below
|
||||||
|
- **Detail tabs**: Main, Properties, Revisions, BOM, Where Used
|
||||||
|
- **Ctrl+F** opens in-page filter overlay with fuzzy search
|
||||||
|
- **Column config**: gear icon popover, separate settings per layout mode
|
||||||
|
- **Search scope**: All / Part Number / Description toggle pills
|
||||||
|
|
||||||
|
### Projects Page (`projects.html`, 345 lines)
|
||||||
|
- Full CRUD for project codes
|
||||||
|
- Item count per project
|
||||||
|
- Click project code to filter items page
|
||||||
|
|
||||||
|
### Schemas Page (`schemas.html`, 689 lines)
|
||||||
|
- Schema browsing and enum value management
|
||||||
|
|
||||||
|
### Base Template (`base.html`, 171 lines)
|
||||||
|
- 3-tab navigation: Items, Projects, Schemas
|
||||||
|
- Catppuccin Mocha dark theme
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature Stubs (Not Yet Implemented)
|
||||||
|
|
||||||
|
These are scaffolded but contain placeholder implementations.
|
||||||
|
|
||||||
|
### Odoo ERP Integration
|
||||||
|
|
||||||
|
All functions in `internal/odoo/` return "not yet implemented" errors:
|
||||||
|
|
||||||
|
| File | Function | Status |
|
||||||
|
|------|----------|--------|
|
||||||
|
| `client.go:30` | `Authenticate()` | Stub |
|
||||||
|
| `client.go:41` | `SearchRead()` | Stub |
|
||||||
|
| `client.go:51` | `Create()` | Stub |
|
||||||
|
| `client.go:60` | `Write()` | Stub |
|
||||||
|
| `client.go:70` | `TestConnection()` | Stub |
|
||||||
|
| `sync.go:27` | `PushItem()` | Stub (logs, returns nil) |
|
||||||
|
| `sync.go:36` | `PullProduct()` | Stub (logs, returns nil) |
|
||||||
|
|
||||||
|
API handlers at `odoo_handlers.go`:
|
||||||
|
- `HandleTestOdooConnection` (line 134) -- returns stub message
|
||||||
|
- `HandleOdooPush` (line 149) -- returns stub message
|
||||||
|
- `HandleOdooPull` (line 167) -- returns stub message
|
||||||
|
|
||||||
|
Config and sync-log CRUD handlers are functional.
|
||||||
|
|
||||||
|
### Part Number Date Segments
|
||||||
|
|
||||||
|
`internal/partnum/generator.go:102` -- `formatDate()` returns error. Date-based segments in schemas will fail at generation time.
|
||||||
|
|
||||||
|
### Location and Inventory APIs
|
||||||
|
|
||||||
|
Tables exist from migration 001 (`locations`, `inventory`) but no API handlers or repository methods are implemented. The database layer has no `LocationRepository` or `InventoryRepository`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Potential Issues
|
||||||
|
|
||||||
|
### Critical
|
||||||
|
|
||||||
|
1. **No authentication or authorization.** All API endpoints are publicly accessible. Single-user only. Adding LDAP/FreeIPA integration is required before multi-user deployment.
|
||||||
|
|
||||||
|
2. **No file locking.** Concurrent edits to the same item or file upload can cause data races. A pessimistic locking mechanism is needed for CAD file workflows.
|
||||||
|
|
||||||
|
3. **No unit tests.** Zero test coverage across the entire Go codebase. Regressions cannot be caught automatically.
|
||||||
|
|
||||||
|
### High
|
||||||
|
|
||||||
|
4. **Large monolithic template.** `items.html` is 3,718 lines with inline CSS and JavaScript. Changes risk unintended side effects. Consider extracting JavaScript into separate files or adopting a build step.
|
||||||
|
|
||||||
|
5. **No input validation middleware.** API handlers validate some fields inline but there is no systematic validation layer. Malformed requests may produce unclear errors or unexpected behavior.
|
||||||
|
|
||||||
|
6. **No rate limiting.** API has no request rate controls. A misbehaving client or script could overwhelm the server.
|
||||||
|
|
||||||
|
7. **Odoo handlers reference DB repositories not wired up.** `odoo_handlers.go` calls `s.db.IntegrationRepository()` but the `DB` struct in `db/db.go` does not expose an `IntegrationRepository` method. These handlers will panic if reached with a real database operation. Currently safe because config is stored in-memory and stubs short-circuit before DB calls.
|
||||||
|
|
||||||
|
### Medium
|
||||||
|
|
||||||
|
8. **No pagination on fuzzy search.** `HandleFuzzySearch` loads all items matching type/project filters into memory before fuzzy matching. Large datasets will cause high memory usage.
|
||||||
|
|
||||||
|
9. **CSV import lacks transaction rollback on partial failure.** If import fails mid-batch, already-imported items remain.
|
||||||
|
|
||||||
|
10. **No CSRF protection.** Web UI forms submit via `fetch()` but there are no CSRF tokens. Acceptable for single-user but a risk if authentication is added.
|
||||||
|
|
||||||
|
11. **MinIO connection not validated at startup.** The `/ready` endpoint checks MinIO, but the server starts regardless. A misconfigured MinIO will only fail on file operations.
|
||||||
|
|
||||||
|
12. **Property migration framework exists but has no registered migrations.** `internal/migration/properties.go` defines the framework but no concrete migrations use it yet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recent Git History
|
||||||
|
|
||||||
|
```
|
||||||
|
8e44ed2 2026-01-29 Fix SIGSEGV: defer document open after dialog close
|
||||||
|
e2b3f12 2026-01-29 Fix API URL: only auto-append /api for bare hostnames
|
||||||
|
559f615 2026-01-29 Fix API URL handling and SSL certificate verification
|
||||||
|
f08ecc1 2026-01-29 feat(workbench): fix icon loading and add settings dialog
|
||||||
|
53b5edb 2026-01-29 update documentation and specs
|
||||||
|
5ee88a6 2026-01-26 update deploy.sh
|
||||||
|
93add05 2026-01-26 improve csv import handling
|
||||||
|
2d44b2a 2026-01-26 add free-ipa setup
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### Supported Methods
|
||||||
|
- **Docker Compose** -- `deployments/docker-compose.yaml` (dev), `docker-compose.prod.yaml` (prod)
|
||||||
|
- **systemd** -- `deployments/systemd/silod.service`
|
||||||
|
- **Manual** -- `go build ./cmd/silod && ./silod -config config.yaml`
|
||||||
|
|
||||||
|
### Infrastructure Requirements
|
||||||
|
- PostgreSQL 15+ with `pg_trgm` and `uuid-ossp` extensions
|
||||||
|
- MinIO or S3-compatible storage
|
||||||
|
- Go 1.23+ for building
|
||||||
1
go.mod
1
go.mod
@@ -29,6 +29,7 @@ require (
|
|||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
github.com/rs/xid v1.5.0 // indirect
|
github.com/rs/xid v1.5.0 // indirect
|
||||||
|
github.com/sahilm/fuzzy v0.1.1 // indirect
|
||||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||||
golang.org/x/crypto v0.16.0 // indirect
|
golang.org/x/crypto v0.16.0 // indirect
|
||||||
golang.org/x/net v0.19.0 // indirect
|
golang.org/x/net v0.19.0 // indirect
|
||||||
|
|||||||
2
go.sum
2
go.sum
@@ -57,6 +57,8 @@ github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
|
|||||||
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||||
github.com/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0=
|
github.com/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0=
|
||||||
github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
|
github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
|
||||||
|
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
|
||||||
|
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
||||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/kindredsystems/silo/internal/db"
|
"github.com/kindredsystems/silo/internal/db"
|
||||||
@@ -263,6 +264,52 @@ func (s *Server) HandleListItems(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, response)
|
writeJSON(w, http.StatusOK, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleFuzzySearch performs fuzzy search across items.
|
||||||
|
func (s *Server) HandleFuzzySearch(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
q := r.URL.Query().Get("q")
|
||||||
|
if q == "" {
|
||||||
|
writeJSON(w, http.StatusOK, []FuzzyResult{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldsParam := r.URL.Query().Get("fields")
|
||||||
|
var fields []string
|
||||||
|
if fieldsParam != "" {
|
||||||
|
fields = strings.Split(fieldsParam, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := 50
|
||||||
|
if l := r.URL.Query().Get("limit"); l != "" {
|
||||||
|
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
|
||||||
|
limit = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-filter by type and project via SQL (no search term)
|
||||||
|
opts := db.ListOptions{
|
||||||
|
ItemType: r.URL.Query().Get("type"),
|
||||||
|
Project: r.URL.Query().Get("project"),
|
||||||
|
Limit: 500, // reasonable upper bound for fuzzy matching
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := s.items.List(ctx, opts)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to list items for fuzzy search")
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to search items")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
responses := make([]ItemResponse, len(items))
|
||||||
|
for i, item := range items {
|
||||||
|
responses[i] = itemToResponse(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
results := FuzzySearch(q, responses, fields, limit)
|
||||||
|
writeJSON(w, http.StatusOK, results)
|
||||||
|
}
|
||||||
|
|
||||||
// HandleCreateItem creates a new item with generated part number.
|
// HandleCreateItem creates a new item with generated part number.
|
||||||
func (s *Server) HandleCreateItem(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) HandleCreateItem(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|||||||
185
internal/api/odoo_handlers.go
Normal file
185
internal/api/odoo_handlers.go
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/kindredsystems/silo/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OdooConfigResponse represents the Odoo configuration (API key masked).
|
||||||
|
type OdooConfigResponse struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Database string `json:"database"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
APIKey string `json:"api_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OdooConfigRequest represents a request to update Odoo configuration.
|
||||||
|
type OdooConfigRequest struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Database string `json:"database"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
APIKey string `json:"api_key,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleGetOdooConfig returns the current Odoo integration configuration.
|
||||||
|
func (s *Server) HandleGetOdooConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
repo := db.NewIntegrationRepository(s.db)
|
||||||
|
integration, err := repo.GetByName(ctx, "odoo")
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to get odoo config")
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get Odoo config")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if integration == nil {
|
||||||
|
writeJSON(w, http.StatusOK, OdooConfigResponse{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := OdooConfigResponse{
|
||||||
|
Enabled: integration.Enabled,
|
||||||
|
URL: getString(integration.Config, "url"),
|
||||||
|
Database: getString(integration.Config, "database"),
|
||||||
|
Username: getString(integration.Config, "username"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mask API key
|
||||||
|
apiKey := getString(integration.Config, "api_key")
|
||||||
|
if len(apiKey) > 4 {
|
||||||
|
resp.APIKey = strings.Repeat("*", len(apiKey)-4) + apiKey[len(apiKey)-4:]
|
||||||
|
} else if apiKey != "" {
|
||||||
|
resp.APIKey = "****"
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleUpdateOdooConfig updates the Odoo integration configuration.
|
||||||
|
func (s *Server) HandleUpdateOdooConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
var req OdooConfigRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := db.NewIntegrationRepository(s.db)
|
||||||
|
|
||||||
|
// If API key is masked or empty, preserve existing one
|
||||||
|
if req.APIKey == "" || strings.Contains(req.APIKey, "*") {
|
||||||
|
existing, err := repo.GetByName(ctx, "odoo")
|
||||||
|
if err == nil && existing != nil {
|
||||||
|
req.APIKey = getString(existing.Config, "api_key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config := map[string]any{
|
||||||
|
"url": req.URL,
|
||||||
|
"database": req.Database,
|
||||||
|
"username": req.Username,
|
||||||
|
"api_key": req.APIKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.Upsert(ctx, "odoo", req.Enabled, config); err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to update odoo config")
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update Odoo config")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleGetOdooSyncLog returns recent sync log entries for the Odoo integration.
|
||||||
|
func (s *Server) HandleGetOdooSyncLog(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
repo := db.NewIntegrationRepository(s.db)
|
||||||
|
integration, err := repo.GetByName(ctx, "odoo")
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to get odoo integration")
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get sync log")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if integration == nil {
|
||||||
|
writeJSON(w, http.StatusOK, []any{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logs, err := repo.ListSyncLog(ctx, integration.ID, 50)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to list sync log")
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list sync log")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if logs == nil {
|
||||||
|
logs = []*db.SyncLog{}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, logs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleTestOdooConnection tests the Odoo connection.
|
||||||
|
func (s *Server) HandleTestOdooConnection(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Stub - would use odoo.Client to test connection
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"status": "not_implemented",
|
||||||
|
"message": "Odoo connection testing is not yet implemented",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleOdooPush pushes an item to Odoo.
|
||||||
|
func (s *Server) HandleOdooPush(w http.ResponseWriter, r *http.Request) {
|
||||||
|
partNumber := chi.URLParam(r, "partNumber")
|
||||||
|
if partNumber == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "missing_param", "Part number is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stub - log the intent
|
||||||
|
s.logger.Info().Str("part_number", partNumber).Msg("odoo push requested (stub)")
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"status": "pending",
|
||||||
|
"message": "Odoo push is not yet implemented",
|
||||||
|
"part_number": partNumber,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleOdooPull pulls a product from Odoo.
|
||||||
|
func (s *Server) HandleOdooPull(w http.ResponseWriter, r *http.Request) {
|
||||||
|
odooID := chi.URLParam(r, "odooId")
|
||||||
|
if odooID == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "missing_param", "Odoo ID is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stub - log the intent
|
||||||
|
s.logger.Info().Str("odoo_id", odooID).Msg("odoo pull requested (stub)")
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"status": "pending",
|
||||||
|
"message": "Odoo pull is not yet implemented",
|
||||||
|
"odoo_id": odooID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// getString safely extracts a string from a map.
|
||||||
|
func getString(m map[string]any, key string) string {
|
||||||
|
if v, ok := m[key]; ok {
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
|
|||||||
|
|
||||||
// Web UI routes
|
// Web UI routes
|
||||||
r.Get("/", webHandler.HandleIndex)
|
r.Get("/", webHandler.HandleIndex)
|
||||||
|
r.Get("/projects", webHandler.HandleProjectsPage)
|
||||||
r.Get("/schemas", webHandler.HandleSchemasPage)
|
r.Get("/schemas", webHandler.HandleSchemasPage)
|
||||||
|
|
||||||
// API routes
|
// API routes
|
||||||
@@ -70,6 +71,7 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
|
|||||||
// Items
|
// Items
|
||||||
r.Route("/items", func(r chi.Router) {
|
r.Route("/items", func(r chi.Router) {
|
||||||
r.Get("/", server.HandleListItems)
|
r.Get("/", server.HandleListItems)
|
||||||
|
r.Get("/search", server.HandleFuzzySearch)
|
||||||
r.Post("/", server.HandleCreateItem)
|
r.Post("/", server.HandleCreateItem)
|
||||||
|
|
||||||
// CSV Import/Export
|
// CSV Import/Export
|
||||||
@@ -112,6 +114,16 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Integrations
|
||||||
|
r.Route("/integrations/odoo", func(r chi.Router) {
|
||||||
|
r.Get("/config", server.HandleGetOdooConfig)
|
||||||
|
r.Put("/config", server.HandleUpdateOdooConfig)
|
||||||
|
r.Get("/sync-log", server.HandleGetOdooSyncLog)
|
||||||
|
r.Post("/test-connection", server.HandleTestOdooConnection)
|
||||||
|
r.Post("/sync/push/{partNumber}", server.HandleOdooPush)
|
||||||
|
r.Post("/sync/pull/{odooId}", server.HandleOdooPull)
|
||||||
|
})
|
||||||
|
|
||||||
// Part number generation
|
// Part number generation
|
||||||
r.Post("/generate-part-number", server.HandleGeneratePartNumber)
|
r.Post("/generate-part-number", server.HandleGeneratePartNumber)
|
||||||
})
|
})
|
||||||
|
|||||||
72
internal/api/search.go
Normal file
72
internal/api/search.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sahilm/fuzzy"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SearchableItems implements the fuzzy.Source interface for item search.
|
||||||
|
type SearchableItems struct {
|
||||||
|
items []ItemResponse
|
||||||
|
fields []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns the searchable text for item at index i.
|
||||||
|
func (s SearchableItems) String(i int) string {
|
||||||
|
item := s.items[i]
|
||||||
|
var parts []string
|
||||||
|
|
||||||
|
for _, field := range s.fields {
|
||||||
|
switch field {
|
||||||
|
case "part_number":
|
||||||
|
parts = append(parts, item.PartNumber)
|
||||||
|
case "description":
|
||||||
|
parts = append(parts, item.Description)
|
||||||
|
case "item_type":
|
||||||
|
parts = append(parts, item.ItemType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parts) == 0 {
|
||||||
|
// Default: search all text fields
|
||||||
|
parts = append(parts, item.PartNumber, item.Description, item.ItemType)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Len returns the number of items.
|
||||||
|
func (s SearchableItems) Len() int {
|
||||||
|
return len(s.items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FuzzyResult wraps an ItemResponse with match score.
|
||||||
|
type FuzzyResult struct {
|
||||||
|
ItemResponse
|
||||||
|
Score int `json:"score"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FuzzySearch runs fuzzy matching on items and returns ranked results.
|
||||||
|
func FuzzySearch(pattern string, items []ItemResponse, fields []string, limit int) []FuzzyResult {
|
||||||
|
if pattern == "" || len(items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
source := SearchableItems{items: items, fields: fields}
|
||||||
|
matches := fuzzy.FindFrom(pattern, source)
|
||||||
|
|
||||||
|
results := make([]FuzzyResult, 0, len(matches))
|
||||||
|
for _, m := range matches {
|
||||||
|
results = append(results, FuzzyResult{
|
||||||
|
ItemResponse: items[m.Index],
|
||||||
|
Score: m.Score,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if limit > 0 && len(results) > limit {
|
||||||
|
results = results[:limit]
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
@@ -480,6 +480,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<nav class="header-nav">
|
<nav class="header-nav">
|
||||||
<a href="/" class="{{if eq .Page "items"}}active{{end}}">Items</a>
|
<a href="/" class="{{if eq .Page "items"}}active{{end}}">Items</a>
|
||||||
|
<a href="/projects" class="{{if eq .Page "projects"}}active{{end}}">Projects</a>
|
||||||
<a href="/schemas" class="{{if eq .Page "schemas"}}active{{end}}">Schemas</a>
|
<a href="/schemas" class="{{if eq .Page "schemas"}}active{{end}}">Schemas</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
@@ -487,6 +488,8 @@
|
|||||||
<main class="main">
|
<main class="main">
|
||||||
{{if eq .Page "items"}}
|
{{if eq .Page "items"}}
|
||||||
{{template "items_content" .}}
|
{{template "items_content" .}}
|
||||||
|
{{else if eq .Page "projects"}}
|
||||||
|
{{template "projects_content" .}}
|
||||||
{{else if eq .Page "schemas"}}
|
{{else if eq .Page "schemas"}}
|
||||||
{{template "schemas_content" .}}
|
{{template "schemas_content" .}}
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -494,6 +497,8 @@
|
|||||||
|
|
||||||
{{if eq .Page "items"}}
|
{{if eq .Page "items"}}
|
||||||
{{template "items_scripts" .}}
|
{{template "items_scripts" .}}
|
||||||
|
{{else if eq .Page "projects"}}
|
||||||
|
{{template "projects_scripts" .}}
|
||||||
{{else if eq .Page "schemas"}}
|
{{else if eq .Page "schemas"}}
|
||||||
{{template "schemas_scripts" .}}
|
{{template "schemas_scripts" .}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
300
internal/api/templates/projects.html
Normal file
300
internal/api/templates/projects.html
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
{{define "projects_content"}}
|
||||||
|
<div class="stats-grid" id="project-stats">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value" id="project-count">-</div>
|
||||||
|
<div class="stat-label">Total Projects</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="card-title">Projects</h2>
|
||||||
|
<button class="btn btn-primary" onclick="openCreateProjectModal()">+ New Project</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Code</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Items</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="projects-table">
|
||||||
|
<tr>
|
||||||
|
<td colspan="6">
|
||||||
|
<div class="loading"><div class="spinner"></div></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Project Modal -->
|
||||||
|
<div class="modal-overlay" id="create-project-modal">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 class="modal-title">Create New Project</h3>
|
||||||
|
<button class="modal-close" onclick="closeCreateProjectModal()">×</button>
|
||||||
|
</div>
|
||||||
|
<form id="create-project-form" onsubmit="createProject(event)">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Code (2-10 characters, uppercase)</label>
|
||||||
|
<input type="text" class="form-input" id="project-code" required
|
||||||
|
minlength="2" maxlength="10" pattern="[A-Za-z0-9\-]+"
|
||||||
|
placeholder="e.g., PROJ-A" style="text-transform: uppercase;" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Name</label>
|
||||||
|
<input type="text" class="form-input" id="project-name" placeholder="Project name" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Description</label>
|
||||||
|
<input type="text" class="form-input" id="project-description" placeholder="Project description" />
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="closeCreateProjectModal()">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Create Project</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Project Modal -->
|
||||||
|
<div class="modal-overlay" id="edit-project-modal">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 class="modal-title">Edit Project</h3>
|
||||||
|
<button class="modal-close" onclick="closeEditProjectModal()">×</button>
|
||||||
|
</div>
|
||||||
|
<form id="edit-project-form" onsubmit="saveProject(event)">
|
||||||
|
<input type="hidden" id="edit-project-code" />
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Code</label>
|
||||||
|
<input type="text" class="form-input" id="edit-project-code-display" disabled />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Name</label>
|
||||||
|
<input type="text" class="form-input" id="edit-project-name" placeholder="Project name" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Description</label>
|
||||||
|
<input type="text" class="form-input" id="edit-project-description" placeholder="Project description" />
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="closeEditProjectModal()">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Project Modal -->
|
||||||
|
<div class="modal-overlay" id="delete-project-modal">
|
||||||
|
<div class="modal" style="max-width: 400px">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 class="modal-title">Delete Project</h3>
|
||||||
|
<button class="modal-close" onclick="closeDeleteProjectModal()">×</button>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom: 1.5rem;">
|
||||||
|
<p>Are you sure you want to permanently delete project <strong id="delete-project-code"></strong>?</p>
|
||||||
|
<p style="color: var(--ctp-red); margin-top: 0.5rem;">This action cannot be undone.</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="closeDeleteProjectModal()">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" style="background-color: var(--ctp-red)" onclick="confirmDeleteProject()">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}} {{define "projects_scripts"}}
|
||||||
|
<script>
|
||||||
|
let projectToDelete = null;
|
||||||
|
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProjects() {
|
||||||
|
const tbody = document.getElementById('projects-table');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/projects');
|
||||||
|
const projects = await response.json();
|
||||||
|
|
||||||
|
document.getElementById('project-count').textContent = projects.length;
|
||||||
|
|
||||||
|
if (!projects || projects.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6"><div class="empty-state"><h3>No projects found</h3><p>Create your first project to start organizing items.</p></div></td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch item counts for each project
|
||||||
|
const projectsWithCounts = await Promise.all(projects.map(async (project) => {
|
||||||
|
try {
|
||||||
|
const itemsRes = await fetch(`/api/projects/${project.code}/items`);
|
||||||
|
const items = await itemsRes.json();
|
||||||
|
return { ...project, itemCount: Array.isArray(items) ? items.length : 0 };
|
||||||
|
} catch {
|
||||||
|
return { ...project, itemCount: 0 };
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
tbody.innerHTML = projectsWithCounts.map(project => `
|
||||||
|
<tr>
|
||||||
|
<td><a href="/?project=${encodeURIComponent(project.code)}" style="color: var(--ctp-peach); font-family: 'JetBrains Mono', monospace; font-weight: 500;">${project.code}</a></td>
|
||||||
|
<td>${project.name || '-'}</td>
|
||||||
|
<td>${project.description || '-'}</td>
|
||||||
|
<td>${project.itemCount}</td>
|
||||||
|
<td>${formatDate(project.created_at)}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-secondary" style="padding: 0.4rem 0.75rem; font-size: 0.85rem;" onclick="openEditProjectModal('${project.code}')">Edit</button>
|
||||||
|
<button class="btn btn-secondary" style="padding: 0.4rem 0.75rem; font-size: 0.85rem; background-color: var(--ctp-surface2);" onclick="openDeleteProjectModal('${project.code}')">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
} catch (error) {
|
||||||
|
tbody.innerHTML = `<tr><td colspan="6"><div class="empty-state"><h3>Error loading projects</h3><p>${error.message}</p></div></td></tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create
|
||||||
|
function openCreateProjectModal() {
|
||||||
|
document.getElementById('create-project-modal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCreateProjectModal() {
|
||||||
|
document.getElementById('create-project-modal').classList.remove('active');
|
||||||
|
document.getElementById('create-project-form').reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createProject(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
code: document.getElementById('project-code').value.toUpperCase(),
|
||||||
|
name: document.getElementById('project-name').value,
|
||||||
|
description: document.getElementById('project-description').value,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/projects', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(`Error: ${error.message || error.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeCreateProjectModal();
|
||||||
|
loadProjects();
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Error: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit
|
||||||
|
async function openEditProjectModal(code) {
|
||||||
|
document.getElementById('edit-project-modal').classList.add('active');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/${code}`);
|
||||||
|
const project = await response.json();
|
||||||
|
|
||||||
|
document.getElementById('edit-project-code').value = code;
|
||||||
|
document.getElementById('edit-project-code-display').value = code;
|
||||||
|
document.getElementById('edit-project-name').value = project.name || '';
|
||||||
|
document.getElementById('edit-project-description').value = project.description || '';
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Error loading project: ${error.message}`);
|
||||||
|
closeEditProjectModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditProjectModal() {
|
||||||
|
document.getElementById('edit-project-modal').classList.remove('active');
|
||||||
|
document.getElementById('edit-project-form').reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveProject(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const code = document.getElementById('edit-project-code').value;
|
||||||
|
const data = {
|
||||||
|
name: document.getElementById('edit-project-name').value,
|
||||||
|
description: document.getElementById('edit-project-description').value,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/${code}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(`Error: ${error.message || error.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeEditProjectModal();
|
||||||
|
loadProjects();
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Error: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
function openDeleteProjectModal(code) {
|
||||||
|
projectToDelete = code;
|
||||||
|
document.getElementById('delete-project-code').textContent = code;
|
||||||
|
document.getElementById('delete-project-modal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDeleteProjectModal() {
|
||||||
|
document.getElementById('delete-project-modal').classList.remove('active');
|
||||||
|
projectToDelete = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDeleteProject() {
|
||||||
|
if (!projectToDelete) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/${projectToDelete}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok && response.status !== 204) {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(`Error: ${error.message || error.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeDeleteProjectModal();
|
||||||
|
loadProjects();
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Error: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modals on overlay click
|
||||||
|
document.querySelectorAll('.modal-overlay').forEach(overlay => {
|
||||||
|
overlay.addEventListener('click', (e) => {
|
||||||
|
if (e.target === overlay) overlay.classList.remove('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
loadProjects();
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
@@ -77,6 +77,20 @@ func (h *WebHandler) HandleIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleProjectsPage serves the projects page.
|
||||||
|
func (h *WebHandler) HandleProjectsPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data := PageData{
|
||||||
|
Title: "Projects",
|
||||||
|
Page: "projects",
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := h.templates.ExecuteTemplate(w, "base.html", data); err != nil {
|
||||||
|
h.logger.Error().Err(err).Msg("failed to render template")
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// HandleSchemasPage serves the schemas page.
|
// HandleSchemasPage serves the schemas page.
|
||||||
func (h *WebHandler) HandleSchemasPage(w http.ResponseWriter, r *http.Request) {
|
func (h *WebHandler) HandleSchemasPage(w http.ResponseWriter, r *http.Request) {
|
||||||
data := PageData{
|
data := PageData{
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type Config struct {
|
|||||||
Storage StorageConfig `yaml:"storage"`
|
Storage StorageConfig `yaml:"storage"`
|
||||||
Schemas SchemasConfig `yaml:"schemas"`
|
Schemas SchemasConfig `yaml:"schemas"`
|
||||||
FreeCAD FreeCADConfig `yaml:"freecad"`
|
FreeCAD FreeCADConfig `yaml:"freecad"`
|
||||||
|
Odoo OdooConfig `yaml:"odoo"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServerConfig holds HTTP server settings.
|
// ServerConfig holds HTTP server settings.
|
||||||
@@ -57,6 +58,15 @@ type FreeCADConfig struct {
|
|||||||
Executable string `yaml:"executable"`
|
Executable string `yaml:"executable"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OdooConfig holds Odoo ERP integration settings.
|
||||||
|
type OdooConfig struct {
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
URL string `yaml:"url"`
|
||||||
|
Database string `yaml:"database"`
|
||||||
|
Username string `yaml:"username"`
|
||||||
|
APIKey string `yaml:"api_key"`
|
||||||
|
}
|
||||||
|
|
||||||
// Load reads configuration from a YAML file.
|
// Load reads configuration from a YAML file.
|
||||||
func Load(path string) (*Config, error) {
|
func Load(path string) (*Config, error) {
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
|
|||||||
139
internal/db/integrations.go
Normal file
139
internal/db/integrations.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Integration represents an ERP integration configuration.
|
||||||
|
type Integration struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Enabled bool
|
||||||
|
Config map[string]any
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncLog represents a sync log entry.
|
||||||
|
type SyncLog struct {
|
||||||
|
ID string
|
||||||
|
IntegrationID string
|
||||||
|
ItemID *string
|
||||||
|
Direction string
|
||||||
|
Status string
|
||||||
|
ExternalID string
|
||||||
|
ExternalModel string
|
||||||
|
RequestPayload json.RawMessage
|
||||||
|
ResponsePayload json.RawMessage
|
||||||
|
ErrorMessage string
|
||||||
|
StartedAt *time.Time
|
||||||
|
CompletedAt *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// IntegrationRepository provides integration database operations.
|
||||||
|
type IntegrationRepository struct {
|
||||||
|
db *DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewIntegrationRepository creates a new integration repository.
|
||||||
|
func NewIntegrationRepository(db *DB) *IntegrationRepository {
|
||||||
|
return &IntegrationRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByName returns an integration by name.
|
||||||
|
func (r *IntegrationRepository) GetByName(ctx context.Context, name string) (*Integration, error) {
|
||||||
|
row := r.db.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, name, enabled, config, created_at, updated_at
|
||||||
|
FROM integrations
|
||||||
|
WHERE name = $1
|
||||||
|
`, name)
|
||||||
|
|
||||||
|
var i Integration
|
||||||
|
var configJSON []byte
|
||||||
|
err := row.Scan(&i.ID, &i.Name, &i.Enabled, &configJSON, &i.CreatedAt, &i.UpdatedAt)
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(configJSON) > 0 {
|
||||||
|
if err := json.Unmarshal(configJSON, &i.Config); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &i, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert creates or updates an integration by name.
|
||||||
|
func (r *IntegrationRepository) Upsert(ctx context.Context, name string, enabled bool, config map[string]any) error {
|
||||||
|
configJSON, err := json.Marshal(config)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = r.db.pool.Exec(ctx, `
|
||||||
|
INSERT INTO integrations (name, enabled, config)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (name) DO UPDATE SET
|
||||||
|
enabled = EXCLUDED.enabled,
|
||||||
|
config = EXCLUDED.config,
|
||||||
|
updated_at = now()
|
||||||
|
`, name, enabled, configJSON)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSyncLog inserts a new sync log entry.
|
||||||
|
func (r *IntegrationRepository) CreateSyncLog(ctx context.Context, entry *SyncLog) error {
|
||||||
|
_, err := r.db.pool.Exec(ctx, `
|
||||||
|
INSERT INTO sync_log (integration_id, item_id, direction, status, external_id, external_model, request_payload, response_payload, error_message, started_at, completed_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
|
`, entry.IntegrationID, entry.ItemID, entry.Direction, entry.Status,
|
||||||
|
entry.ExternalID, entry.ExternalModel, entry.RequestPayload, entry.ResponsePayload,
|
||||||
|
entry.ErrorMessage, entry.StartedAt, entry.CompletedAt)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSyncLog returns recent sync log entries for an integration.
|
||||||
|
func (r *IntegrationRepository) ListSyncLog(ctx context.Context, integrationID string, limit int) ([]*SyncLog, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := r.db.pool.Query(ctx, `
|
||||||
|
SELECT id, integration_id, item_id, direction, status,
|
||||||
|
external_id, external_model, request_payload, response_payload,
|
||||||
|
error_message, started_at, completed_at, created_at
|
||||||
|
FROM sync_log
|
||||||
|
WHERE integration_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $2
|
||||||
|
`, integrationID, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var logs []*SyncLog
|
||||||
|
for rows.Next() {
|
||||||
|
var l SyncLog
|
||||||
|
err := rows.Scan(
|
||||||
|
&l.ID, &l.IntegrationID, &l.ItemID, &l.Direction, &l.Status,
|
||||||
|
&l.ExternalID, &l.ExternalModel, &l.RequestPayload, &l.ResponsePayload,
|
||||||
|
&l.ErrorMessage, &l.StartedAt, &l.CompletedAt, &l.CreatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
logs = append(logs, &l)
|
||||||
|
}
|
||||||
|
|
||||||
|
return logs, rows.Err()
|
||||||
|
}
|
||||||
75
internal/odoo/client.go
Normal file
75
internal/odoo/client.go
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
package odoo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client provides access to the Odoo JSON-RPC API.
|
||||||
|
type Client struct {
|
||||||
|
config Config
|
||||||
|
http *http.Client
|
||||||
|
logger zerolog.Logger
|
||||||
|
uid int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient creates a new Odoo API client.
|
||||||
|
func NewClient(cfg Config, logger zerolog.Logger) *Client {
|
||||||
|
return &Client{
|
||||||
|
config: cfg,
|
||||||
|
http: &http.Client{},
|
||||||
|
logger: logger.With().Str("component", "odoo-client").Logger(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate authenticates with the Odoo server and stores the user ID.
|
||||||
|
func (c *Client) Authenticate(_ context.Context) error {
|
||||||
|
// TODO: Implement JSON-RPC call to /web/session/authenticate
|
||||||
|
c.logger.Info().
|
||||||
|
Str("url", c.config.URL).
|
||||||
|
Str("database", c.config.Database).
|
||||||
|
Str("username", c.config.Username).
|
||||||
|
Msg("odoo authenticate stub called")
|
||||||
|
return fmt.Errorf("odoo authentication not yet implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchRead queries records from an Odoo model.
|
||||||
|
func (c *Client) SearchRead(_ context.Context, model string, domain []any, fields []string, limit int) ([]map[string]any, error) {
|
||||||
|
// TODO: Implement JSON-RPC call to /web/dataset/call_kw
|
||||||
|
c.logger.Info().
|
||||||
|
Str("model", model).
|
||||||
|
Int("limit", limit).
|
||||||
|
Msg("odoo search_read stub called")
|
||||||
|
return nil, fmt.Errorf("odoo search_read not yet implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create creates a new record in an Odoo model.
|
||||||
|
func (c *Client) Create(_ context.Context, model string, values map[string]any) (int, error) {
|
||||||
|
// TODO: Implement JSON-RPC call to create
|
||||||
|
c.logger.Info().
|
||||||
|
Str("model", model).
|
||||||
|
Msg("odoo create stub called")
|
||||||
|
return 0, fmt.Errorf("odoo create not yet implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write updates an existing record in an Odoo model.
|
||||||
|
func (c *Client) Write(_ context.Context, model string, id int, values map[string]any) error {
|
||||||
|
// TODO: Implement JSON-RPC call to write
|
||||||
|
c.logger.Info().
|
||||||
|
Str("model", model).
|
||||||
|
Int("id", id).
|
||||||
|
Msg("odoo write stub called")
|
||||||
|
return fmt.Errorf("odoo write not yet implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConnection verifies the Odoo connection is working.
|
||||||
|
func (c *Client) TestConnection(_ context.Context) error {
|
||||||
|
// TODO: Attempt authentication and return result
|
||||||
|
c.logger.Info().
|
||||||
|
Str("url", c.config.URL).
|
||||||
|
Msg("odoo test_connection stub called")
|
||||||
|
return fmt.Errorf("odoo test connection not yet implemented")
|
||||||
|
}
|
||||||
39
internal/odoo/sync.go
Normal file
39
internal/odoo/sync.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package odoo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Syncer orchestrates sync operations between Silo and Odoo.
|
||||||
|
type Syncer struct {
|
||||||
|
client *Client
|
||||||
|
logger zerolog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSyncer creates a new sync orchestrator.
|
||||||
|
func NewSyncer(client *Client, logger zerolog.Logger) *Syncer {
|
||||||
|
return &Syncer{
|
||||||
|
client: client,
|
||||||
|
logger: logger.With().Str("component", "odoo-syncer").Logger(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushItem pushes a Silo item to Odoo as a product.
|
||||||
|
func (s *Syncer) PushItem(_ context.Context, partNumber string) error {
|
||||||
|
s.logger.Info().
|
||||||
|
Str("part_number", partNumber).
|
||||||
|
Msg("push item to odoo stub called")
|
||||||
|
// TODO: Fetch item from DB, map fields, call client.Create or client.Write
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PullProduct pulls an Odoo product into Silo as an item.
|
||||||
|
func (s *Syncer) PullProduct(_ context.Context, odooID int) error {
|
||||||
|
s.logger.Info().
|
||||||
|
Int("odoo_id", odooID).
|
||||||
|
Msg("pull product from odoo stub called")
|
||||||
|
// TODO: Call client.SearchRead, map fields, create/update item in DB
|
||||||
|
return nil
|
||||||
|
}
|
||||||
87
internal/odoo/types.go
Normal file
87
internal/odoo/types.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package odoo
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Config holds Odoo connection configuration.
|
||||||
|
type Config struct {
|
||||||
|
URL string `json:"url" yaml:"url"`
|
||||||
|
Database string `json:"database" yaml:"database"`
|
||||||
|
Username string `json:"username" yaml:"username"`
|
||||||
|
APIKey string `json:"api_key" yaml:"api_key"`
|
||||||
|
FieldMap map[string]string `json:"field_map,omitempty" yaml:"field_map,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSONRPCRequest is the Odoo JSON-RPC request envelope.
|
||||||
|
type JSONRPCRequest struct {
|
||||||
|
JSONRPC string `json:"jsonrpc"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
ID int `json:"id"`
|
||||||
|
Params any `json:"params"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSONRPCResponse is the Odoo JSON-RPC response envelope.
|
||||||
|
type JSONRPCResponse struct {
|
||||||
|
JSONRPC string `json:"jsonrpc"`
|
||||||
|
ID int `json:"id"`
|
||||||
|
Result any `json:"result,omitempty"`
|
||||||
|
Error *struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data any `json:"data,omitempty"`
|
||||||
|
} `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProductProduct maps to the Odoo product.product model.
|
||||||
|
type ProductProduct struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
DefaultCode string `json:"default_code"` // maps to part_number
|
||||||
|
Name string `json:"name"` // maps to description
|
||||||
|
Type string `json:"type"`
|
||||||
|
ListPrice float64 `json:"list_price"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProductTemplate maps to the Odoo product.template model.
|
||||||
|
type ProductTemplate struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
DefaultCode string `json:"default_code"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
ListPrice float64 `json:"list_price"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncDirection indicates the direction of a sync operation.
|
||||||
|
type SyncDirection string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SyncPush SyncDirection = "push"
|
||||||
|
SyncPull SyncDirection = "pull"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SyncStatus indicates the status of a sync operation.
|
||||||
|
type SyncStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SyncPending SyncStatus = "pending"
|
||||||
|
SyncRunning SyncStatus = "running"
|
||||||
|
SyncCompleted SyncStatus = "completed"
|
||||||
|
SyncFailed SyncStatus = "failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SyncLogEntry represents a single sync operation record.
|
||||||
|
type SyncLogEntry struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
IntegrationID string `json:"integration_id"`
|
||||||
|
ItemID *string `json:"item_id,omitempty"`
|
||||||
|
Direction SyncDirection `json:"direction"`
|
||||||
|
Status SyncStatus `json:"status"`
|
||||||
|
ExternalID string `json:"external_id,omitempty"`
|
||||||
|
ExternalModel string `json:"external_model,omitempty"`
|
||||||
|
RequestPayload any `json:"request_payload,omitempty"`
|
||||||
|
ResponsePayload any `json:"response_payload,omitempty"`
|
||||||
|
ErrorMessage string `json:"error_message,omitempty"`
|
||||||
|
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||||
|
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
30
migrations/008_odoo_integration.sql
Normal file
30
migrations/008_odoo_integration.sql
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
-- Integration configuration and sync logging for ERP connections (Odoo, etc.)
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS integrations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
name TEXT UNIQUE NOT NULL,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
config JSONB NOT NULL DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sync_log (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
integration_id UUID NOT NULL REFERENCES integrations(id) ON DELETE CASCADE,
|
||||||
|
item_id UUID REFERENCES items(id) ON DELETE SET NULL,
|
||||||
|
direction TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
external_id TEXT,
|
||||||
|
external_model TEXT,
|
||||||
|
request_payload JSONB,
|
||||||
|
response_payload JSONB,
|
||||||
|
error_message TEXT,
|
||||||
|
started_at TIMESTAMPTZ,
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sync_log_integration ON sync_log(integration_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sync_log_item ON sync_log(item_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sync_log_status ON sync_log(status);
|
||||||
Reference in New Issue
Block a user