Đang tải...

Từ Lý Thuyết Đến Thực Tế: Thiết Kế Module Invoice Nâng Cao - 3 Dạng Tính Điện/Nước, Dịch Vụ Động, và InvoiceCalculator Thuần Túy không phụ thuộc database

15/05/2026
12 phút đọc
Từ Lý Thuyết Đến Thực Tế: Thiết Kế Module Invoice Nâng Cao - 3 Dạng Tính Điện/Nước, Dịch Vụ Động, và InvoiceCalculator Thuần Túy không phụ thuộc database
Tiếp theo series Từ Lý Thuyết Đến Thực Tế, bài này mình viết câu chuyện thiết kế Module Invoice Nâng Cao, chúng ta cùng đi trả lời các câu hỏi: - Tại sao phải...

Tiếp theo series Từ Lý Thuyết Đến Thực Tế, bài này mình viết câu chuyện thiết kế Module Invoice Nâng Cao, chúng ta cùng đi trả lời các câu hỏi:

  • Tại sao phải thiết kế lại từ đầu, quyết định tách InvoiceCalculator ra khỏi database, và cách Value Objects biến một bài toán tưởng như phức tạp thành những khối code nhỏ dễ test.

Khi Một Cách Tính Không Còn Đủ

Thiết kế ban đầu chỉ hỗ trợ tính theo chỉ số đồng hồ: (chỉ số cuối - chỉ số đầu) × đơn giá. Đơn giản, đúng với cách cho thuê phòng có công tơ riêng từng phòng.

Tuy nhiên, với dạng căn hộ dịch vụ hoặc nhà ở chuyển đổi thành công năng cho thuê phòng thì không có công tơ riêng từng phòng, họ sẽ tính toán chia tiền điện hoặc tiền nước bằng cách Lấy tổng chia đều, mỗi phòng đóng 200.000 đ/ tháng, không cần đọc số. Thiết kế ban đầu không làm được vì form yêu cầu nhập chỉ số đầu kỳ và cuối kỳ.

Bắt đầu bài toán ở đây và đi giải bài toán này.


Ba Dạng Tính — Phân Tích Từ Thực Tế

Sau khi tham khảo và khảo sát thực tế, tôi gom lại được ba nhóm chính:

Dạng 1 — meter (chỉ số đồng hồ):
 cost = (curr_reading - prev_reading) × unit_price
 Cần: chỉ số đầu kỳ, cuối kỳ
 Dùng khi: có công tơ điện/nước riêng từng phòng

Dạng 2 — flat (gói phẳng):
 cost = flat_price_per_room
 Cần: không cần input thêm
 Dùng khi: không có công tơ, chia đều cố định

Dạng 3 — per_person (theo đầu người):
 cost = price_per_person × occupant_count
 Cần: số người đang ở phòng
 Dùng khi: muốn phân bổ công bằng theo số người

Điện và nước cấu hình độc lập — một phòng có thể tính điện theo đồng hồ nhưng nước theo gói phẳng. Đây là điểm tôi gần như bỏ sót: ban đầu tôi định dùng chung một calc_type cho cả điện lẫn nước. Sai. Thực tế có chủ cho thuê không lắp đồng hồ nước riêng nhưng có công tơ điện — nên nước phẳng, điện theo số.


Thiết Kế InvoiceCalculator: một class Service thuần túy không Inject Repository

Câu hỏi kiến trúc lúc này là: tính toán đặt ở đâu?

Lựa chọn 1: Repository tự tính khi lấy dữ liệu. Gọn, ít class. Nhưng test phải setup database, preview API phải giả vờ tạo invoice rồi rollback, logic tính toán bị nhúng vào tầng data access.

Lựa chọn 2: Service tính toán. Nhưng service nào? InvoiceAdvancedService còn phải lo transaction, resolve config, tạo snapshot. Gộp tất cả vào là một God Service không ai dám sửa.

Lựa chọn tôi chọn: tách hẳn thành InvoiceCalculator — một service thuần túy, không inject repository, làm việc độc lập.

final class InvoiceCalculator
{
 public function calculateUtility(
 UtilityConfig $config,
 UtilityCalculationInput $input,
 ): UtilityCalculationResult
 {
 return match ($config->calcType) {
 'meter' => $this->calcMeter($config, $input),
 'flat' => $this->calcFlat($config, $input),
 'per_person' => $this->calcPerPerson($config, $input),
 };
 }

 public function calculateServices(
 array $serviceConfigs,
 array $manualValues = [],
 int $occupantCount = 1,
 ): array { ... }

 public function calculateTotal(
 int $rentAmount,
 UtilityCalculationResult $electricResult,
 UtilityCalculationResult $waterResult,
 array $serviceResults,
 ): int { ... }
}

