feat: Frappe ERP matching, pipeline model cache, ACL, stock field, specs by type
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>
This commit is contained in:
parent
cba8ebcf5e
commit
c19637465b
31 changed files with 721 additions and 14 deletions
|
|
@ -13,4 +13,5 @@ docker compose exec \
|
||||||
-e FRAPPE_ERP_BASE_URL="${FRAPPE_ERP_BASE_URL:-}" \
|
-e FRAPPE_ERP_BASE_URL="${FRAPPE_ERP_BASE_URL:-}" \
|
||||||
-e FRAPPE_ERP_API_KEY="${FRAPPE_ERP_API_KEY:-}" \
|
-e FRAPPE_ERP_API_KEY="${FRAPPE_ERP_API_KEY:-}" \
|
||||||
-e FRAPPE_ERP_API_SECRET="${FRAPPE_ERP_API_SECRET:-}" \
|
-e FRAPPE_ERP_API_SECRET="${FRAPPE_ERP_API_SECRET:-}" \
|
||||||
app php vendor/bin/phpunit tests/Integration/ --testdox "$@"
|
-e FRAPPE_GENERIC_ITEM_CODE="${FRAPPE_GENERIC_ITEM_CODE:-}" \
|
||||||
|
app php vendor/bin/phpunit --testdox "${@:-tests/Integration/}"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
doctrine:
|
doctrine:
|
||||||
dbal:
|
dbal:
|
||||||
url: '%env(resolve:DATABASE_URL)%'
|
url: '%env(resolve:DATABASE_URL)%'
|
||||||
schema_filter: ~^(?!logs_archive\.)~
|
schema_filter: ~^(?!logs_archive\.|app\.inventory_seq$)~
|
||||||
orm:
|
orm:
|
||||||
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
|
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
|
||||||
auto_mapping: true
|
auto_mapping: true
|
||||||
|
|
|
||||||
26
migrations/Version20260518150000.php
Normal file
26
migrations/Version20260518150000.php
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260518150000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add specs_text column to articles for ERP invoice description';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE app.articles ADD COLUMN specs_text TEXT DEFAULT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE app.articles DROP COLUMN specs_text');
|
||||||
|
}
|
||||||
|
}
|
||||||
68
migrations/Version20260518160000.php
Normal file
68
migrations/Version20260518160000.php
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260518160000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Update specs_research prompt to use article-type-specific attribute fields instead of hardcoded laptop specs';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$body = 'You are a hardware specifications expert. Extract the technical specifications for the {{articleType}}: "{{subject}}".
|
||||||
|
|
||||||
|
Web search results:
|
||||||
|
{{searchResults}}
|
||||||
|
|
||||||
|
Based on the search results above, extract the following specifications:
|
||||||
|
{{fields}}
|
||||||
|
|
||||||
|
Be specific and accurate. Use the exact values from the search results.
|
||||||
|
If a specification is not found in the search results, omit it rather than guessing.
|
||||||
|
Output only the specifications as a plain list — no introduction, no commentary.
|
||||||
|
|
||||||
|
If the search results reveal that the model number in "{{subject}}" contains an OCR error
|
||||||
|
(e.g. a letter misread as a digit), output the corrected model number on the very first line
|
||||||
|
in exactly this format, then leave a blank line before the specs:
|
||||||
|
CORRECTED_MODEL_NUMBER: <corrected number>
|
||||||
|
|
||||||
|
If the model number is correct or no model number was provided, omit the CORRECTED_MODEL_NUMBER line entirely.';
|
||||||
|
|
||||||
|
$this->addSql('UPDATE app.prompt_templates SET body = :body WHERE key = :key', [
|
||||||
|
'body' => $body,
|
||||||
|
'key' => 'specs_research',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$body = 'You are a hardware specifications expert. Extract the technical specifications for the {{articleType}}: "{{subject}}".
|
||||||
|
|
||||||
|
Web search results:
|
||||||
|
{{searchResults}}
|
||||||
|
|
||||||
|
Based on the search results above, list all technical specifications including:
|
||||||
|
processor, RAM, storage variants, display size and resolution, GPU, battery capacity,
|
||||||
|
ports, connectivity, weight, dimensions, OS, and any other relevant specs.
|
||||||
|
Be specific and accurate. If a spec is not found in the search results, omit it rather than guessing.
|
||||||
|
|
||||||
|
If the search results reveal that the model number in "{{subject}}" contains an OCR error
|
||||||
|
(e.g. a letter misread as a digit), output the corrected model number on the very first line
|
||||||
|
in exactly this format, then leave a blank line before the specs:
|
||||||
|
CORRECTED_MODEL_NUMBER: <corrected number>
|
||||||
|
|
||||||
|
If the model number is correct or no model number was provided, omit the CORRECTED_MODEL_NUMBER line entirely.';
|
||||||
|
|
||||||
|
$this->addSql('UPDATE app.prompt_templates SET body = :body WHERE key = :key', [
|
||||||
|
'body' => $body,
|
||||||
|
'key' => 'specs_research',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
5
public/css/admin/custom.css
Normal file
5
public/css/admin/custom.css
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
/* Required field asterisk */
|
||||||
|
label.required::after {
|
||||||
|
content: " *";
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
|
@ -26,4 +26,10 @@ interface ErpAdapterInterface
|
||||||
* Returns raw binary PDF content.
|
* Returns raw binary PDF content.
|
||||||
*/
|
*/
|
||||||
public function fetchInvoicePdf(string $frappeInvoiceId): string;
|
public function fetchInvoicePdf(string $frappeInvoiceId): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Searches Frappe for a customer matching name + address.
|
||||||
|
* Returns the Frappe document name (e.g. "CUST-00001") or null if not found.
|
||||||
|
*/
|
||||||
|
public function findExistingCustomer(string $name, string $street, string $city, string $zip): ?string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,9 @@ class Article
|
||||||
#[ORM\Column(type: 'text', nullable: true)]
|
#[ORM\Column(type: 'text', nullable: true)]
|
||||||
private ?string $ebayDescription = null;
|
private ?string $ebayDescription = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'text', nullable: true)]
|
||||||
|
private ?string $specsText = null;
|
||||||
|
|
||||||
/** @var Collection<int, AttributeValue> */
|
/** @var Collection<int, AttributeValue> */
|
||||||
#[ORM\OneToMany(mappedBy: 'article', targetEntity: AttributeValue::class, cascade: ['persist', 'remove'])]
|
#[ORM\OneToMany(mappedBy: 'article', targetEntity: AttributeValue::class, cascade: ['persist', 'remove'])]
|
||||||
private Collection $attributeValues;
|
private Collection $attributeValues;
|
||||||
|
|
@ -198,6 +201,16 @@ class Article
|
||||||
$this->attributeValues->add($value);
|
$this->attributeValues->add($value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function addAttributeValue(AttributeValue $value): void
|
||||||
|
{
|
||||||
|
$this->setAttributeValue($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeAttributeValue(AttributeValue $value): void
|
||||||
|
{
|
||||||
|
$this->attributeValues->removeElement($value);
|
||||||
|
}
|
||||||
|
|
||||||
/** @return Collection<int, AttributeValue> */
|
/** @return Collection<int, AttributeValue> */
|
||||||
public function getAttributeValues(): Collection
|
public function getAttributeValues(): Collection
|
||||||
{
|
{
|
||||||
|
|
@ -269,4 +282,14 @@ class Article
|
||||||
{
|
{
|
||||||
$this->ebayDescription = $desc;
|
$this->ebayDescription = $desc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getSpecsText(): ?string
|
||||||
|
{
|
||||||
|
return $this->specsText;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setSpecsText(?string $specsText): void
|
||||||
|
{
|
||||||
|
$this->specsText = $specsText;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,11 @@ class AttributeValue
|
||||||
return $this->id;
|
return $this->id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getArticle(): Article
|
||||||
|
{
|
||||||
|
return $this->article;
|
||||||
|
}
|
||||||
|
|
||||||
public function getAttributeDefinition(): AttributeDefinition
|
public function getAttributeDefinition(): AttributeDefinition
|
||||||
{
|
{
|
||||||
return $this->attributeDefinition;
|
return $this->attributeDefinition;
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,13 @@ interface ArticleRepositoryInterface
|
||||||
/** @return list<Article> */
|
/** @return list<Article> */
|
||||||
public function findByStatus(ArticleStatus $status): array;
|
public function findByStatus(ArticleStatus $status): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the most-recently created article with this model number that has
|
||||||
|
* completed the pipeline (Draft, Active, or Sold). Returns null when the
|
||||||
|
* model number is empty or no match exists.
|
||||||
|
*/
|
||||||
|
public function findCompletedByModelNumber(string $modelNumber): ?Article;
|
||||||
|
|
||||||
public function decrementStockAtomic(Uuid $articleId): bool;
|
public function decrementStockAtomic(Uuid $articleId): bool;
|
||||||
|
|
||||||
public function save(Article $article): void;
|
public function save(Article $article): void;
|
||||||
|
|
|
||||||
|
|
@ -142,4 +142,19 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, TotpTwo
|
||||||
{
|
{
|
||||||
unset($this->permissions[$permission]);
|
unset($this->permissions[$permission]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public function getGrantedPermissions(): array
|
||||||
|
{
|
||||||
|
return array_keys(array_filter($this->permissions));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<string> $granted */
|
||||||
|
public function setGrantedPermissions(array $granted): void
|
||||||
|
{
|
||||||
|
$this->permissions = [];
|
||||||
|
foreach ($granted as $permission) {
|
||||||
|
$this->permissions[$permission] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,18 +19,25 @@ final class SpecsResearchAgent
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* @param list<string> $attributeFields Attribute names defined for this article type
|
||||||
|
*
|
||||||
* @return array{specsText: string, correctedModelNumber: string}
|
* @return array{specsText: string, correctedModelNumber: string}
|
||||||
*/
|
*/
|
||||||
public function research(string $modelName, string $articleTypeName, string $manufacturer = ''): array
|
public function research(string $modelName, string $articleTypeName, string $manufacturer = '', array $attributeFields = []): array
|
||||||
{
|
{
|
||||||
$subject = trim(($manufacturer !== '' ? $manufacturer.' ' : '').$modelName);
|
$subject = trim(($manufacturer !== '' ? $manufacturer.' ' : '').$modelName);
|
||||||
|
|
||||||
$searchResults = $this->search->search("{$subject} {$articleTypeName} specifications");
|
$searchResults = $this->search->search("{$subject} {$articleTypeName} specifications");
|
||||||
|
|
||||||
|
$fieldsList = [] !== $attributeFields
|
||||||
|
? implode("\n", array_map(static fn (string $f) => "- {$f}", $attributeFields))
|
||||||
|
: '- all relevant technical specifications';
|
||||||
|
|
||||||
$prompt = $this->prompts->render('specs_research', [
|
$prompt = $this->prompts->render('specs_research', [
|
||||||
'articleType' => $articleTypeName,
|
'articleType' => $articleTypeName,
|
||||||
'subject' => $subject,
|
'subject' => $subject,
|
||||||
'searchResults' => $searchResults !== '' ? $searchResults : 'No web results available.',
|
'searchResults' => $searchResults !== '' ? $searchResults : 'No web results available.',
|
||||||
|
'fields' => $fieldsList,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$result = $this->client->generate($this->model, $prompt);
|
$result = $this->client->generate($this->model, $prompt);
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ final class PromptTemplateService
|
||||||
public static function knownKeys(): array
|
public static function knownKeys(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'specs_research' => ['articleType', 'subject', 'searchResults'],
|
'specs_research' => ['articleType', 'subject', 'searchResults', 'fields'],
|
||||||
'ebay_title' => ['typeName', 'deviceLabel', 'condition', 'specsSection'],
|
'ebay_title' => ['typeName', 'deviceLabel', 'condition', 'specsSection'],
|
||||||
'ebay_description' => ['typeName', 'deviceLabel', 'condition', 'conditionNotes', 'specsSection'],
|
'ebay_description' => ['typeName', 'deviceLabel', 'condition', 'conditionNotes', 'specsSection'],
|
||||||
'vision_analyze' => [],
|
'vision_analyze' => [],
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||||
namespace App\Infrastructure\Channel\Frappe;
|
namespace App\Infrastructure\Channel\Frappe;
|
||||||
|
|
||||||
use App\Application\Order\ErpAdapterInterface;
|
use App\Application\Order\ErpAdapterInterface;
|
||||||
|
use App\Domain\Article\Article;
|
||||||
use App\Domain\Order\Customer;
|
use App\Domain\Order\Customer;
|
||||||
use App\Domain\Order\Order;
|
use App\Domain\Order\Order;
|
||||||
|
|
||||||
|
|
@ -50,7 +51,7 @@ final class FrappeErpAdapter implements ErpAdapterInterface
|
||||||
[
|
[
|
||||||
'item_code' => $this->genericItemCode,
|
'item_code' => $this->genericItemCode,
|
||||||
'item_name' => $article->getEbayTitle() ?? $article->getSku(),
|
'item_name' => $article->getEbayTitle() ?? $article->getSku(),
|
||||||
'description' => \sprintf('%s — Inventar: %s', $article->getEbayTitle() ?? $article->getSku(), $article->getInventoryNumber()),
|
'description' => $this->buildItemDescription($article),
|
||||||
'qty' => 1,
|
'qty' => 1,
|
||||||
'rate' => $order->getSalePrice(),
|
'rate' => $order->getSalePrice(),
|
||||||
],
|
],
|
||||||
|
|
@ -62,11 +63,77 @@ final class FrappeErpAdapter implements ErpAdapterInterface
|
||||||
/** @var array{data: array{name: string}} $draft */
|
/** @var array{data: array{name: string}} $draft */
|
||||||
$invoiceName = $draft['data']['name'];
|
$invoiceName = $draft['data']['name'];
|
||||||
|
|
||||||
$this->frappe->post('/api/resource/Sales Invoice/'.$invoiceName.'/submit', []);
|
$this->frappe->put('/api/resource/Sales Invoice/'.$invoiceName, ['docstatus' => 1]);
|
||||||
|
|
||||||
return $invoiceName;
|
return $invoiceName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function findExistingCustomer(string $name, string $street, string $city, string $zip): ?string
|
||||||
|
{
|
||||||
|
$query = http_build_query([
|
||||||
|
'filters' => json_encode([['customer_name', '=', $name]]),
|
||||||
|
'fields' => json_encode(['name']),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->frappe->get('/api/resource/Customer?'.$query);
|
||||||
|
|
||||||
|
/** @var array{data: list<array{name: string}>} $response */
|
||||||
|
foreach ($response['data'] as $candidate) {
|
||||||
|
$customerId = $candidate['name'];
|
||||||
|
|
||||||
|
$addrQuery = http_build_query([
|
||||||
|
'filters' => json_encode([
|
||||||
|
['Dynamic Link', 'link_doctype', '=', 'Customer'],
|
||||||
|
['Dynamic Link', 'link_name', '=', $customerId],
|
||||||
|
]),
|
||||||
|
'fields' => json_encode(['address_line1', 'city', 'pincode']),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$addrResponse = $this->frappe->get('/api/resource/Address?'.$addrQuery);
|
||||||
|
|
||||||
|
/** @var array{data: list<array{address_line1: string, city: string, pincode: string}>} $addrResponse */
|
||||||
|
foreach ($addrResponse['data'] as $addr) {
|
||||||
|
if ($this->addressMatches($addr, $street, $city, $zip)) {
|
||||||
|
return $customerId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildItemDescription(Article $article): string
|
||||||
|
{
|
||||||
|
$meta = 'Inventar: '.$article->getInventoryNumber();
|
||||||
|
if (null !== $article->getSerialNumber()) {
|
||||||
|
$meta .= ' — S/N: '.$article->getSerialNumber();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $article->getSpecsText()) {
|
||||||
|
return $article->getSpecsText()."\n\n".$meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fallback = $article->getEbayTitle() ?? trim(implode(' ', array_filter([
|
||||||
|
$article->getManufacturer(),
|
||||||
|
$article->getModelName(),
|
||||||
|
$article->getModelNumber(),
|
||||||
|
]))) ?: $article->getSku();
|
||||||
|
|
||||||
|
return $fallback.' — '.$meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{address_line1?: string, city?: string, pincode?: string} $addr
|
||||||
|
*/
|
||||||
|
private function addressMatches(array $addr, string $street, string $city, string $zip): bool
|
||||||
|
{
|
||||||
|
$n = static fn(string $s): string => mb_strtolower(trim($s));
|
||||||
|
|
||||||
|
return $n($addr['address_line1'] ?? '') === $n($street)
|
||||||
|
&& $n($addr['city'] ?? '') === $n($city)
|
||||||
|
&& trim($addr['pincode'] ?? '') === trim($zip);
|
||||||
|
}
|
||||||
|
|
||||||
public function fetchInvoicePdf(string $frappeInvoiceId): string
|
public function fetchInvoicePdf(string $frappeInvoiceId): string
|
||||||
{
|
{
|
||||||
$path = http_build_query([
|
$path = http_build_query([
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,29 @@ class FrappeHttpClient
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT (update) a Frappe resource.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function put(string $path, array $data): array
|
||||||
|
{
|
||||||
|
$response = $this->httpClient->request('PUT', $this->baseUrl.$path, [
|
||||||
|
'headers' => [
|
||||||
|
'Authorization' => $this->authHeader,
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
],
|
||||||
|
'json' => $data,
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** @var array<string, mixed> $result */
|
||||||
|
$result = $response->toArray();
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE a Frappe resource.
|
* DELETE a Frappe resource.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField;
|
||||||
use EasyCorp\Bundle\EasyAdminBundle\Field\CollectionField;
|
use EasyCorp\Bundle\EasyAdminBundle\Field\CollectionField;
|
||||||
use EasyCorp\Bundle\EasyAdminBundle\Field\Field;
|
use EasyCorp\Bundle\EasyAdminBundle\Field\Field;
|
||||||
use EasyCorp\Bundle\EasyAdminBundle\Field\IdField;
|
use EasyCorp\Bundle\EasyAdminBundle\Field\IdField;
|
||||||
|
use EasyCorp\Bundle\EasyAdminBundle\Field\IntegerField;
|
||||||
use EasyCorp\Bundle\EasyAdminBundle\Field\MoneyField;
|
use EasyCorp\Bundle\EasyAdminBundle\Field\MoneyField;
|
||||||
use EasyCorp\Bundle\EasyAdminBundle\Field\TextareaField;
|
use EasyCorp\Bundle\EasyAdminBundle\Field\TextareaField;
|
||||||
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
|
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
|
||||||
|
|
@ -110,6 +111,8 @@ final class ArticleCrudController extends AbstractCrudController
|
||||||
ArticleStatus::cases(),
|
ArticleStatus::cases(),
|
||||||
)
|
)
|
||||||
)->hideOnForm();
|
)->hideOnForm();
|
||||||
|
yield IntegerField::new('stock', new TranslatableMessage('field.stock', [], 'admin'))
|
||||||
|
->setFormTypeOption('attr', ['min' => 0]);
|
||||||
yield MoneyField::new('listingPrice', new TranslatableMessage('field.price', [], 'admin'))->setCurrency('EUR')->setRequired(false);
|
yield MoneyField::new('listingPrice', new TranslatableMessage('field.price', [], 'admin'))->setCurrency('EUR')->setRequired(false);
|
||||||
yield ChoiceField::new('condition', new TranslatableMessage('field.condition', [], 'admin'))->setChoices(
|
yield ChoiceField::new('condition', new TranslatableMessage('field.condition', [], 'admin'))->setChoices(
|
||||||
array_combine(
|
array_combine(
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,9 @@ final class DashboardController extends AbstractDashboardController
|
||||||
|
|
||||||
public function configureAssets(): Assets
|
public function configureAssets(): Assets
|
||||||
{
|
{
|
||||||
return Assets::new()->addJsFile('js/admin/pipeline-notifications.js');
|
return Assets::new()
|
||||||
|
->addCssFile('css/admin/custom.css')
|
||||||
|
->addJsFile('js/admin/pipeline-notifications.js');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function configureDashboard(): Dashboard
|
public function configureDashboard(): Dashboard
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,7 @@ final class ManualIngestController extends AbstractController
|
||||||
'articleTypeId' => $articleType->getId()->toRfc4122(),
|
'articleTypeId' => $articleType->getId()->toRfc4122(),
|
||||||
'condition' => $form->get('condition')->getData()->value,
|
'condition' => $form->get('condition')->getData()->value,
|
||||||
'conditionNotes' => $form->get('conditionNotes')->getData(),
|
'conditionNotes' => $form->get('conditionNotes')->getData(),
|
||||||
|
'stock' => (int) $form->get('stock')->getData(),
|
||||||
'originalFilename' => $image->getClientOriginalName(),
|
'originalFilename' => $image->getClientOriginalName(),
|
||||||
'storedPhotoPath' => $storedPath,
|
'storedPhotoPath' => $storedPath,
|
||||||
'extraPhotos' => $extraPhotos,
|
'extraPhotos' => $extraPhotos,
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,13 @@ declare(strict_types=1);
|
||||||
namespace App\Infrastructure\Http\Controller\Admin;
|
namespace App\Infrastructure\Http\Controller\Admin;
|
||||||
|
|
||||||
use App\Domain\Auth\User;
|
use App\Domain\Auth\User;
|
||||||
|
use App\Infrastructure\Security\PermissionVoter;
|
||||||
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
|
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
|
||||||
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
|
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
|
||||||
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
|
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
|
||||||
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
|
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
|
||||||
use EasyCorp\Bundle\EasyAdminBundle\Field\BooleanField;
|
use EasyCorp\Bundle\EasyAdminBundle\Field\BooleanField;
|
||||||
|
use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField;
|
||||||
use EasyCorp\Bundle\EasyAdminBundle\Field\IdField;
|
use EasyCorp\Bundle\EasyAdminBundle\Field\IdField;
|
||||||
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
|
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
|
||||||
use Symfony\Component\Translation\TranslatableMessage;
|
use Symfony\Component\Translation\TranslatableMessage;
|
||||||
|
|
@ -37,5 +39,14 @@ final class UserCrudController extends AbstractCrudController
|
||||||
yield IdField::new('id')->hideOnForm()->hideOnIndex();
|
yield IdField::new('id')->hideOnForm()->hideOnIndex();
|
||||||
yield TextField::new('email')->setFormTypeOption('disabled', true);
|
yield TextField::new('email')->setFormTypeOption('disabled', true);
|
||||||
yield BooleanField::new('isActive');
|
yield BooleanField::new('isActive');
|
||||||
|
|
||||||
|
$allPerms = PermissionVoter::allPermissions();
|
||||||
|
$choices = array_combine($allPerms, $allPerms);
|
||||||
|
|
||||||
|
yield ChoiceField::new('grantedPermissions', 'Permissions')
|
||||||
|
->setChoices($choices)
|
||||||
|
->allowMultipleChoices()
|
||||||
|
->renderExpanded()
|
||||||
|
->hideOnIndex();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,11 +23,21 @@ final class AttributeValueFormType extends AbstractType
|
||||||
}
|
}
|
||||||
|
|
||||||
$def = $av->getAttributeDefinition();
|
$def = $av->getAttributeDefinition();
|
||||||
|
$defId = $def->getId()->toRfc4122();
|
||||||
$label = $def->getName().($def->getUnit() ? ' ('.$def->getUnit().')' : '');
|
$label = $def->getName().($def->getUnit() ? ' ('.$def->getUnit().')' : '');
|
||||||
|
|
||||||
|
$isRequired = false;
|
||||||
|
foreach ($av->getArticle()->getArticleType()->getAttributeAssignments() as $assignment) {
|
||||||
|
if ($assignment->getAttributeDefinition()->getId()->toRfc4122() === $defId) {
|
||||||
|
$isRequired = $assignment->isRequired();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$event->getForm()->add('value', TextType::class, [
|
$event->getForm()->add('value', TextType::class, [
|
||||||
'label' => $label,
|
'label' => $label,
|
||||||
'required' => false,
|
'required' => $isRequired,
|
||||||
|
'empty_data' => '',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,11 @@ use Symfony\Bridge\Doctrine\Form\Type\EntityType;
|
||||||
use Symfony\Component\Form\AbstractType;
|
use Symfony\Component\Form\AbstractType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\EnumType;
|
use Symfony\Component\Form\Extension\Core\Type\EnumType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\FileType;
|
use Symfony\Component\Form\Extension\Core\Type\FileType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||||
use Symfony\Component\Form\FormBuilderInterface;
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
use Symfony\Component\Validator\Constraints\GreaterThanOrEqual;
|
||||||
use Symfony\Component\Validator\Constraints\Image;
|
use Symfony\Component\Validator\Constraints\Image;
|
||||||
use Symfony\Component\Validator\Constraints\NotNull;
|
use Symfony\Component\Validator\Constraints\NotNull;
|
||||||
|
|
||||||
|
|
@ -42,6 +44,12 @@ final class ManualIngestType extends AbstractType
|
||||||
],
|
],
|
||||||
'attr' => ['accept' => 'image/*', 'capture' => 'environment'],
|
'attr' => ['accept' => 'image/*', 'capture' => 'environment'],
|
||||||
])
|
])
|
||||||
|
->add('stock', IntegerType::class, [
|
||||||
|
'label' => 'Quantity',
|
||||||
|
'data' => 1,
|
||||||
|
'attr' => ['min' => 1],
|
||||||
|
'constraints' => [new GreaterThanOrEqual(1)],
|
||||||
|
])
|
||||||
->add('conditionNotes', TextareaType::class, [
|
->add('conditionNotes', TextareaType::class, [
|
||||||
'label' => 'Condition Notes',
|
'label' => 'Condition Notes',
|
||||||
'required' => false,
|
'required' => false,
|
||||||
|
|
|
||||||
|
|
@ -50,11 +50,12 @@ final class DraftArticleHandler
|
||||||
} else {
|
} else {
|
||||||
$condition = ArticleCondition::tryFrom($message->condition) ?? ArticleCondition::Good;
|
$condition = ArticleCondition::tryFrom($message->condition) ?? ArticleCondition::Good;
|
||||||
$inventoryNumber = $message->inventoryNumber ?? ($job->getInputData()['inventoryNumber'] ?? null);
|
$inventoryNumber = $message->inventoryNumber ?? ($job->getInputData()['inventoryNumber'] ?? null);
|
||||||
|
$stock = max(1, (int) ($job->getInputData()['stock'] ?? 1));
|
||||||
|
|
||||||
$article = $this->articleService->create(
|
$article = $this->articleService->create(
|
||||||
articleTypeId: Uuid::fromString($message->articleTypeId),
|
articleTypeId: Uuid::fromString($message->articleTypeId),
|
||||||
condition: $condition,
|
condition: $condition,
|
||||||
stock: 1,
|
stock: $stock,
|
||||||
inventoryNumber: $inventoryNumber,
|
inventoryNumber: $inventoryNumber,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -76,6 +77,26 @@ final class DraftArticleHandler
|
||||||
$article->setModelName((string) $vision['modelName']);
|
$article->setModelName((string) $vision['modelName']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$modelMatch = $job->getOutputData()['model_match'] ?? null;
|
||||||
|
|
||||||
|
if (null !== $modelMatch) {
|
||||||
|
// Cache hit: copy texts directly from the matched article
|
||||||
|
if (isset($modelMatch['ebayTitle'])) {
|
||||||
|
$article->setEbayTitle((string) $modelMatch['ebayTitle']);
|
||||||
|
}
|
||||||
|
if (isset($modelMatch['ebayDescription'])) {
|
||||||
|
$article->setEbayDescription((string) $modelMatch['ebayDescription']);
|
||||||
|
}
|
||||||
|
if (isset($modelMatch['specsText'])) {
|
||||||
|
$article->setSpecsText((string) $modelMatch['specsText']);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$specsText = (string) ($job->getOutputData()['specs_research']['specsText'] ?? '');
|
||||||
|
if ('' !== $specsText) {
|
||||||
|
$article->setSpecsText($specsText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ([] !== $message->attributes) {
|
if ([] !== $message->attributes) {
|
||||||
$this->articleService->updateAttributes($article->getId(), $message->attributes);
|
$this->articleService->updateAttributes($article->getId(), $message->attributes);
|
||||||
}
|
}
|
||||||
|
|
@ -104,6 +125,17 @@ final class DraftArticleHandler
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (null !== $modelMatch) {
|
||||||
|
// Skip eBay text generation — texts already copied from cache
|
||||||
|
$job->markCompleted([
|
||||||
|
'articleId' => $article->getId()->toRfc4122(),
|
||||||
|
'model_match' => ['sourceArticleId' => $modelMatch['sourceArticleId'] ?? null],
|
||||||
|
]);
|
||||||
|
$this->jobRepository->save($job);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$this->bus->dispatch(new EbayTextMessage(
|
$this->bus->dispatch(new EbayTextMessage(
|
||||||
jobId: $message->jobId,
|
jobId: $message->jobId,
|
||||||
articleId: $article->getId()->toRfc4122(),
|
articleId: $article->getId()->toRfc4122(),
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,10 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Infrastructure\Messenger\Handler;
|
namespace App\Infrastructure\Messenger\Handler;
|
||||||
|
|
||||||
|
use App\Domain\Article\Repository\ArticleRepositoryInterface;
|
||||||
use App\Domain\Pipeline\Repository\AIPipelineJobRepositoryInterface;
|
use App\Domain\Pipeline\Repository\AIPipelineJobRepositoryInterface;
|
||||||
use App\Infrastructure\AI\Agent\OllamaVisionAgent;
|
use App\Infrastructure\AI\Agent\OllamaVisionAgent;
|
||||||
|
use App\Infrastructure\Messenger\Message\DraftArticleMessage;
|
||||||
use App\Infrastructure\Messenger\Message\PhotoUploadMessage;
|
use App\Infrastructure\Messenger\Message\PhotoUploadMessage;
|
||||||
use App\Infrastructure\Messenger\Message\SpecsResearchMessage;
|
use App\Infrastructure\Messenger\Message\SpecsResearchMessage;
|
||||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||||
|
|
@ -18,6 +20,7 @@ final class PhotoUploadHandler
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly OllamaVisionAgent $visionAgent,
|
private readonly OllamaVisionAgent $visionAgent,
|
||||||
private readonly AIPipelineJobRepositoryInterface $jobRepository,
|
private readonly AIPipelineJobRepositoryInterface $jobRepository,
|
||||||
|
private readonly ArticleRepositoryInterface $articleRepository,
|
||||||
private readonly MessageBusInterface $bus,
|
private readonly MessageBusInterface $bus,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
@ -50,6 +53,39 @@ final class PhotoUploadHandler
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$modelNumber = $result['modelNumber'];
|
||||||
|
$existing = $this->articleRepository->findCompletedByModelNumber($modelNumber);
|
||||||
|
|
||||||
|
if (null !== $existing) {
|
||||||
|
$attributes = [];
|
||||||
|
foreach ($existing->getAttributeValues() as $av) {
|
||||||
|
if ('' !== $av->getValue()) {
|
||||||
|
$attributes[$av->getAttributeDefinition()->getName()] = $av->getValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$job->recordStep('model_match', [
|
||||||
|
'sourceArticleId' => $existing->getId()->toRfc4122(),
|
||||||
|
'ebayTitle' => $existing->getEbayTitle(),
|
||||||
|
'ebayDescription' => $existing->getEbayDescription(),
|
||||||
|
'specsText' => $existing->getSpecsText(),
|
||||||
|
'attributes' => $attributes,
|
||||||
|
]);
|
||||||
|
$this->jobRepository->save($job);
|
||||||
|
|
||||||
|
$inputData = $job->getInputData();
|
||||||
|
$this->bus->dispatch(new DraftArticleMessage(
|
||||||
|
jobId: $message->jobId,
|
||||||
|
articleTypeId: $message->articleTypeId,
|
||||||
|
attributes: $attributes,
|
||||||
|
condition: $inputData['condition'] ?? 'good',
|
||||||
|
inventoryNumber: $inputData['inventoryNumber'] ?? null,
|
||||||
|
serialNumber: $result['serial'] !== '' ? $result['serial'] : null,
|
||||||
|
));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$this->bus->dispatch(new SpecsResearchMessage(
|
$this->bus->dispatch(new SpecsResearchMessage(
|
||||||
jobId: $message->jobId,
|
jobId: $message->jobId,
|
||||||
articleTypeId: $message->articleTypeId,
|
articleTypeId: $message->articleTypeId,
|
||||||
|
|
|
||||||
|
|
@ -44,11 +44,20 @@ final class SpecsResearchHandler
|
||||||
$parts = array_filter([$message->modelName, $message->modelNumber]);
|
$parts = array_filter([$message->modelName, $message->modelNumber]);
|
||||||
$searchSubject = implode(' ', $parts);
|
$searchSubject = implode(' ', $parts);
|
||||||
|
|
||||||
|
$attributeFields = $message->attributeFields;
|
||||||
|
if ([] === $attributeFields) {
|
||||||
|
// Derive from article type when the message predates this field
|
||||||
|
foreach ($articleType->getAttributeDefinitions() as $def) {
|
||||||
|
$attributeFields[] = $def->getName();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$result = $this->specsAgent->research(
|
$result = $this->specsAgent->research(
|
||||||
$searchSubject,
|
$searchSubject,
|
||||||
$articleType->getName(),
|
$articleType->getName(),
|
||||||
$message->manufacturer,
|
$message->manufacturer,
|
||||||
|
$attributeFields,
|
||||||
);
|
);
|
||||||
} catch (\RuntimeException $e) {
|
} catch (\RuntimeException $e) {
|
||||||
$job->markNeedsReview('SpecsResearchAgent: '.$e->getMessage());
|
$job->markNeedsReview('SpecsResearchAgent: '.$e->getMessage());
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,9 @@ namespace App\Infrastructure\Messenger\Message;
|
||||||
|
|
||||||
final readonly class SpecsResearchMessage
|
final readonly class SpecsResearchMessage
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* @param list<string> $attributeFields Attribute definition names for this article type
|
||||||
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public string $jobId,
|
public string $jobId,
|
||||||
public string $articleTypeId,
|
public string $articleTypeId,
|
||||||
|
|
@ -13,6 +16,7 @@ final readonly class SpecsResearchMessage
|
||||||
public string $modelName,
|
public string $modelName,
|
||||||
public string $serialNumber,
|
public string $serialNumber,
|
||||||
public string $manufacturer = '',
|
public string $manufacturer = '',
|
||||||
|
public array $attributeFields = [],
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
58
src/Infrastructure/Messenger/PipelineJobFailureListener.php
Normal file
58
src/Infrastructure/Messenger/PipelineJobFailureListener.php
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Infrastructure\Messenger;
|
||||||
|
|
||||||
|
use App\Domain\Pipeline\Repository\AIPipelineJobRepositoryInterface;
|
||||||
|
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||||
|
use Symfony\Component\Messenger\Event\WorkerMessageFailedEvent;
|
||||||
|
use Symfony\Component\Uid\Uuid;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks a pipeline job as failed when Messenger exhausts all retries.
|
||||||
|
* Without this the job stays stuck in "processing" forever.
|
||||||
|
*/
|
||||||
|
#[AsEventListener]
|
||||||
|
final class PipelineJobFailureListener
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AIPipelineJobRepositoryInterface $jobRepository,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __invoke(WorkerMessageFailedEvent $event): void
|
||||||
|
{
|
||||||
|
if ($event->willRetry()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = $event->getEnvelope()->getMessage();
|
||||||
|
|
||||||
|
if (!property_exists($message, 'jobId')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$jobId = $message->jobId; // @phpstan-ignore-line
|
||||||
|
if (!\is_string($jobId) || '' === $jobId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$job = $this->jobRepository->findById(Uuid::fromString($jobId));
|
||||||
|
if (null === $job) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$throwable = $event->getThrowable();
|
||||||
|
$error = $throwable->getMessage();
|
||||||
|
|
||||||
|
// Unwrap HandlerFailedException to get the real cause
|
||||||
|
$previous = $throwable->getPrevious();
|
||||||
|
if (null !== $previous) {
|
||||||
|
$error = $previous->getMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
$job->markFailed($error);
|
||||||
|
$this->jobRepository->save($job);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -43,6 +43,26 @@ final class DoctrineArticleRepository implements ArticleRepositoryInterface
|
||||||
return $this->em->getRepository(Article::class)->findBy(['status' => $status]);
|
return $this->em->getRepository(Article::class)->findBy(['status' => $status]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function findCompletedByModelNumber(string $modelNumber): ?Article
|
||||||
|
{
|
||||||
|
if ('' === $modelNumber) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var ?Article */
|
||||||
|
return $this->em->createQueryBuilder()
|
||||||
|
->select('a')
|
||||||
|
->from(Article::class, 'a')
|
||||||
|
->where('a.modelNumber = :modelNumber')
|
||||||
|
->andWhere('a.status IN (:statuses)')
|
||||||
|
->setParameter('modelNumber', $modelNumber)
|
||||||
|
->setParameter('statuses', [ArticleStatus::Draft, ArticleStatus::Active, ArticleStatus::Sold])
|
||||||
|
->orderBy('a.id', 'DESC')
|
||||||
|
->setMaxResults(1)
|
||||||
|
->getQuery()
|
||||||
|
->getOneOrNullResult();
|
||||||
|
}
|
||||||
|
|
||||||
public function decrementStockAtomic(Uuid $articleId): bool
|
public function decrementStockAtomic(Uuid $articleId): bool
|
||||||
{
|
{
|
||||||
$affected = $this->em->getConnection()->executeStatement(
|
$affected = $this->em->getConnection()->executeStatement(
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,26 @@ final class PermissionVoter extends Voter
|
||||||
{
|
{
|
||||||
public const PREFIX = 'PERM_';
|
public const PREFIX = 'PERM_';
|
||||||
|
|
||||||
|
public const ARTICLES_MANAGE = 'articles.manage';
|
||||||
|
public const PIPELINE_RUN = 'pipeline.run';
|
||||||
|
public const ORDERS_MANAGE = 'orders.manage';
|
||||||
|
public const USERS_MANAGE = 'users.manage';
|
||||||
|
public const PROMPTS_MANAGE = 'prompts.manage';
|
||||||
|
public const SETTINGS_MANAGE = 'settings.manage';
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public static function allPermissions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::ARTICLES_MANAGE,
|
||||||
|
self::PIPELINE_RUN,
|
||||||
|
self::ORDERS_MANAGE,
|
||||||
|
self::USERS_MANAGE,
|
||||||
|
self::PROMPTS_MANAGE,
|
||||||
|
self::SETTINGS_MANAGE,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
protected function supports(string $attribute, mixed $subject): bool
|
protected function supports(string $attribute, mixed $subject): bool
|
||||||
{
|
{
|
||||||
return str_starts_with($attribute, self::PREFIX);
|
return str_starts_with($attribute, self::PREFIX);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Integration\Infrastructure\Channel\Frappe;
|
||||||
|
|
||||||
|
use App\Domain\Article\Article;
|
||||||
|
use App\Domain\Article\ArticleCondition;
|
||||||
|
use App\Domain\Article\ArticleType;
|
||||||
|
use App\Domain\Channel\Platform;
|
||||||
|
use App\Domain\Order\Customer;
|
||||||
|
use App\Domain\Order\Order;
|
||||||
|
use App\Infrastructure\Channel\Frappe\FrappeErpAdapter;
|
||||||
|
use App\Infrastructure\Channel\Frappe\FrappeHttpClient;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\HttpClient\HttpClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration tests against the live ERPNext staging instance.
|
||||||
|
* Requires FRAPPE_ERP_BASE_URL, FRAPPE_ERP_API_KEY, FRAPPE_ERP_API_SECRET,
|
||||||
|
* FRAPPE_GENERIC_ITEM_CODE in .env.local.
|
||||||
|
*
|
||||||
|
* Run with: bin/test-integration
|
||||||
|
*/
|
||||||
|
final class FrappeErpAdapterIntegrationTest extends TestCase
|
||||||
|
{
|
||||||
|
private FrappeErpAdapter $adapter;
|
||||||
|
private FrappeHttpClient $client;
|
||||||
|
private string $createdInvoiceName = '';
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$baseUrl = $_SERVER['FRAPPE_ERP_BASE_URL'] ?? getenv('FRAPPE_ERP_BASE_URL');
|
||||||
|
$apiKey = $_SERVER['FRAPPE_ERP_API_KEY'] ?? getenv('FRAPPE_ERP_API_KEY');
|
||||||
|
$apiSecret = $_SERVER['FRAPPE_ERP_API_SECRET'] ?? getenv('FRAPPE_ERP_API_SECRET');
|
||||||
|
$itemCode = $_SERVER['FRAPPE_GENERIC_ITEM_CODE'] ?? getenv('FRAPPE_GENERIC_ITEM_CODE');
|
||||||
|
|
||||||
|
if (!$baseUrl || !$apiKey || !$apiSecret || !$itemCode) {
|
||||||
|
$this->markTestSkipped('FRAPPE_ERP_* env vars not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->client = new FrappeHttpClient(
|
||||||
|
HttpClient::create(),
|
||||||
|
(string) $baseUrl,
|
||||||
|
(string) $apiKey,
|
||||||
|
(string) $apiSecret,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->adapter = new FrappeErpAdapter($this->client, (string) $itemCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
if ('' !== $this->createdInvoiceName) {
|
||||||
|
try {
|
||||||
|
$this->client->put('/api/resource/Sales Invoice/'.$this->createdInvoiceName, ['docstatus' => 2]);
|
||||||
|
$this->client->delete('/api/resource/Sales Invoice/'.$this->createdInvoiceName);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// best-effort cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_find_simon_kuehn_by_name_and_address(): void
|
||||||
|
{
|
||||||
|
$customerId = $this->adapter->findExistingCustomer(
|
||||||
|
'Simon Kühn',
|
||||||
|
'Kirchstr. 1',
|
||||||
|
'Karbach',
|
||||||
|
'56281',
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertNotNull($customerId, 'Simon Kühn should exist in Frappe staging');
|
||||||
|
$this->assertStringStartsWith('Simon', $customerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_unknown_person_is_not_found(): void
|
||||||
|
{
|
||||||
|
$customerId = $this->adapter->findExistingCustomer(
|
||||||
|
'Voldemort',
|
||||||
|
'Dunkle Gasse 1',
|
||||||
|
'Nirgendwo',
|
||||||
|
'00000',
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertNull($customerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_find_simon_and_create_invoice_for_1337(): void
|
||||||
|
{
|
||||||
|
$frappeId = $this->adapter->findExistingCustomer(
|
||||||
|
'Simon Kühn',
|
||||||
|
'Kirchstr. 1',
|
||||||
|
'Karbach',
|
||||||
|
'56281',
|
||||||
|
);
|
||||||
|
$this->assertNotNull($frappeId, 'Simon Kühn should exist in Frappe staging');
|
||||||
|
|
||||||
|
$simon = new Customer('Simon Kühn', 'simon.kuehn83@gmail.com', [
|
||||||
|
'street' => 'Kirchstr. 1',
|
||||||
|
'city' => 'Karbach',
|
||||||
|
'zip' => '56281',
|
||||||
|
]);
|
||||||
|
$simon->setFrappeCustomerId($frappeId);
|
||||||
|
|
||||||
|
$article = new Article(
|
||||||
|
new ArticleType('Laptop'),
|
||||||
|
'LAP-THINK-SIMON',
|
||||||
|
'INV-THINK-SIMON',
|
||||||
|
1,
|
||||||
|
ArticleCondition::Good,
|
||||||
|
);
|
||||||
|
$article->setManufacturer('Lenovo');
|
||||||
|
$article->setModelName('ThinkBook 14 G6 IRL');
|
||||||
|
$article->setModelNumber('21KG00NQGE');
|
||||||
|
$article->setSerialNumber('PNV09SJZ');
|
||||||
|
$article->setEbayTitle('Lenovo ThinkBook 14 G6 IRL — generalüberholt, läuft wie Butter');
|
||||||
|
|
||||||
|
$order = new Order(
|
||||||
|
$article,
|
||||||
|
$simon,
|
||||||
|
new Platform('direct', 'Direktverkauf'),
|
||||||
|
'ORDER-SIMON-1337',
|
||||||
|
'1337.00',
|
||||||
|
new \DateTimeImmutable('2026-05-18'),
|
||||||
|
);
|
||||||
|
|
||||||
|
$invoiceId = $this->adapter->createSalesInvoice($order);
|
||||||
|
|
||||||
|
$this->assertNotEmpty($invoiceId);
|
||||||
|
$this->createdInvoiceName = $invoiceId;
|
||||||
|
|
||||||
|
// Verify the invoice actually exists in Frappe
|
||||||
|
$response = $this->client->get('/api/resource/Sales Invoice/'.$invoiceId);
|
||||||
|
$this->assertSame($invoiceId, $response['data']['name']);
|
||||||
|
$this->assertEquals(1337.0, $response['data']['grand_total']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -44,12 +44,15 @@ final class FrappeErpAdapterTest extends TestCase
|
||||||
public function test_create_sales_invoice_submits_and_returns_id(): void
|
public function test_create_sales_invoice_submits_and_returns_id(): void
|
||||||
{
|
{
|
||||||
$this->frappe
|
$this->frappe
|
||||||
->expects($this->exactly(2))
|
->expects($this->once())
|
||||||
->method('post')
|
->method('post')
|
||||||
->willReturnOnConsecutiveCalls(
|
->willReturn(['data' => ['name' => 'SINV-00001']]);
|
||||||
['data' => ['name' => 'SINV-00001']],
|
|
||||||
['data' => ['name' => 'SINV-00001', 'docstatus' => 1]],
|
$this->frappe
|
||||||
);
|
->expects($this->once())
|
||||||
|
->method('put')
|
||||||
|
->with($this->stringContains('SINV-00001'), ['docstatus' => 1])
|
||||||
|
->willReturn(['data' => ['name' => 'SINV-00001', 'docstatus' => 1]]);
|
||||||
|
|
||||||
$customer = new Customer('Max Mustermann', 'max@test.de', ['street' => 'Str 1', 'city' => 'Berlin', 'zip' => '10115']);
|
$customer = new Customer('Max Mustermann', 'max@test.de', ['street' => 'Str 1', 'city' => 'Berlin', 'zip' => '10115']);
|
||||||
$customer->setFrappeCustomerId('CUST-00001');
|
$customer->setFrappeCustomerId('CUST-00001');
|
||||||
|
|
@ -79,4 +82,101 @@ final class FrappeErpAdapterTest extends TestCase
|
||||||
|
|
||||||
$this->assertStringStartsWith('%PDF', $result);
|
$this->assertStringStartsWith('%PDF', $result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_find_existing_customer_returns_id_when_address_matches(): void
|
||||||
|
{
|
||||||
|
$this->frappe
|
||||||
|
->expects($this->exactly(2))
|
||||||
|
->method('get')
|
||||||
|
->willReturnOnConsecutiveCalls(
|
||||||
|
['data' => [['name' => 'CUST-99999']]],
|
||||||
|
['data' => [['address_line1' => 'Kirchstr. 1', 'city' => 'Karbach', 'pincode' => '56281']]],
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $this->adapter->findExistingCustomer('Simon Kühn', 'Kirchstr. 1', 'Karbach', '56281');
|
||||||
|
|
||||||
|
$this->assertSame('CUST-99999', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_find_existing_customer_returns_null_when_address_mismatch(): void
|
||||||
|
{
|
||||||
|
$this->frappe
|
||||||
|
->expects($this->exactly(2))
|
||||||
|
->method('get')
|
||||||
|
->willReturnOnConsecutiveCalls(
|
||||||
|
['data' => [['name' => 'CUST-99999']]],
|
||||||
|
['data' => [['address_line1' => 'Musterstr. 5', 'city' => 'Berlin', 'pincode' => '10115']]],
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $this->adapter->findExistingCustomer('Simon Kühn', 'Kirchstr. 1', 'Karbach', '56281');
|
||||||
|
|
||||||
|
$this->assertNull($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_find_existing_customer_returns_null_when_not_in_erp(): void
|
||||||
|
{
|
||||||
|
$this->frappe
|
||||||
|
->expects($this->once())
|
||||||
|
->method('get')
|
||||||
|
->willReturn(['data' => []]);
|
||||||
|
|
||||||
|
$result = $this->adapter->findExistingCustomer('Nobody', 'Unknown St. 1', 'Nowhere', '00000');
|
||||||
|
|
||||||
|
$this->assertNull($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_find_simon_and_create_invoice_for_1337(): void
|
||||||
|
{
|
||||||
|
$this->frappe
|
||||||
|
->expects($this->exactly(2))
|
||||||
|
->method('get')
|
||||||
|
->willReturnOnConsecutiveCalls(
|
||||||
|
['data' => [['name' => 'CUST-99999']]],
|
||||||
|
['data' => [['address_line1' => 'Kirchstr. 1', 'city' => 'Karbach', 'pincode' => '56281']]],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->frappe
|
||||||
|
->expects($this->once())
|
||||||
|
->method('post')
|
||||||
|
->willReturn(['data' => ['name' => 'SINV-13370']]);
|
||||||
|
|
||||||
|
$this->frappe
|
||||||
|
->expects($this->once())
|
||||||
|
->method('put')
|
||||||
|
->willReturn(['data' => ['name' => 'SINV-13370', 'docstatus' => 1]]);
|
||||||
|
|
||||||
|
$frappeId = $this->adapter->findExistingCustomer('Simon Kühn', 'Kirchstr. 1', 'Karbach', '56281');
|
||||||
|
$this->assertSame('CUST-99999', $frappeId);
|
||||||
|
|
||||||
|
$simon = new Customer('Simon Kühn', 'simon.kuehn83@gmail.com', [
|
||||||
|
'street' => 'Kirchstr. 1',
|
||||||
|
'city' => 'Karbach',
|
||||||
|
'zip' => '56281',
|
||||||
|
]);
|
||||||
|
$simon->setFrappeCustomerId($frappeId);
|
||||||
|
|
||||||
|
$article = new Article(
|
||||||
|
new ArticleType('Laptop'),
|
||||||
|
'LAP-THINK-001',
|
||||||
|
'INV-THINK-001',
|
||||||
|
1,
|
||||||
|
ArticleCondition::Good,
|
||||||
|
);
|
||||||
|
$article->setManufacturer('Lenovo');
|
||||||
|
$article->setModelName('ThinkBook 14 G6 IRL');
|
||||||
|
$article->setModelNumber('21KG00NQGE');
|
||||||
|
$article->setEbayTitle('Lenovo ThinkBook 14 G6 IRL — generalüberholt, top Zustand');
|
||||||
|
|
||||||
|
$order = new Order(
|
||||||
|
$article,
|
||||||
|
$simon,
|
||||||
|
new Platform('direct', 'Direktverkauf'),
|
||||||
|
'ORDER-SIMON-1337',
|
||||||
|
'1337.00',
|
||||||
|
new \DateTimeImmutable('2026-05-18'),
|
||||||
|
);
|
||||||
|
|
||||||
|
$invoiceId = $this->adapter->createSalesInvoice($order);
|
||||||
|
$this->assertSame('SINV-13370', $invoiceId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ field.attempts: Versuche
|
||||||
field.started: Gestartet
|
field.started: Gestartet
|
||||||
field.error: Fehler
|
field.error: Fehler
|
||||||
field.ai_results: 'KI-Ergebnisse'
|
field.ai_results: 'KI-Ergebnisse'
|
||||||
|
field.stock: Anzahl
|
||||||
field.price: Preis
|
field.price: Preis
|
||||||
field.condition: Zustand
|
field.condition: Zustand
|
||||||
field.manufacturer: Hersteller
|
field.manufacturer: Hersteller
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ field.attempts: Attempts
|
||||||
field.started: Started
|
field.started: Started
|
||||||
field.error: Error
|
field.error: Error
|
||||||
field.ai_results: 'AI Results'
|
field.ai_results: 'AI Results'
|
||||||
|
field.stock: Stock
|
||||||
field.price: Price
|
field.price: Price
|
||||||
field.condition: Condition
|
field.condition: Condition
|
||||||
field.manufacturer: Manufacturer
|
field.manufacturer: Manufacturer
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue