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
InvoiceCalculatorra 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() và 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