Không có __construct inject gì cả. Nhận Value Object vào, trả Value Object ra. Không đọc database, không ghi database.

Ba lợi ích cụ thể ngay lập tức:

Unit test chạy trong milliseconds, không cần database:

$result = $calculator->calculateUtility(
 new UtilityConfig(calcType: 'meter', unitPrice: 3500, unitLabel: 'kWh', ...),
 UtilityCalculationInput::forMeter('electric', prev: 1250, curr: 1320)
);
$this->assertEquals(245000, $result->amount); // 70 kWh × 3.500đ
$this->assertEquals(70.0, $result->consumption);

Preview API gọi Calculator trực tiếp, không ghi DB:

public function preview(Request $request): JsonResponse
{
 $electricConfig = $this->utilityConfigRepo->resolveForRoom($roomId, 'electric');
 $electricResult = $this->calculator->calculateUtility($electricConfig, $electricInput);
 // Trả JSON luôn — không transaction, không insert
 return response()->json(['electric_amount' => $electricResult->amount, ...]);
}

Vue form có thể gọi endpoint này mỗi khi người dùng thay đổi chỉ số — real-time preview mà không có thao tác ghi database.

InvoiceAdvancedService chỉ lo điều phối:

// Service biết LẤY DATA TỪ ĐÂU
$electricConfig = $this->utilityConfigRepo->resolveForRoom($roomId, 'electric');

// Calculator biết TÍNH NHƯ THẾ NÀO
$electricResult = $this->calculator->calculateUtility($electricConfig, $electricInput);

// Repository biết GHI VÀO ĐÂU
$this->snapshotRepo->create(
 $electricResult->toSnapshotArray($invoiceId, $tenantId)
);

Mỗi lớp có một nhiệm vụ riêng. Khi có bug, tôi biết ngay cần debug lớp nào.


Logic Tính Toán: Ưu tiên Đơn Giản

Ba private method cho ba dạng tính:

private function calcMeter(
 UtilityConfig $config,
 UtilityCalculationInput $input,
): UtilityCalculationResult
{
 if ($input->prevReading === null || $input->currReading === null) {
 throw new DomainException("Dạng tính 'meter' cần nhập chỉ số đầu kỳ và cuối kỳ");
 }

 // max(0) để tránh chỉ số âm khi đồng hồ bị reset
 $consumption = max(0.0, $input->currReading - $input->prevReading);
 $amount = (int) round($consumption * (float) $config->unitPrice);

 return new UtilityCalculationResult(
 utilityType: $config->utilityType,
 calcType: 'meter',
 amount: $amount,
 prevReading: $input->prevReading,
 currReading: $input->currReading,
 consumption: $consumption,
 unitPrice: $config->unitPrice,
 unitLabel: $config->unitLabel,
 // các field flat/per_person để null
 );
}
private function calcFlat(
 UtilityConfig $config,
 UtilityCalculationInput $input,
): UtilityCalculationResult
{
 // Không cần input gì — giá lấy từ config
 return new UtilityCalculationResult(
 utilityType: $config->utilityType,
 calcType: 'flat',
 amount: (int) $config->flatPrice,
 flatPrice: $config->flatPrice,
 unitLabel: $config->unitLabel,
 );
}
private function calcPerPerson(
 UtilityConfig $config,
 UtilityCalculationInput $input,
): UtilityCalculationResult
{
 $count = $input->occupantCount ?? 1;

 if ($count < 1) {
 throw new DomainException("Số người ở phải ít nhất là 1");
 }

 return new UtilityCalculationResult(
 utilityType: $config->utilityType,
 calcType: 'per_person',
 amount: (int) $config->pricePerPerson * $count,
 pricePerPerson: $config->pricePerPerson,
 occupantCount: $count,
 unitLabel: $config->unitLabel,
 );
}

Chú ý max(0.0, ...) ở dạng meter — Tình huống ko có trong kịch bản ngoài thực tế: đồng hồ cơ bị reset về 0, chỉ số cuối nhỏ hơn đầu. Không throw exception, không tính âm — trả về 0. Việc này phòng tránh case số điện bị âm (case thực tế đã bị)


Dịch Vụ Động: Khi Mỗi Tenant Có Danh Mục Riêng

Ngoài điện nước, còn phí rác, internet, giữ xe — mỗi tenant một danh sách khác nhau, mỗi phòng có thể khác mức giá.

Ba loại dịch vụ:

fixed — giá cố định/phòng/tháng
 VD: Phí rác 30.000đ, Internet 100.000đ
 is_mandatory = true → tự động thêm vào mọi hợp đồng

