Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions backend/migrations/Version20260711101940.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

/**
* Work orders (partes de trabajo) + their material/service lines. New tables only — no change to existing
* data, so the invoice hash chain is untouched.
* ES: Partes de trabajo + sus líneas de material/servicio. Solo tablas nuevas — no toca datos existentes,
* así que la cadena de hash de facturas queda intacta.
*/
final class Version20260711101940 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create work_order + work_order_line tables';
}

public function up(Schema $schema): void
{
$this->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');
}
}
142 changes: 142 additions & 0 deletions backend/src/Controller/WorkOrderController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
<?php

namespace App\Controller;

use App\Entity\User;
use App\Entity\WorkOrder;
use App\Repository\WorkOrderRepository;
use App\Service\WorkOrderService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;

/**
* Work orders (partes de trabajo): CRUD + convert-to-invoice. Everything is scoped to the current user.
* ES: Partes de trabajo: CRUD + conversión a factura. Todo acotado al usuario actual.
*/
class WorkOrderController extends AbstractController
{
#[Route('/api/work-orders', name: 'api_work_orders_list', methods: ['GET'])]
public function list(WorkOrderRepository $repo, #[CurrentUser] User $user): JsonResponse
{
$rows = array_map(static fn (WorkOrder $w): array => [
'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<string,mixed> */
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(),
],
];
}
}
136 changes: 136 additions & 0 deletions backend/src/Entity/WorkOrder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
<?php

namespace App\Entity;

use App\Repository\WorkOrderRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

/**
* A work order (parte de trabajo): the everyday job of an electrician — a call-out or repair, filled in at
* the site. Carries the materials used (lines from the service catalog) and labour hours; when finished it
* can be **converted into a real invoice** (idempotently), reusing the quote→invoice pattern.
*
* ES: Un parte de trabajo: el día a día del electricista — un aviso o reparación, rellenado a pie de obra.
* Lleva los materiales usados (líneas del catálogo) y las horas de mano de obra; cuando se termina puede
* **convertirse en factura real** (de forma idempotente), reusando el patrón presupuesto→factura.
*/
#[ORM\Entity(repositoryClass: WorkOrderRepository::class)]
class WorkOrder
{
/** The lifecycle: pendiente → en_curso → terminado → facturado. */
public const STATUSES = ['pendiente', 'en_curso', 'terminado', 'facturado'];

#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;

#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false)]
private ?User $user = null;

#[ORM\ManyToOne(targetEntity: Customer::class)]
#[ORM\JoinColumn(nullable: false)]
private ?Customer $customer = null;

#[ORM\Column(length: 150)]
private string $title;

#[ORM\Column(type: 'text', nullable: true)]
private ?string $description = null;

#[ORM\Column(length: 12)]
private string $status = 'pendiente';

/** When the job is scheduled / took place. / Cuándo se programa o se hizo el trabajo. */
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $scheduledAt = null;

/** Labour hours (decimal, e.g. "1.50"). / Horas de mano de obra. */
#[ORM\Column(type: 'decimal', precision: 6, scale: 2)]
private string $laborHours = '0.00';

/** Labour rate in euros/hour. / Precio de la mano de obra en euros/hora. */
#[ORM\Column(type: 'decimal', precision: 12, scale: 2)]
private string $laborRate = '0.00';

/** VAT rate applied to the labour line. / Tipo de IVA de la línea de mano de obra. */
#[ORM\Column(type: 'decimal', precision: 5, scale: 2)]
private string $laborVatRate = '21.00';

/** The invoice this order was converted into. / La factura en que se convirtió. */
#[ORM\ManyToOne(targetEntity: Invoice::class)]
#[ORM\JoinColumn(nullable: true)]
private ?Invoice $convertedInvoice = null;

#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $createdAt;

/** @var Collection<int, WorkOrderLine> */
#[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<int, WorkOrderLine> */
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;
}
}
63 changes: 63 additions & 0 deletions backend/src/Entity/WorkOrderLine.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
* A material/service line of a WorkOrder. Amounts in exact integer cents, like InvoiceLine/QuoteLine.
* ES: Una línea de material/servicio de un parte. Importes en céntimos enteros exactos.
*/
#[ORM\Entity]
class WorkOrderLine
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;

#[ORM\ManyToOne(targetEntity: WorkOrder::class, inversedBy: 'lines')]
#[ORM\JoinColumn(nullable: false)]
private ?WorkOrder $workOrder = null;

#[ORM\Column(length: 255)]
private string $description;

#[ORM\Column]
private int $quantity = 1;

#[ORM\Column(type: 'decimal', precision: 12, scale: 2)]
private string $unitPrice;

#[ORM\Column(type: 'decimal', precision: 5, scale: 2)]
private string $vatRate;

public function getId(): ?int { return $this->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);
}
}
Loading
Loading