- Remove Ollama as AI backend (never used); Mistral is primary throughout
- Add infrastructure.md: full Docker setup, queue architecture, prod
deployment steps, env vars — prod has no staging ERP container
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Introduces a proper key-value table (article_type_ebay_mappings) that
explicitly maps each eBay aspect name to either an Article field
(manufacturer, modelNumber, …) or an AttributeDefinition, with a
required flag per mapping entry.
- New entity ArticleTypeEbayMapping with SOURCE_ARTICLE_FIELD / SOURCE_ATTRIBUTE
- ArticleType gains OneToMany ebayMappings collection with upsertEbayMapping()
- EbayAdapter.buildAspects() reads from the mapping table instead of implicit name-matching
- Import controller persists mappings via upsertEbayMapping() and syncs required attribute assignments
- Template shows active mappings card and article_field action option
- Migration 20260520100000 creates the new table, drops old JSON column
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Doctrine sets nullable JSON columns to null when the DB value is NULL.
Typed array property cannot hold null — changing to ?array and coercing
to [] in the getter.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds articleTypeName as a mappable article field — Article.getArticleTypeName()
proxies to articleType.getName(). 'Produktart' auto-detects via alias, so the
import UI pre-selects this mapping and the eBay listing gets the type name as
the Produktart aspect value automatically.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds an 'Artikelfeld' action in the aspect import UI alongside skip/match/create.
Aspects like 'Marke' and 'Herstellernummer' auto-detect to manufacturer/modelNumber
via ARTICLE_FIELD_ALIASES. Mappings are persisted as a JSON column on ArticleType.
EbayAdapter.buildAspects() now reads these mappings and populates them from the
article's direct fields when building eBay listing aspects.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Plain redirectToRoute() bypasses EasyAdmin's AdminRouterSubscriber, so
the admin context (ea.i18n etc.) was never set and the EasyAdmin layout
threw "Impossible to access i18n on null". Using AdminUrlGenerator wraps
the redirect URL in the EasyAdmin routing layer, keeping context alive.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds live category search so users don't need to know eBay category IDs.
Typing in the search box hits /admin/ebay/category-search, shows name +
breadcrumb path, and auto-saves the selection to the ArticleType on pick.
Aspect import table now only renders after a category is set.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ArticleType gains ebayCategoryId (migration 20260520080000).
New admin action "Import eBay Aspects" on ArticleType list and detail:
- Fetches aspects via EbayTaxonomyService (cached 7d)
- Sorts: Required → Recommended → Optional
- Auto-matches by case-insensitive name to existing AttributeDefinitions
- Pre-selects "Create new" for required/recommended with no match
- Pre-selects "Skip" for optional with no match
- Already-assigned definitions highlighted green
- Per-row: override to Skip / Match existing / Create new
- Type auto-detected: Select (≤30 eBay values) or String (freetext)
- User can override type in create form
- Required checkbox pre-checked for eBay-required aspects
- "All → Create" / "All → Skip" bulk buttons
- On submit: creates new AttributeDefinitions, links all to ArticleType,
deduplicates, calls applyAttributeAssignments(), flushes
PHPStan level 9 clean throughout.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add sandbox credentials to .env.test and .env.local (sandbox URLs).
Pass EBAY_* vars through bin/test-integration.
EbayTaxonomyIntegrationTest: 6 tests against sandbox Taxonomy API using
app token (client_credentials) — verifies OAuth, aspects for notebooks
(cat 177) and RAM (cat 170083), required flags, value lists, caching.
EbayAdapterIntegrationTest: listing publish/update/deactivate tests skip
gracefully when EBAY_USER_TOKEN not set (Inventory API requires
Authorization Code user token). Noop-deactivate test always runs.
All 6 taxonomy tests pass against live sandbox.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
OllamaVisionAgent.extractField() now handles field labels at the start
of a value (e.g. MODEL_NUMBER: "SERIAL: 1005NK677594" -> "") not just
mid-value bleed. Both agent test files updated to mock
PromptTemplateRepositoryInterface and construct a real
PromptTemplateService, since the service is final and unmockable.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Frappe ERP:
- findExistingCustomer() on FrappeErpAdapter — two-step name+address lookup
- FrappeHttpClient: add put() method; switch invoice submit to PUT docstatus=1 (Frappe v16)
- buildItemDescription() uses specsText + inventory number + serial number
- Integration tests: find Simon Kühn, create real 1337€ invoice, cancel+delete in tearDown
- FRAPPE_GENERIC_ITEM_CODE=SKU002 added to .env.local and bin/test-integration
Pipeline — model cache:
- PhotoUploadHandler: after vision, check DB for existing article with same modelNumber
- On match: copy ebayTitle/ebayDescription/specsText/attributes, skip specs+JSON+eBay steps
- DraftArticleHandler: apply model_match data and mark job complete directly
- ArticleRepository: findCompletedByModelNumber() query
Pipeline — specs by article type:
- SpecsResearchAgent: accept attributeFields list, format as bullet list in {{fields}} var
- SpecsResearchHandler: derive attribute names from ArticleType, pass to agent
- SpecsResearchMessage: add attributeFields param
- Prompt migration: replace hardcoded laptop spec list with {{fields}} placeholder
Article:
- specsText field (nullable text column + migration)
- stock field visible on index and editable in CRUD form
- addAttributeValue()/removeAttributeValue() adder-remover pair for Symfony form binding
- AttributeValue::getArticle() getter
- AttributeValueFormType: detect required attributes from ArticleType assignments, set required=true
- ManualIngestType: add stock/quantity field (default 1, min 1)
Users / ACL:
- PermissionVoter: define named permission constants + allPermissions()
- User: getGrantedPermissions()/setGrantedPermissions() helpers
- UserCrudController: permissions checkbox group on edit form
UI / assets:
- public/css/admin/custom.css: red asterisk for required fields
- DashboardController: register custom CSS
Infra:
- PipelineJobFailureListener: mark job failed (with real error) when Messenger exhausts retries
- doctrine.yaml: exclude app.inventory_seq from schema diff
- ErpAdapterInterface: add findExistingCustomer()
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds GET and DELETE methods to FrappeHttpClient. Integration tests cover
create, find, not-found (wrong name), and delete against the live staging
ERPNext instance. Run with: bin/test-integration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Photos field moved to first position. ID field removed entirely. eBay
description on detail uses a custom template that renders raw HTML in a
plain div (no span/title wrapper, no overflow constraints). Form view
keeps the textarea editor.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Agent now extracts content between --- markers so LLM preamble/postamble
is discarded. ebay_description prompt updated for commercial listings:
no private-sale language, condition explicitly "gebraucht", 1 year
gesetzliche Gewährleistung. Device label now includes modelName.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TextField rejects Collection values before formatValue runs. Switching to
the generic Field avoids the type check. ebayDescription now renders its
HTML tags in the detail view instead of showing raw markup.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
FK on attribute_values.attribute_definition_id now uses ON DELETE CASCADE
so deleting an AttributeDefinition also clears its values from all
articles. Admin delete action shows a confirmation warning.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When Tavily search results reveal that the OCR'd model number has a
character error, the specs_research prompt asks the LLM to output a
CORRECTED_MODEL_NUMBER line. The agent parses it out, stores it in the
job output, and DraftArticleHandler applies it to the article in
preference to the raw vision value.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Model numbers from OCR often have character errors (G→6, O→0 etc).
Using both fields together lets the human-readable name anchor the
search even when the model number is slightly wrong.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Captures the current working vision prompt (MODEL_NAME/MODEL_NUMBER
split) as a versioned migration so fresh installs get the correct prompt
without manual intervention.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove all hardcoded defaults from PromptTemplateService — the DB record
is now mandatory and render() throws if a key is missing. Admin UI
disables new/delete and makes the key field read-only so system prompts
cannot be renamed or removed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Parser now strips any embedded field labels (e.g. "SERIAL: x") that the
LLM mistakenly appends to a field value. Prompt updated with a concrete
example showing MODEL_NUMBER as blank to reinforce leaving it empty when
no separate part code is visible.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Without a model name/number specs research is pointless. Stays at
needs_review so the user can enter the model manually, then re-run.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Splitting MODEL into MODEL_NAME and MODEL_NUMBER confused the vision
model into returning empty for both. Now MODEL_NAME accepts any visible
model identifier; MODEL_NUMBER is only filled when a separate part/
product code is explicitly shown on the label.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously blocked on needs_review when model name/number were empty,
even if manufacturer or serial were detected. Now proceeds to specs
research whenever any useful field was extracted, only blocking when the
nameplate was completely unreadable.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Vision prompt now distinguishes MODEL_NAME (human-readable product name,
e.g. "ThinkPad T490s") from MODEL_NUMBER (part/product code, e.g.
"20NXS0BA00"). Both fields flow through the pipeline and are written to
the article. Specs research uses model number as search subject when
available, falling back to model name.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds model_name VARCHAR(255) column to app.articles, exposes it in the
admin CRUD form (optional, hidden on index), and adds translations for
both EN and DE.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
DraftArticleHandler was calling markCompleted() before dispatching
EbayTextMessage, causing the SSE to fire "completed" while the article
title was still null. EbayTextHandler had no job tracking at all.
- DraftArticleHandler: recordStep('draft_article') instead of markCompleted()
- EbayTextHandler: call markCompleted() after setEbayTexts() so the job
is only marked done once the title and description are actually written
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Use <label for="..."> with form.image.vars.id instead of .click() on
hidden input — display:none blocks programmatic click in some browsers
- Add drag-and-drop to the search photo drop zone (dragover/drop)
- Make extra photos input opacity:0/absolute so label trigger works too
- Camera fallback references correct searchInput variable via closure
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Ingest page redesigned: camera modal (getUserMedia + capture fallback
for mobile), mandatory search photo with client-side validation,
optional extra photos grid with per-photo remove button
- Camera modal: live video preview, capture → preview → confirm/retake
flow; stops stream on modal close; falls back to native file picker
if getUserMedia is unavailable
- ManualIngestController: uploads extra photos via PhotoService::uploadRaw(),
stores [{storagePathId, filename}] in job inputData as extraPhotos
- PhotoService::attachExtra(): attach already-stored file to an article
by StoragePath ID + filename
- DraftArticleHandler: after creating the article, attaches extra photos
in sort order; errors are best-effort (pipeline not aborted)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add secured GET /admin/photos/{filename} to serve files from var/uploads/
- Add POST /api/articles/{id}/photos/sort for drag-and-drop reordering
- Add PhotoService::reorder() to persist new sort positions
- Add photo gallery field (onlyOnDetail) using custom Twig template:
- Drag-and-drop reorder via SortableJS CDN
- Click or drop to upload new photos
- Set main (★) and delete per photo
- Main photo highlighted with blue border + badge
- Add field.photos translation key (EN/DE)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SSE connections hold a worker for up to 90 s each; the default of 5
children meant the admin UI became unresponsive under normal use.
Mount zzz-pool.conf (loaded after zz-docker.conf) to override only the
pm.* settings without touching daemonize/listen.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add WebSearchInterface + TavilyWebSearch (POST /search, max 5 results)
- SpecsResearchAgent now fetches search results first, injects them as
{{searchResults}} context into the prompt, then calls plain generate()
— no dependency on model-specific web_search tool support
- Update specs_research prompt template (PHP default + DB migration) to
use the new {{searchResults}} variable
- Wire TAVILY_API_KEY env var; register TavilyWebSearch in services.yaml
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
mistral-large-latest may not support the web_search tool type on all API
tiers; catch the exception and retry without web search so the pipeline
does not crash with needs_review.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add hasActiveJobForArticle() to check for queued/processing jobs.
The displayIf closure hides the action while a job is running.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add Translation entity (locale/domain/key/value, unique on all three)
- Add TranslationRepositoryInterface + DoctrineTranslationRepository
- Add DatabaseTranslator decorator (#[AsDecorator]) that checks DB first
and falls back to YAML files; clears per-request cache on setLocale()
- Add TranslationCrudController with locale/domain filters and read-only
key/locale/domain on edit to prevent accidental renames
- Add "Übersetzungen / Translations" menu entry in DashboardController
- Migration 20260520000000: create app.translations table
- Migration 20260520010000: seed from admin.en.yaml + admin.de.yaml (204 rows)
- Flatten admin.de.yaml to dot-notation; add new keys for translation CRUD
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add LocaleSubscriber: detects browser language, honours session override (priority 20)
- Add LocaleSwitchController: stores locale in session, linked from user menu
- Add admin.en.yaml / admin.de.yaml translation files (95 keys each)
- Wire translation fallback to EN in config/packages/translation.yaml
- Replace all hard-coded strings in CRUD controllers with TranslatableMessage
- Inject TranslatorInterface into DashboardController, ArticleCrudController,
AIPipelineJobCrudController and PipelineStreamController; add locale switcher
links (English / Deutsch) to the user menu
- Add confirmation dialog to "Re-run AI" and "Retry" pipeline actions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>