per_person — đơn giá × số người
 VD: Gửi xe 80.000đ/người

manual — nhân viên nhập tay khi lập hợp đồng
 VD: Phí sửa chữa — thay đổi mỗi tháng

calculateServices() xử lý cả ba loại trong một vòng lặp:

public function calculateServices(
 array $serviceConfigs,
 array $manualValues = [],
 int $occupantCount = 1,
): array
{
 $results = [];

 foreach ($serviceConfigs as $config) {
 if (!$config->isActive) {
 continue; // Phòng này được miễn dịch vụ
 }

 $result = match ($config->calcType) {
 'fixed' => new ServiceCalculationResult(
 serviceConfigId: $config->id,
 serviceName: $config->serviceName,
 calcType: 'fixed',
 quantity: 1,
 unitPrice: $config->effectivePrice ?? 0,
 amount: $config->effectivePrice ?? 0,
 ),
 'per_person' => new ServiceCalculationResult(
 serviceConfigId: $config->id,
 serviceName: $config->serviceName,
 calcType: 'per_person',
 quantity: $occupantCount,
 unitPrice: $config->effectivePrice ?? 0,
 amount: ($config->effectivePrice ?? 0) * $occupantCount,
 ),
 'manual' => isset($manualValues[$config->id]) && $manualValues[$config->id] > 0
 ? new ServiceCalculationResult(
 serviceConfigId: $config->id,
 serviceName: $config->serviceName,
 calcType: 'manual',
 quantity: 1,
 unitPrice: (int) $manualValues[$config->id],
 amount: (int) $manualValues[$config->id],
 )
 : null,
 };

 if ($result !== null) {
 $results[] = $result;
 }
 }

 return $results;
}

Dịch vụ manual không có giá mặc định — chỉ được thêm vào hóa đơn khi nhân viên thực sự nhập giá. Không nhập thì không tính — không phải 0đ, mà là không có dòng đó trong hóa đơn.


Value Objects: Typed Contract Giữa Các Lớp

Thay vì truyền array với key tùy ý, toàn bộ dữ liệu đi qua Value Objects có type rõ ràng.

UtilityConfig — validate ngay trong constructor, không để lỗi lan sang Calculator:

public function __construct(
 public readonly string $utilityType,
 public readonly string $calcType,
 public readonly ?int $unitPrice,
 public readonly ?int $flatPrice,
 public readonly ?int $pricePerPerson,
 public readonly string $displayName,
 public readonly string $unitLabel,
) {
 match ($calcType) {
 'meter' => $unitPrice !== null
 || throw new InvalidArgumentException("meter cần unitPrice"),
 'flat' => $flatPrice !== null
 || throw new InvalidArgumentException("flat cần flatPrice"),
 'per_person' => $pricePerPerson !== null
 || throw new InvalidArgumentException("per_person cần pricePerPerson"),
 };
}

UtilityCalculationInput — static factory methods thay vì constructor nhiều tham số nullable:

// Rõ ràng hơn nhiều so với new UtilityCalculationInput('electric', 1250, 1320, null)
$input = UtilityCalculationInput::forMeter('electric', prev: 1250.0, curr: 1320.0);
$input = UtilityCalculationInput::forFlat('water');
$input = UtilityCalculationInput::forPerPerson('electric', count: 3);

UtilityCalculationResult — biết cách tự chuyển sang format để ghi database:

// Trong InvoiceAdvancedService — không cần nhớ field name của bảng
$this->snapshotRepo->create(
 $electricResult->toSnapshotArray($invoiceId, $tenantId)
);

// Trong buildInvoiceItems — không cần map thủ công
$items[] = $electricResult->toItemArray(sortOrder: 1);

Method toSnapshotArray()toItemArray() đóng gói toàn bộ mapping logic. Khi schema thay đổi, chỉ cần sửa một chỗ trong Value Object, tất cả nơi dùng đều đúng ngay.


Cách viết Test Cases

Sau khi build xong Calculator, tôi viết test cho các tình huống thực tế có thể xảy ra

// Chỉ số cuối < đầu (đồng hồ bị reset) → consumption = 0, không âm
$result = $calculator->calculateUtility(
 config: new UtilityConfig(calcType: 'meter', unitPrice: 3500, ...),
 input: UtilityCalculationInput::forMeter('electric', prev: 100, curr: 90)
);
$this->assertEquals(0, $result->amount);
$this->assertEquals(0.0, $result->consumption);

// Dạng flat: không cần input, giá từ config
$result = $calculator->calculateUtility(
 config: new UtilityConfig(calcType: 'flat', flatPrice: 200_000, ...),
 input: UtilityCalculationInput::forFlat('electric')
);
$this->assertEquals(200_000, $result->amount);

