Files
portal-api/app/Services/Order/ServiceOrderRecorder.php
T
baodev0andClaude Opus 5.5 46aa138eec feat(admin): IP pool allocation, billing exports, service attachments
- IP pool: sheet import (xlsx/csv) matched by address, bulk allocate/revoke
  with notification email (ip_allocated/ip_revoked), ip_pool_logs history.
- Orders/invoices: Excel export on branded template (BrandedReportWriter),
  shared billing filters, order item service info in list responses.
- Web design quote Excel export; activity log export.
- Service attachments with copy-to-invoice; deployment invoice attachments.
- Customer email attachments stored on disk and attached by path.
- Admin password change endpoint; user.fullname backfill migration;
  orders.user_id backfill from logs.
- Disable deploy workflow until CI/CD is set up.
- Ignore stored customer email and service attachment uploads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:58:36 +07:00

189 lines
6.3 KiB
PHP

<?php
namespace App\Services\Order;
use App\Enums\Invoice\InvoiceStatus;
use App\Enums\Order\OrderStatus;
use App\Helpers\OrderHelper;
use App\Models\DomainPricing;
use App\Models\Invoice;
use App\Models\InvoiceItem;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Product;
use App\Models\Service;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
class ServiceOrderRecorder
{
/**
* @return array{order: Order, orderItem: OrderItem, invoice: Invoice}
*/
public function create(
int $customerId,
?Product $product,
string $billingCycle,
int $price,
?string $serverName,
bool $paid,
?string $paymentMethod = null,
?string $clientIp = null,
): array {
$months = OrderHelper::cycleToMonths($billingCycle);
$isDomain = $product?->product_type === 'domain';
$order = Order::create([
'order_num' => OrderHelper::generateOrderNumber(),
'customer_id' => $customerId,
'user_id' => Auth::guard('admin')->id(),
'date' => now()->toDateString(),
'sub_total' => $price,
'discount_amount' => 0,
'total' => $price,
'status' => $paid ? OrderStatus::ACTIVE->value : OrderStatus::PENDING->value,
'payment_method' => $paymentMethod,
'client_ip' => $clientIp,
]);
$orderItem = OrderItem::create([
'order_id' => $order->id,
'product_id' => $product?->id,
'billing_cycle' => $billingCycle,
'months' => $months,
'server_name' => $serverName,
'domain_name' => $isDomain ? $serverName : null,
'quantity' => 1,
'price' => $price,
]);
$invoice = Invoice::create([
'invoice_number' => OrderHelper::generateOrderNumber('INV'),
'customer_id' => $customerId,
'order_id' => $order->id,
'invoice_type' => 1,
'date' => now()->toDateString(),
'sub_total' => $price,
'discount_amount' => 0,
'tax' => 0,
'total' => $price,
'status' => $paid ? InvoiceStatus::PAID->value : InvoiceStatus::UNPAID->value,
'payment_method' => $paymentMethod,
'paid_at' => $paid ? now() : null,
'due_date' => $paid ? now()->addMonths($months)->toDateString() : now()->toDateString(),
]);
InvoiceItem::create([
'invoice_id' => $invoice->id,
'product_id' => $product?->id,
'type' => 'service',
'description' => $this->describe($product, $serverName),
'months' => $months,
'quantity' => 1,
'unit_price' => $price,
'total_price' => $price,
]);
return compact('order', 'orderItem', 'invoice');
}
public function ensureForService(Service $service, ?int $price = null, ?string $billingCycle = null): void
{
if ($service->order_item_id || ! $service->customer_id) {
return;
}
DB::transaction(function () use ($service, $price, $billingCycle) {
$product = $service->product ?? $this->defaultProductFor($service->type);
$isDomain = ($product?->product_type ?? $service->type) === 'domain';
$billingCycle ??= $isDomain ? 'annually' : 'monthly';
$price ??= $this->listPrice($product, $billingCycle, $service->server_name) ?? 0;
$records = $this->create(
(int) $service->customer_id,
$product,
$billingCycle,
$price,
$service->server_name,
paid: false,
);
$service->forceFill([
'product_id' => $service->product_id ?? $product?->id,
'order_item_id' => $records['orderItem']->id,
'invoice_id' => $records['invoice']->id,
])->save();
});
}
public function syncUnpaidInvoice(Service $service, int $price): void
{
$invoice = $service->invoice;
if (! $invoice || (int) $invoice->status !== InvoiceStatus::UNPAID->value || (int) $invoice->invoice_type !== 1) {
return;
}
$months = (int) ($service->orderItem?->months ?: 1);
$invoice->update(['sub_total' => $price, 'total' => $price]);
$invoice->invoiceItems()->where('type', 'service')->update([
'months' => $months,
'unit_price' => $price,
'total_price' => $price,
]);
$invoice->order?->update(['sub_total' => $price, 'total' => $price]);
}
public function cancelUnpaidInvoice(Service $service): void
{
$invoice = $service->invoice;
if (! $invoice || (int) $invoice->status !== InvoiceStatus::UNPAID->value) {
return;
}
$invoice->update(['status' => InvoiceStatus::CANCELLED->value]);
$invoice->order?->update(['status' => OrderStatus::CANCELLED->value]);
}
public function discardOrder(?int $orderItemId): void
{
$order = $orderItemId ? OrderItem::find($orderItemId)?->order : null;
if (! $order) {
return;
}
if (Service::whereIn('order_item_id', $order->orderItems()->pluck('id'))->exists()) {
return;
}
$order->invoices()->delete();
$order->delete();
}
public function listPrice(?Product $product, string $billingCycle, ?string $serverName): ?int
{
if ($product?->product_type === 'domain') {
return DomainPricing::matchDomain((string) $serverName)
?->orderPrice(DomainPricing::yearsForMonths(OrderHelper::cycleToMonths($billingCycle)));
}
$price = $product?->pricings()->where('billing_cycle', $billingCycle)->value('price');
return $price === null ? null : (int) $price;
}
private function defaultProductFor(?string $type): ?Product
{
return $type
? Product::where('product_type', $type)->where('is_active', true)->orderBy('id')->first()
: null;
}
private function describe(?Product $product, ?string $serverName): string
{
$name = $product->name ?? 'Dịch vụ';
return $serverName ? "{$name} - {$serverName}" : $name;
}
}