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>
2026-05-18 16:42:15 +00:00
|
|
|
<?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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 10:56:37 +00:00
|
|
|
public function testFindSimonKuehnByNameAndAddress(): void
|
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>
2026-05-18 16:42:15 +00:00
|
|
|
{
|
|
|
|
|
$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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 10:56:37 +00:00
|
|
|
public function testUnknownPersonIsNotFound(): void
|
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>
2026-05-18 16:42:15 +00:00
|
|
|
{
|
|
|
|
|
$customerId = $this->adapter->findExistingCustomer(
|
|
|
|
|
'Voldemort',
|
|
|
|
|
'Dunkle Gasse 1',
|
|
|
|
|
'Nirgendwo',
|
|
|
|
|
'00000',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
$this->assertNull($customerId);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 10:56:37 +00:00
|
|
|
public function testFindSimonAndCreateInvoiceFor1337(): void
|
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>
2026-05-18 16:42:15 +00:00
|
|
|
{
|
|
|
|
|
$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']);
|
|
|
|
|
}
|
|
|
|
|
}
|