// Manual service không có giá → không tính vào kết quả
$serviceConfigs = [
 new ServiceConfig(calcType: 'fixed', effectivePrice: 30_000, isActive: true, ...),
 new ServiceConfig(calcType: 'manual', effectivePrice: null, isActive: true, ...),
];
$results = $calculator->calculateServices($serviceConfigs, manualValues: [], occupantCount: 1);
$this->assertCount(1, $results); // manual bị skip
$this->assertEquals(30_000, $results[0]->amount);

// per_person: 2 người, 80.000đ/người → 160.000đ
$result = $calculator->calculateUtility(
 config: new UtilityConfig(calcType: 'per_person', pricePerPerson: 80_000, ...),
 input: UtilityCalculationInput::forPerPerson('electric', count: 2)
);
$this->assertEquals(160_000, $result->amount);

Tất cả test này chạy không cần database. Không cần RefreshDatabase, không cần factory, không cần seeder. Chạy toàn bộ trong dưới 1 giây.


Toàn Bộ Flow Tạo Hóa Đơn

Nhìn lại từ controller xuống repository:

POST /invoices
 │
 ▼
InvoicesController::store()
 └── validate StoreAdvancedInvoiceRequest
 └── gọi InvoiceAdvancedService::createInvoice($data)
 │
 ├── 1. UtilityConfigRepo::resolveForRoom($roomId, 'electric') → UtilityConfig
 ├── 2. UtilityConfigRepo::resolveForRoom($roomId, 'water') → UtilityConfig
 ├── 3. Calculator::calculateUtility(config, input) → UtilityCalculationResult ×2
 ├── 4. ServiceConfigRepo::resolveForRoom($roomId) → ServiceConfig[]
 ├── 5. Calculator::calculateServices(configs, manualValues, occupantCount)
 ├── 6. Calculator::calculateTotal(rent, electric, water, services)
 └── 7. DB::transaction():
 ├── InvoiceRepo::create() → invoice_id
 ├── InvoiceItemRepo::createBatch() → invoice_items
 └── SnapshotRepo::create() ×2 → invoice_utility_snapshots

Bảy bước. Mỗi bước rõ ràng trách nhiệm. Khi có bug, tôi biết nhìn vào đâu mà không cần đọc hết một method 300 dòng.


Kết

Thiết kế class InvoiceCalculator giúp logic nghiệp vụ được rõ ràng và chuyên biệt, khi test có thể mock test dễ dàng thuận tiện.

Khi thiết kế schema database cần lấy rộng mẫu usecase từ thực tế, ko chỉ thiết kế cho 1 case cố định, tham khảo nhiều usecase thực tế, tìm điểm chung và thiết kế có tính mở rộng, ít phụ thuộc vào nhau để tiện sau này.


Bài tiếp theo trong series: Module Tính Tiền (Subscription) - Thiết Kế Gói Cước và Billing Cho SaaS Multi-Tenant Laravel Và PostgreSQL

📚 Nguồn: Viblo

Bình luận

0 bình luận

Email không hiển thị công khai.

Chưa có bình luận nào. Hãy là người đầu tiên bình luận.

Chia sẻ bài viết

Cần tư vấn?

Liên hệ với chúng tôi để được hỗ trợ

Liên hệ ngay

Bài viết liên quan

Flash USDT Software for Beginners Learn the Basics with Simple Steps Examples
29/06/2026

Flash USDT Software for Beginners Learn the Basics with Simple Steps Examples

**Table of Contents** What Is Flash USDT Software? Why Beginners Should Learn the Basics How It Works in Simple Terms Common Educational Uses Benefits of Learning Through Testing Understanding the Lim...

Đọc thêm
TensorRT – Tại sao nhiều mô hình AI có accuracy cao nhưng vẫn không deploy production
29/06/2026

TensorRT – Tại sao nhiều mô hình AI có accuracy cao nhưng vẫn không deploy production

Trong thế giới nghiên cứu Trí tuệ Nhân tạo (AI), việc đạt được một mô hình có độ chính xác (Accuracy) lên tới 98% hay 99% trên tập dữ liệu kiểm thử (Test ...

Đọc thêm
Self-hosting n8n in production: the ops tax the pricing page doesn't show you
29/06/2026

Self-hosting n8n in production: the ops tax the pricing page doesn't show you

*n8n is "free if you self-host." After running 10 workflows on one box for months, here are the sharp edges that free actually buys you — and the ones worth budgeting for.* The headline number for n...

Đọc thêm

Bắt đầu dự án của bạn

Hãy để Flash Dev đồng hành cùng bạn

Liên hệ ngay