From 04dce290a19d538e4b062d38cb4fceff542b60ae Mon Sep 17 00:00:00 2001 From: R0b3r7DEV Date: Sat, 11 Jul 2026 12:26:18 +0200 Subject: [PATCH] =?UTF-8?q?feat(partes):=20partes=20de=20trabajo=20con=20c?= =?UTF-8?q?onversi=C3=B3n=20idempotente=20a=20factura=20(P1=20backend)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ES -- El 80% del trabajo de un electricista son avisos y reparaciones, no instalaciones nuevas. Este PR añade el backend de los partes de trabajo. - Entidades WorkOrder + WorkOrderLine: cliente, título, descripción, estado (pendiente→en_curso→terminado→facturado), fecha, mano de obra (horas × precio + IVA) y líneas de material en céntimos enteros. Solo tablas nuevas: la cadena de hash de facturas no se toca. - WorkOrderService::convert() reutiliza el patrón de QuoteService::convert(): el enlace convertedInvoice lo hace idempotente (convertir dos veces = misma factura). Materiales → líneas; mano de obra → una línea "Mano de obra (N h)". Se emite por InvoiceService::create(), así que hereda numeración sin huecos + el registro de la cadena y respeta el modo de facturación (ADR 0004). - WorkOrderController: CRUD + /convert, acotado al usuario; parte facturado inmutable (409). - Tests: WorkOrderServiceTest + integración que convierte dos veces y comprueba UNA sola factura (total = materiales + mano de obra), aislamiento e inmutabilidad. 91 tests, 401 aserciones. La UI móvil, las fotos y la firma del cliente van en el siguiente PR. EN -- 80% of an electrician's work is call-outs and repairs. This PR adds the work-order backend: WorkOrder + WorkOrderLine entities, an idempotent convert-to-invoice (reusing QuoteService::convert()'s convertedInvoice link — convert twice = same invoice), CRUD scoped per user, and tests (convert twice ⇒ one invoice, isolation, invoiced-immutability). New tables only; the invoice hash chain is untouched. 91 tests, 401 assertions. Mobile UI, photos and signature come next. Docs: guide 39, DEVLOG 049. --- backend/migrations/Version20260711101940.php | 46 +++++ .../src/Controller/WorkOrderController.php | 142 ++++++++++++++ backend/src/Entity/WorkOrder.php | 136 +++++++++++++ backend/src/Entity/WorkOrderLine.php | 63 ++++++ .../src/Repository/WorkOrderRepository.php | 33 ++++ backend/src/Service/WorkOrderService.php | 183 ++++++++++++++++++ backend/tests/Api/ApiIntegrationTest.php | 56 ++++++ .../tests/Service/WorkOrderServiceTest.php | 67 +++++++ docs/DEVLOG.md | 32 +++ docs/guide/39-work-orders.md | 57 ++++++ 10 files changed, 815 insertions(+) create mode 100644 backend/migrations/Version20260711101940.php create mode 100644 backend/src/Controller/WorkOrderController.php create mode 100644 backend/src/Entity/WorkOrder.php create mode 100644 backend/src/Entity/WorkOrderLine.php create mode 100644 backend/src/Repository/WorkOrderRepository.php create mode 100644 backend/src/Service/WorkOrderService.php create mode 100644 backend/tests/Service/WorkOrderServiceTest.php create mode 100644 docs/guide/39-work-orders.md diff --git a/backend/migrations/Version20260711101940.php b/backend/migrations/Version20260711101940.php new file mode 100644 index 0000000..b8c21bd --- /dev/null +++ b/backend/migrations/Version20260711101940.php @@ -0,0 +1,46 @@ +addSql('CREATE TABLE work_order (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, title VARCHAR(150) NOT NULL, description TEXT DEFAULT NULL, status VARCHAR(12) NOT NULL, scheduled_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, labor_hours NUMERIC(6, 2) NOT NULL, labor_rate NUMERIC(12, 2) NOT NULL, labor_vat_rate NUMERIC(5, 2) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, user_id INT NOT NULL, customer_id INT NOT NULL, converted_invoice_id INT DEFAULT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX IDX_DDD2E8B7A76ED395 ON work_order (user_id)'); + $this->addSql('CREATE INDEX IDX_DDD2E8B79395C3F3 ON work_order (customer_id)'); + $this->addSql('CREATE INDEX IDX_DDD2E8B79DC100AB ON work_order (converted_invoice_id)'); + $this->addSql('CREATE TABLE work_order_line (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, description VARCHAR(255) NOT NULL, quantity INT NOT NULL, unit_price NUMERIC(12, 2) NOT NULL, vat_rate NUMERIC(5, 2) NOT NULL, work_order_id INT NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX IDX_87862332582AE764 ON work_order_line (work_order_id)'); + $this->addSql('ALTER TABLE work_order ADD CONSTRAINT FK_DDD2E8B7A76ED395 FOREIGN KEY (user_id) REFERENCES app_user (id) NOT DEFERRABLE'); + $this->addSql('ALTER TABLE work_order ADD CONSTRAINT FK_DDD2E8B79395C3F3 FOREIGN KEY (customer_id) REFERENCES customer (id) NOT DEFERRABLE'); + $this->addSql('ALTER TABLE work_order ADD CONSTRAINT FK_DDD2E8B79DC100AB FOREIGN KEY (converted_invoice_id) REFERENCES invoice (id) NOT DEFERRABLE'); + $this->addSql('ALTER TABLE work_order_line ADD CONSTRAINT FK_87862332582AE764 FOREIGN KEY (work_order_id) REFERENCES work_order (id) NOT DEFERRABLE'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE work_order DROP CONSTRAINT FK_DDD2E8B7A76ED395'); + $this->addSql('ALTER TABLE work_order DROP CONSTRAINT FK_DDD2E8B79395C3F3'); + $this->addSql('ALTER TABLE work_order DROP CONSTRAINT FK_DDD2E8B79DC100AB'); + $this->addSql('ALTER TABLE work_order_line DROP CONSTRAINT FK_87862332582AE764'); + $this->addSql('DROP TABLE work_order'); + $this->addSql('DROP TABLE work_order_line'); + } +} diff --git a/backend/src/Controller/WorkOrderController.php b/backend/src/Controller/WorkOrderController.php new file mode 100644 index 0000000..2581f7a --- /dev/null +++ b/backend/src/Controller/WorkOrderController.php @@ -0,0 +1,142 @@ + [ + 'id' => $w->getId(), + 'title' => $w->getTitle(), + 'customer' => $w->getCustomer()?->getName(), + 'status' => $w->getStatus(), + 'scheduledAt' => $w->getScheduledAt()?->format('Y-m-d H:i'), + 'invoiced' => $w->getConvertedInvoice() !== null, + ], $repo->findForUser($user)); + + return $this->json($rows); + } + + #[Route('/api/work-orders', name: 'api_work_orders_create', methods: ['POST'])] + public function create(Request $request, WorkOrderService $service, #[CurrentUser] User $user): JsonResponse + { + $data = json_decode($request->getContent(), true); + if (!is_array($data)) { + return $this->json(['error' => 'Invalid JSON'], 400); + } + try { + return $this->json($this->detail($service->create($user, $data)), 201); + } catch (\Throwable $e) { + return $this->json(['error' => $e->getMessage()], 400); + } + } + + #[Route('/api/work-orders/{id}', name: 'api_work_orders_get', methods: ['GET'], requirements: ['id' => '\d+'])] + public function get(int $id, WorkOrderRepository $repo, #[CurrentUser] User $user): JsonResponse + { + $wo = $repo->findOwned($id, $user); + + return $wo === null ? $this->json(['error' => 'Not found'], 404) : $this->json($this->detail($wo)); + } + + #[Route('/api/work-orders/{id}', name: 'api_work_orders_update', methods: ['PUT'], requirements: ['id' => '\d+'])] + public function update(int $id, Request $request, WorkOrderRepository $repo, WorkOrderService $service, #[CurrentUser] User $user): JsonResponse + { + $wo = $repo->findOwned($id, $user); + if ($wo === null) { + return $this->json(['error' => 'Not found'], 404); + } + if ($wo->getConvertedInvoice() !== null) { + return $this->json(['error' => 'invoiced_immutable', 'message' => 'Un parte ya facturado no se puede modificar.'], 409); + } + $data = json_decode($request->getContent(), true); + if (!is_array($data)) { + return $this->json(['error' => 'Invalid JSON'], 400); + } + + return $this->json($this->detail($service->update($wo, $data))); + } + + #[Route('/api/work-orders/{id}', name: 'api_work_orders_delete', methods: ['DELETE'], requirements: ['id' => '\d+'])] + public function delete(int $id, WorkOrderRepository $repo, EntityManagerInterface $em, #[CurrentUser] User $user): JsonResponse + { + $wo = $repo->findOwned($id, $user); + if ($wo === null) { + return $this->json(['error' => 'Not found'], 404); + } + if ($wo->getConvertedInvoice() !== null) { + return $this->json(['error' => 'invoiced_immutable', 'message' => 'Un parte ya facturado no se puede borrar.'], 409); + } + $em->remove($wo); + $em->flush(); + + return $this->json(['deleted' => true]); + } + + #[Route('/api/work-orders/{id}/convert', name: 'api_work_orders_convert', methods: ['POST'], requirements: ['id' => '\d+'])] + public function convert(int $id, WorkOrderRepository $repo, WorkOrderService $service, #[CurrentUser] User $user): JsonResponse + { + $wo = $repo->findOwned($id, $user); + if ($wo === null) { + return $this->json(['error' => 'Not found'], 404); + } + try { + $invoice = $service->convert($user, $wo); + } catch (\Throwable $e) { + return $this->json(['error' => $e->getMessage()], 400); + } + + return $this->json([ + 'invoiceId' => $invoice->getId(), + 'invoiceNumber' => $invoice->getFullNumber(), + 'status' => $wo->getStatus(), + ], 201); + } + + /** @return array */ + private function detail(WorkOrder $w): array + { + return [ + 'id' => $w->getId(), + 'title' => $w->getTitle(), + 'description' => $w->getDescription(), + 'status' => $w->getStatus(), + 'scheduledAt' => $w->getScheduledAt()?->format('Y-m-d H:i'), + 'customer' => [ + 'id' => $w->getCustomer()?->getId(), + 'name' => $w->getCustomer()?->getName(), + 'taxId' => $w->getCustomer()?->getTaxId(), + ], + 'laborHours' => $w->getLaborHours(), + 'laborRate' => $w->getLaborRate(), + 'laborVatRate' => $w->getLaborVatRate(), + 'lines' => array_map(static fn ($l) => [ + 'description' => $l->getDescription(), + 'quantity' => $l->getQuantity(), + 'unitPrice' => $l->getUnitPrice(), + 'vatRate' => $l->getVatRate(), + ], $w->getLines()->toArray()), + 'convertedInvoice' => $w->getConvertedInvoice() === null ? null : [ + 'id' => $w->getConvertedInvoice()->getId(), + 'number' => $w->getConvertedInvoice()->getFullNumber(), + ], + ]; + } +} diff --git a/backend/src/Entity/WorkOrder.php b/backend/src/Entity/WorkOrder.php new file mode 100644 index 0000000..2fc1aeb --- /dev/null +++ b/backend/src/Entity/WorkOrder.php @@ -0,0 +1,136 @@ + */ + #[ORM\OneToMany(mappedBy: 'workOrder', targetEntity: WorkOrderLine::class, cascade: ['persist'], orphanRemoval: true)] + private Collection $lines; + + public function __construct() + { + $this->createdAt = new \DateTimeImmutable(); + $this->lines = new ArrayCollection(); + } + + public function getId(): ?int { return $this->id; } + + public function getUser(): ?User { return $this->user; } + public function setUser(?User $user): self { $this->user = $user; return $this; } + + public function getCustomer(): ?Customer { return $this->customer; } + public function setCustomer(?Customer $customer): self { $this->customer = $customer; return $this; } + + public function getTitle(): string { return $this->title; } + public function setTitle(string $title): self { $this->title = $title; return $this; } + + public function getDescription(): ?string { return $this->description; } + public function setDescription(?string $description): self { $this->description = $description; return $this; } + + public function getStatus(): string { return $this->status; } + public function setStatus(string $status): self + { + $this->status = in_array($status, self::STATUSES, true) ? $status : 'pendiente'; + return $this; + } + + public function getScheduledAt(): ?\DateTimeImmutable { return $this->scheduledAt; } + public function setScheduledAt(?\DateTimeImmutable $v): self { $this->scheduledAt = $v; return $this; } + + public function getLaborHours(): string { return $this->laborHours; } + public function setLaborHours(string $v): self { $this->laborHours = $v; return $this; } + + public function getLaborRate(): string { return $this->laborRate; } + public function setLaborRate(string $v): self { $this->laborRate = $v; return $this; } + + public function getLaborVatRate(): string { return $this->laborVatRate; } + public function setLaborVatRate(string $v): self { $this->laborVatRate = $v; return $this; } + + public function getConvertedInvoice(): ?Invoice { return $this->convertedInvoice; } + public function setConvertedInvoice(?Invoice $invoice): self { $this->convertedInvoice = $invoice; return $this; } + + public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; } + + /** Labour amount in integer cents (hours × rate). / Importe de mano de obra en céntimos. */ + public function laborBaseCents(): int + { + return (int) round((float) $this->laborHours * (float) $this->laborRate * 100); + } + + /** @return Collection */ + public function getLines(): Collection { return $this->lines; } + + public function addLine(WorkOrderLine $line): self + { + if (!$this->lines->contains($line)) { + $this->lines->add($line); + $line->setWorkOrder($this); + } + return $this; + } +} diff --git a/backend/src/Entity/WorkOrderLine.php b/backend/src/Entity/WorkOrderLine.php new file mode 100644 index 0000000..01b5911 --- /dev/null +++ b/backend/src/Entity/WorkOrderLine.php @@ -0,0 +1,63 @@ +id; } + + public function getWorkOrder(): ?WorkOrder { return $this->workOrder; } + public function setWorkOrder(?WorkOrder $workOrder): self { $this->workOrder = $workOrder; return $this; } + + public function getDescription(): string { return $this->description; } + public function setDescription(string $description): self { $this->description = $description; return $this; } + + public function getQuantity(): int { return $this->quantity; } + public function setQuantity(int $quantity): self { $this->quantity = $quantity; return $this; } + + public function getUnitPrice(): string { return $this->unitPrice; } + public function setUnitPrice(string $unitPrice): self { $this->unitPrice = $unitPrice; return $this; } + + public function getVatRate(): string { return $this->vatRate; } + public function setVatRate(string $vatRate): self { $this->vatRate = $vatRate; return $this; } + + /** Line base in integer cents. / Base de línea en céntimos enteros. */ + public function baseCents(): int + { + return (int) round((float) $this->unitPrice * 100) * $this->quantity; + } + + /** Line VAT in integer cents. / IVA de línea en céntimos enteros. */ + public function vatCents(): int + { + return (int) round($this->baseCents() * (float) $this->vatRate / 100); + } +} diff --git a/backend/src/Repository/WorkOrderRepository.php b/backend/src/Repository/WorkOrderRepository.php new file mode 100644 index 0000000..fc8acf9 --- /dev/null +++ b/backend/src/Repository/WorkOrderRepository.php @@ -0,0 +1,33 @@ + + */ +class WorkOrderRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, WorkOrder::class); + } + + /** @return WorkOrder[] newest first */ + public function findForUser(User $user): array + { + return $this->findBy(['user' => $user], ['createdAt' => 'DESC', 'id' => 'DESC']); + } + + /** A work order by id, only if it belongs to this user. */ + public function findOwned(int $id, User $user): ?WorkOrder + { + $wo = $this->find($id); + + return ($wo !== null && $wo->getUser() === $user) ? $wo : null; + } +} diff --git a/backend/src/Service/WorkOrderService.php b/backend/src/Service/WorkOrderService.php new file mode 100644 index 0000000..1222643 --- /dev/null +++ b/backend/src/Service/WorkOrderService.php @@ -0,0 +1,183 @@ + $data */ + public function create(User $user, array $data): WorkOrder + { + $title = trim((string) ($data['title'] ?? '')); + if ($title === '') { + throw new \InvalidArgumentException('A work order needs a title'); + } + + $wo = (new WorkOrder()) + ->setUser($user) + ->setCustomer($this->resolveCustomer($user, $data)) + ->setTitle($title) + ->setDescription(isset($data['description']) ? trim((string) $data['description']) : null) + ->setScheduledAt(!empty($data['scheduledAt']) ? new \DateTimeImmutable((string) $data['scheduledAt']) : null) + ->setLaborHours($this->decimal($data['laborHours'] ?? '0')) + ->setLaborRate($this->decimal($data['laborRate'] ?? '0')) + ->setLaborVatRate($this->decimal($data['laborVatRate'] ?? '21')) + ->setStatus((string) ($data['status'] ?? 'pendiente')); + + $this->applyLines($wo, is_array($data['lines'] ?? null) ? $data['lines'] : []); + + $this->em->persist($wo); + $this->em->flush(); + + return $wo; + } + + /** @param array $data */ + public function update(WorkOrder $wo, array $data): WorkOrder + { + if (array_key_exists('title', $data) && trim((string) $data['title']) !== '') { + $wo->setTitle(trim((string) $data['title'])); + } + if (array_key_exists('description', $data)) { + $wo->setDescription(trim((string) $data['description']) ?: null); + } + if (array_key_exists('status', $data)) { + $wo->setStatus((string) $data['status']); + } + if (array_key_exists('scheduledAt', $data)) { + $wo->setScheduledAt(!empty($data['scheduledAt']) ? new \DateTimeImmutable((string) $data['scheduledAt']) : null); + } + foreach (['laborHours', 'laborRate', 'laborVatRate'] as $k) { + if (array_key_exists($k, $data)) { + $wo->{'set' . ucfirst($k)}($this->decimal($data[$k])); + } + } + if (array_key_exists('lines', $data)) { + foreach ($wo->getLines()->toArray() as $line) { + $wo->getLines()->removeElement($line); + $this->em->remove($line); + } + $this->applyLines($wo, is_array($data['lines']) ? $data['lines'] : []); + } + $this->em->flush(); + + return $wo; + } + + /** + * Convert a work order into a real invoice (idempotent). Materials become invoice lines; the labour + * hours become one "Mano de obra" line (hours × rate). Marks the order 'facturado' and links it. + * ES: Convierte un parte en factura real (idempotente). Los materiales pasan a líneas; la mano de obra a + * una línea "Mano de obra" (horas × precio). Marca el parte 'facturado' y lo enlaza. + */ + public function convert(User $user, WorkOrder $wo): Invoice + { + if ($wo->getConvertedInvoice() !== null) { + return $wo->getConvertedInvoice(); + } + + $lines = []; + foreach ($wo->getLines() as $l) { + $lines[] = [ + 'description' => $l->getDescription(), + 'quantity' => $l->getQuantity(), + 'unitPrice' => $l->getUnitPrice(), + 'vatRate' => $l->getVatRate(), + ]; + } + + $laborCents = $wo->laborBaseCents(); + if ($laborCents > 0) { + $lines[] = [ + 'description' => 'Mano de obra (' . rtrim(rtrim($wo->getLaborHours(), '0'), '.') . ' h)', + 'quantity' => 1, + 'unitPrice' => number_format($laborCents / 100, 2, '.', ''), + 'vatRate' => $wo->getLaborVatRate(), + ]; + } + + if ($lines === []) { + throw new \InvalidArgumentException('A work order needs materials or labour to be invoiced'); + } + + $invoice = $this->invoices->create($user, [ + 'customerId' => $wo->getCustomer()?->getId(), + 'lines' => $lines, + ]); + + $wo->setStatus('facturado')->setConvertedInvoice($invoice); + $this->em->flush(); + + return $invoice; + } + + /** @param array> $lines */ + private function applyLines(WorkOrder $wo, array $lines): void + { + foreach ($lines as $l) { + if (!is_array($l)) { + continue; + } + $wo->addLine((new WorkOrderLine()) + ->setDescription(trim((string) ($l['description'] ?? ''))) + ->setQuantity(max(1, (int) ($l['quantity'] ?? 1))) + ->setUnitPrice($this->decimal($l['unitPrice'] ?? '0')) + ->setVatRate($this->decimal($l['vatRate'] ?? '0'))); + } + } + + /** @param array $data */ + private function resolveCustomer(User $user, array $data): Customer + { + $customerId = $data['customerId'] ?? ($data['customer']['id'] ?? null); + if ($customerId !== null) { + $existing = $this->customers->find((int) $customerId); + if ($existing !== null && $existing->getUser() === $user) { + return $existing; + } + } + + $c = is_array($data['customer'] ?? null) ? $data['customer'] : []; + $taxId = trim((string) ($c['taxId'] ?? '')); + $existing = $taxId !== '' ? $this->customers->findOneBy(['user' => $user, 'taxId' => $taxId]) : null; + if ($existing !== null) { + return $existing; + } + + $customer = (new Customer()) + ->setUser($user) + ->setName(trim((string) ($c['name'] ?? 'Cliente'))) + ->setTaxId($taxId !== '' ? $taxId : 'N/A') + ->setAddress(isset($c['address']) ? trim((string) $c['address']) : null) + ->setEmail(isset($c['email']) ? trim((string) $c['email']) : null); + $this->em->persist($customer); + + return $customer; + } + + private function decimal(string|int|float $v): string + { + return number_format((float) $v, 2, '.', ''); + } +} diff --git a/backend/tests/Api/ApiIntegrationTest.php b/backend/tests/Api/ApiIntegrationTest.php index 9d8271c..ade9ac9 100644 --- a/backend/tests/Api/ApiIntegrationTest.php +++ b/backend/tests/Api/ApiIntegrationTest.php @@ -271,6 +271,62 @@ public function testBillingSettingsPersistIssuerProfileAndRejectBadMode(): void self::assertSame('standard', $d2['billingMode']); } + public function testWorkOrderConvertsToInvoiceIdempotently(): void + { + $this->registerAndLogin('parte@test.local'); + + [$sc, $wo] = $this->json('POST', '/api/work-orders', [ + 'title' => 'Avería en cuadro', + 'customer' => ['name' => 'Comunidad Sol', 'taxId' => 'H1'], + 'laborHours' => '2', 'laborRate' => '35', + 'lines' => [['description' => 'Magnetotérmico 16A', 'quantity' => 2, 'unitPrice' => '12.00', 'vatRate' => '21.00']], + ]); + self::assertSame(201, $sc); + self::assertSame('pendiente', $wo['status']); + $id = $wo['id']; + + // convert once → a real invoice; the order becomes 'facturado' + [$c1, $inv1] = $this->json('POST', "/api/work-orders/$id/convert"); + self::assertSame(201, $c1); + $invoiceId = $inv1['invoiceId']; + + // converting again returns the SAME invoice and never duplicates + [$c2, $inv2] = $this->json('POST', "/api/work-orders/$id/convert"); + self::assertSame(201, $c2); + self::assertSame($invoiceId, $inv2['invoiceId'], 'a work order converts to exactly one invoice'); + + [, $invoices] = $this->json('GET', '/api/invoices'); + self::assertCount(1, $invoices, 'converting twice must not create two invoices'); + + // the invoice carries materials (2 × 12.00 = 24.00 + 21% = 29.04) + labour (2h × 35 = 70.00 + 21% = 84.70) + [, $detail] = $this->json('GET', "/api/invoices/$invoiceId"); + self::assertCount(2, $detail['lines']); + self::assertSame('113.74', $detail['total']); + + // an invoiced order is immutable + [$us] = $this->json('PUT', "/api/work-orders/$id", ['title' => 'x']); + self::assertSame(409, $us); + [$ds] = $this->json('DELETE', "/api/work-orders/$id"); + self::assertSame(409, $ds); + } + + public function testWorkOrdersAreScopedPerUser(): void + { + $this->registerAndLogin('woA@test.local'); + [, $wo] = $this->json('POST', '/api/work-orders', [ + 'title' => 'Mío', 'customer' => ['name' => 'C', 'taxId' => 'B1'], + 'lines' => [['description' => 'x', 'unitPrice' => '10.00', 'vatRate' => '21.00']], + ]); + $id = $wo['id']; + + $this->json('POST', '/api/logout'); + $this->registerAndLogin('woB@test.local'); + [, $list] = $this->json('GET', '/api/work-orders'); + self::assertCount(0, $list, 'a user never sees another user\'s work orders'); + [$gs] = $this->json('GET', "/api/work-orders/$id"); + self::assertSame(404, $gs); + } + public function testOpenBankingReportsDisabledWithoutCredentials(): void { $this->registerAndLogin('bank@test.local'); diff --git a/backend/tests/Service/WorkOrderServiceTest.php b/backend/tests/Service/WorkOrderServiceTest.php new file mode 100644 index 0000000..3d3e9c6 --- /dev/null +++ b/backend/tests/Service/WorkOrderServiceTest.php @@ -0,0 +1,67 @@ +createStub(EntityManagerInterface::class); + $customers = $this->createStub(CustomerRepository::class); + $customers->method('findOneBy')->willReturn(null); + + return new WorkOrderService($em, $customers, $invoices ?? $this->createStub(InvoiceService::class)); + } + + public function testCreateStoresTitleLinesAndLabourAndDefaultsToPendiente(): void + { + $wo = $this->service()->create(new User(), [ + 'title' => 'Cambiar diferencial', + 'customer' => ['name' => 'Vecino', 'taxId' => 'B1'], + 'laborHours' => '1.5', 'laborRate' => '30', + 'lines' => [['description' => 'Diferencial 40A', 'quantity' => 1, 'unitPrice' => '45.00', 'vatRate' => '21.00']], + ]); + + self::assertSame('Cambiar diferencial', $wo->getTitle()); + self::assertSame('pendiente', $wo->getStatus()); + self::assertCount(1, $wo->getLines()); + self::assertSame('1.50', $wo->getLaborHours()); + self::assertSame('30.00', $wo->getLaborRate()); + // labour amount in cents: 1.5 h × 30 €/h = 45.00 € + self::assertSame(4500, $wo->laborBaseCents()); + } + + public function testRejectsWorkOrderWithoutTitle(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->service()->create(new User(), ['customer' => ['name' => 'X', 'taxId' => 'Y'], 'lines' => []]); + } + + public function testInvalidStatusFallsBackToPendiente(): void + { + $wo = $this->service()->create(new User(), ['title' => 'x', 'customer' => ['name' => 'X', 'taxId' => 'Y'], 'status' => 'nonsense']); + self::assertSame('pendiente', $wo->getStatus()); + } + + /** Idempotency guard: an already-converted order returns its invoice without invoicing again. */ + public function testConvertIsIdempotentAndNeverInvoicesTwice(): void + { + $invoices = $this->createMock(InvoiceService::class); + $invoices->expects(self::never())->method('create'); // must NOT create a second invoice + + $existing = new Invoice(); + $wo = (new WorkOrder())->setConvertedInvoice($existing); + + $result = $this->service($invoices)->convert(new User(), $wo); + self::assertSame($existing, $result); + } +} diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 7b59c0d..23715ab 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -12,6 +12,22 @@ recientes van arriba.* ## English +### 2026-07-11 — Entry 049: Work orders — the electrician's everyday job (P1, backend) +**Done (P1 of [PLAN-v2](../PLAN-v2.md), backend PR)** +- `WorkOrder` + `WorkOrderLine` entities: customer, title, description, status + (`pendiente→en_curso→terminado→facturado`), scheduled time, labour (hours × rate + VAT) and material + lines in exact integer cents. New tables only — the invoice hash chain is untouched. +- `WorkOrderService::convert()` reuses `QuoteService::convert()` **exactly**: a `convertedInvoice` link makes + it idempotent (convert twice ⇒ same invoice). Materials → invoice lines, labour → one "Mano de obra (N h)" + line. Issued through `InvoiceService::create()`, so it inherits gapless numbering + the chain record and + respects the billing mode (ADR 0004). +- `WorkOrderController`: CRUD + `/convert`, all user-scoped; an invoiced order is immutable (409). +- Tests: `WorkOrderServiceTest` + an integration test that **converts twice and asserts a single invoice** + (total = materials + labour), isolation, immutability. 91 tests, 401 assertions. + +**Next** +- P1 PR2 — mobile UI, photos and the client signature. + ### 2026-07-11 — Entry 048: Dual billing mode — real invoices by default, Verifactu as a demo **What happened** - Every invoice carried an AEAT **test-host QR** and a "Verifactu" legend, because `InvoiceService` always @@ -831,6 +847,22 @@ recientes van arriba.* ## Español +### 2026-07-11 — Entrada 049: Partes de trabajo — el día a día del electricista (P1, backend) +**Hecho (P1 del [PLAN-v2](../PLAN-v2.md), PR de backend)** +- Entidades `WorkOrder` + `WorkOrderLine`: cliente, título, descripción, estado + (`pendiente→en_curso→terminado→facturado`), fecha programada, mano de obra (horas × precio + IVA) y líneas + de material en céntimos enteros. Solo tablas nuevas — la cadena de hash de facturas queda intacta. +- `WorkOrderService::convert()` reutiliza `QuoteService::convert()` **igual**: el enlace `convertedInvoice` + lo hace idempotente (convertir dos veces ⇒ misma factura). Materiales → líneas; mano de obra → una línea + "Mano de obra (N h)". Se emite por `InvoiceService::create()`, así que hereda numeración sin huecos + el + registro de la cadena y respeta el modo de facturación (ADR 0004). +- `WorkOrderController`: CRUD + `/convert`, todo acotado al usuario; un parte facturado es inmutable (409). +- Tests: `WorkOrderServiceTest` + un test de integración que **convierte dos veces y comprueba una sola + factura** (total = materiales + mano de obra), aislamiento e inmutabilidad. 91 tests, 401 aserciones. + +**Siguiente** +- P1 PR2 — UI móvil, fotos y firma del cliente. + ### 2026-07-11 — Entrada 048: Modo dual de facturación — facturas reales por defecto, Verifactu como demo **Qué pasaba** - Toda factura llevaba un **QR del host de pruebas** de la AEAT y una leyenda «Verifactu», porque diff --git a/docs/guide/39-work-orders.md b/docs/guide/39-work-orders.md new file mode 100644 index 0000000..738201c --- /dev/null +++ b/docs/guide/39-work-orders.md @@ -0,0 +1,57 @@ +# 39 — Work orders (partes de trabajo): the everyday job · el día a día + +Goal / Objetivo: model the 80 % of an electrician's real work — call-outs and repairs — as a **work order** +that is filled in at the site and, when finished, **converts into a real invoice** without ever +duplicating. + +*Objetivo: modelar el 80 % del trabajo real de un electricista —avisos y reparaciones— como un **parte de +trabajo** que se rellena a pie de obra y, al terminar, **se convierte en factura real** sin duplicar nunca.* + +> This guide covers the **backend** (entities, service, API). The mobile UI, photos and the client +> signature land in the next step (PR3). / Esta guía cubre el **backend**; la UI móvil, las fotos y la firma +> del cliente llegan en el siguiente paso. + +--- + +## The model / El modelo + +- **`WorkOrder`**: `user`, `customer`, `title`, `description`, `status`, `scheduledAt`, labour (`laborHours` + × `laborRate`, plus `laborVatRate`), a `convertedInvoice` link, and material `lines`. +- **`WorkOrderLine`**: description, quantity, unit price, VAT rate — exact **integer cents**, exactly like + `InvoiceLine`/`QuoteLine`. +- **Lifecycle**: `pendiente → en_curso → terminado → facturado`. + +## Convert → invoice, idempotently / Conversión idempotente + +`WorkOrderService::convert()` reuses the **exact** pattern of `QuoteService::convert()`: a `convertedInvoice` +link means *convert twice ⇒ same invoice*. Materials become invoice lines; the labour hours become one +**"Mano de obra (N h)"** line priced at `hours × rate`. The order is then marked `facturado` and linked. + +The invoice is issued through the one door, `InvoiceService::create()`, so it gets the **gapless number** and +the **hash-chain record** for free — and, per [ADR 0004](../decisions/0004-dual-billing-mode.md), it prints +as a standard or Verifactu-demo invoice according to the user's billing mode. An invoiced order is +**immutable** (update/delete → 409). + +*`convert()` reutiliza el patrón de `QuoteService::convert()`: el enlace `convertedInvoice` garantiza que +convertir dos veces no duplica. Los materiales pasan a líneas; la mano de obra a una línea "Mano de obra +(N h)". La factura se emite por la única puerta, `InvoiceService::create()`, así que hereda numeración sin +huecos y registro de la cadena. Un parte ya facturado es inmutable (409).* + +## API + +| Method | Route | What | +|---|---|---| +| GET | `/api/work-orders` | list (newest first, own only) | +| POST | `/api/work-orders` | create | +| GET | `/api/work-orders/{id}` | detail | +| PUT | `/api/work-orders/{id}` | update (409 if invoiced) | +| DELETE | `/api/work-orders/{id}` | delete (409 if invoiced) | +| POST | `/api/work-orders/{id}/convert` | convert to invoice (idempotent) | + +Everything is scoped to the current user (`findOwned`). + +## Tests + +`WorkOrderServiceTest` (create, labour cents, no-title, idempotent shortcut) + an integration test that +creates an order, **converts it twice and asserts a single invoice** with the right total (materials + +labour), plus per-user isolation and invoiced-immutability (409). 91 tests overall.