Laravel 4 应用中的业务逻辑应放在哪里?
结论
账务汇总计算属于业务逻辑,不适合堆在 controller 或 view 里。可以按下面的方式划分职责:
- Eloquent model 表达数据关系,以及少量与单个实体密切相关的规则。
- 独立的 service 处理多个付款和参与者之间的汇总计算。
- controller 接收请求、调用 service,再把结果传给 view。
- view 只展示数据,不查询数据库,也不重新计算金额。
从数据库读取数据不等于处理全部业务逻辑。查询可以放在 Eloquent model、repository 或查询对象中。至于每个人该承担多少、最终净余额是多少、应该怎样互相结算,更适合交给专门的账务服务。
先明确账务模型
现有的 payers_payments 同时记录了实际支付金额和是否参与分摊。这样的设计可以工作,但混合了两个不同的概念:
- 谁实际垫付了多少钱。
- 谁应该承担这笔费用,以及承担多少。
假设 Alice 垫付了 300 元,Alice、Bob、Carol 三人平摊。Alice 的支付额是 300 元,应承担额则只有 100 元。这两项数据应分别表示。
如果每笔付款始终只有一个垫付人,可以采用较简单的结构:
payers
- id
- name
payments
- id
- description
- amount
- paid_by
- paid_at
payment_participants
- payment_id
- payer_id
- share
这里的字段含义是:
payments.paid_by指向实际垫付人。payments.amount是付款总额。payment_participants记录参与分摊的人。share可以表示权重,例如三人均摊时都为1,也可以改为明确的应承担金额。
如果同一笔费用允许多人共同支付,还需要进一步拆分:
payment_contributions
- payment_id
- payer_id
- amount
payment_allocations
- payment_id
- payer_id
- amount
payment_contributions 记录实际支付金额,payment_allocations 记录应承担金额。两者分开后,账务含义更明确,也可以直接验证:
付款总额 = contributions.amount 之和
付款总额 = allocations.amount 之和
如果只是学习项目,而且当前结构已经完成,没有必要马上重写。不过在计算过程中,至少要在代码里明确区分 paid 和 owed,不要用一个布尔字段隐含复杂的分摊规则。
Model 负责关系和实体规则
以一人垫付、多人按权重分摊为例,Laravel 4 的 Eloquent model 可以这样定义:
<?php
class Payment extends Eloquent
{
protected $fillable = array(
'description',
'amount',
'paid_by',
'paid_at'
);
public function payer()
{
return $this->belongsTo('Payer', 'paid_by');
}
public function participants()
{
return $this->belongsToMany(
'Payer',
'payment_participants'
)->withPivot('share');
}
}
<?php
class Payer extends Eloquent
{
public function paymentsMade()
{
return $this->hasMany('Payment', 'paid_by');
}
public function participatedPayments()
{
return $this->belongsToMany(
'Payment',
'payment_participants'
)->withPivot('share');
}
}
Model 适合处理与单笔付款密切相关的计算。例如,可以根据权重计算某个参与者应承担的金额:
<?php
class Payment extends Eloquent
{
// 省略关系定义
public function amountOwedBy(Payer $payer)
{
$participant = $this->participants->first(function ($key, $item) use ($payer) {
return $item->id === $payer->id;
});
if (!$participant) {
return 0;
}
$totalShare = $this->participants->sum(function ($participant) {
return (int) $participant->pivot->share;
});
if ($totalShare <= 0) {
throw new LogicException('A payment must have a positive total share.');
}
return $this->amount * $participant->pivot->share / $totalShare;
}
}
实际处理金额时,不建议直接使用浮点数。上面的代码主要用于说明职责划分,生产代码应使用整数分或可靠的 decimal 运算。
用 Service 生成汇总
汇总页面需要处理多笔付款和多个人员,属于跨实体的业务流程。可以创建一个 PaymentSummaryService:
<?php
class PaymentSummaryService
{
public function build()
{
$payers = Payer::orderBy('name')->get();
$payments = Payment::with(array(
'payer',
'participants'
))->orderBy('paid_at')->get();
$balances = array();
foreach ($payers as $payer) {
$balances[$payer->id] = array(
'payer' => $payer,
'paid' => 0,
'owed' => 0,
'balance' => 0
);
}
foreach ($payments as $payment) {
$amount = (int) $payment->amount;
$balances[$payment->paid_by]['paid'] += $amount;
$totalShare = $payment->participants->sum(function ($participant) {
return (int) $participant->pivot->share;
});
if ($totalShare <= 0) {
throw new LogicException(
'Payment '.$payment->id.' has no valid participants.'
);
}
$allocations = $this->allocate(
$amount,
$payment->participants,
$totalShare
);
foreach ($allocations as $payerId => $owedAmount) {
$balances[$payerId]['owed'] += $owedAmount;
}
}
foreach ($balances as &$balance) {
$balance['balance'] = $balance['paid'] - $balance['owed'];
}
unset($balance);
return array(
'payments' => $payments,
'balances' => $balances,
'settlements' => $this->calculateSettlements($balances)
);
}
private function allocate($amount, $participants, $totalShare)
{
$allocations = array();
$allocated = 0;
$lastIndex = count($participants) - 1;
foreach ($participants as $index => $participant) {
if ($index === $lastIndex) {
$shareAmount = $amount - $allocated;
} else {
$shareAmount = (int) floor(
$amount * $participant->pivot->share / $totalShare
);
}
$allocations[$participant->id] = $shareAmount;
$allocated += $shareAmount;
}
return $allocations;
}
private function calculateSettlements(array $balances)
{
$creditors = array();
$debtors = array();
foreach ($balances as $payerId => $row) {
if ($row['balance'] > 0) {
$creditors[] = array(
'payer' => $row['payer'],
'amount' => $row['balance']
);
} elseif ($row['balance'] < 0) {
$debtors[] = array(
'payer' => $row['payer'],
'amount' => -$row['balance']
);
}
}
$settlements = array();
$creditorIndex = 0;
$debtorIndex = 0;
while (
isset($creditors[$creditorIndex]) &&
isset($debtors[$debtorIndex])
) {
$amount = min(
$creditors[$creditorIndex]['amount'],
$debtors[$debtorIndex]['amount']
);
$settlements[] = array(
'from' => $debtors[$debtorIndex]['payer'],
'to' => $creditors[$creditorIndex]['payer'],
'amount' => $amount
);
$creditors[$creditorIndex]['amount'] -= $amount;
$debtors[$debtorIndex]['amount'] -= $amount;
if ($creditors[$creditorIndex]['amount'] === 0) {
$creditorIndex++;
}
if ($debtors[$debtorIndex]['amount'] === 0) {
$debtorIndex++;
}
}
return $settlements;
}
}
这段代码统一假设金额以分为单位保存。例如,12345 表示 123.45 元,可以避免 0.1 + 0.2 一类浮点精度问题。
每个人的净余额按下面的公式计算:
balance = paid - owed
其中:
balance > 0:此人应收款。balance < 0:此人应付款。balance = 0:此人的账务已经平衡。
calculateSettlements() 使用贪心方式生成一组可行的转账方案。它可以结清所有余额,但不能保证在任意约束下都得到数学意义上的最少转账次数。如果业务要求转账次数必须最少,就需要单独定义优化目标,并使用更复杂的算法。
Controller 保持简单
Controller 不需要了解分摊公式,也不应遍历付款记录计算余额。它只负责调用 service:
<?php
class SummaryController extends BaseController
{
protected $summary;
public function __construct(PaymentSummaryService $summary)
{
$this->summary = $summary;
}
public function index()
{
$summary = $this->summary->build();
return View::make('summary.index', $summary);
}
}
Laravel 4 的 IoC container 可以解析没有额外依赖的具体类。如果 service 还依赖 repository,可以显式绑定接口:
App::bind(
'PaymentRepositoryInterface',
'EloquentPaymentRepository'
);
然后通过构造函数注入:
public function __construct(PaymentRepositoryInterface $payments)
{
$this->payments = $payments;
}
Repository 并不是必需的。如果应用规模很小,让 service 直接使用 Eloquent 完全可以。等查询变得复杂、需要重复使用,或者需要隔离数据来源时,再引入 repository 更合适。
View 只展示整理好的数据
Service 返回的数据结构已经适合展示,Blade 模板只需负责格式化输出:
<h2>成员余额</h2>
<table>
<thead>
<tr>
<th>成员</th>
<th>已支付</th>
<th>应承担</th>
<th>净余额</th>
</tr>
</thead>
<tbody>
@foreach ($balances as $row)
<tr>
<td>{{{ $row['payer']->name }}}</td>
<td>{{ number_format($row['paid'] / 100, 2) }}</td>
<td>{{ number_format($row['owed'] / 100, 2) }}</td>
<td>{{ number_format($row['balance'] / 100, 2) }}</td>
</tr>
@endforeach
</tbody>
</table>
<h2>建议结算</h2>
<ul>
@foreach ($settlements as $settlement)
<li>
{{{ $settlement['from']->name }}}
支付给
{{{ $settlement['to']->name }}}
{{ number_format($settlement['amount'] / 100, 2) }}
</li>
@endforeach
</ul>
不要在 Blade 中执行 Eloquent 查询,也不要在模板中决定分摊方式。否则,同一套业务规则很难复用于 API、命令行任务或测试。
需要特别注意的问题
金额不要随意使用浮点数
建议将金额保存为整数分:
10.25 元 → 1025
如果必须使用数据库的 DECIMAL,PHP 端也要谨慎处理,必要时可以使用 BCMath。不要用普通浮点运算判断两个金额是否相等。
舍入差额必须有明确归属
100 分由 3 人均摊时无法整除,因此要明确规定多出的 1 分如何分配,例如:
- 按稳定顺序分给前几个参与者;
- 分给垫付人;
- 使用最大余数法。
无论选择哪种方式,最终都要满足:
所有人的应承担金额之和 = 付款总额
保存必要的历史事实
如果付款记录不能随着规则变化而改变,最好保存每个人最终分配到的明确金额,而不是只保存 pays 或权重。否则,参与者、分摊规则或默认权重一旦修改,旧账重新计算后可能得到不同结果。
避免 N+1 查询
汇总数据时,应通过 with() 预加载关系:
Payment::with(array('payer', 'participants'))->get();
不要在循环中逐条查询付款人或参与者。
为计算服务编写独立测试
至少应覆盖以下情况:
- 一人付款、多人均摊;
- 多笔付款由不同人垫付;
- 某人付款但不参与分摊;
- 金额不能整除;
- 没有参与者或总权重为零;
- 所有净余额之和为零;
- 结算完成后每个人的余额都归零。
最终可以保留这样的结构:
Eloquent models
表达关系和单个实体规则
PaymentSummaryService
加载账务、计算分摊、汇总余额、生成结算建议
SummaryController
调用 service 并传递结果
Blade view
格式化和展示结果
对于小型 Laravel 4 项目,这样的职责划分已经足够清楚,也方便以后增加 repository、API 或其他分摊策略。
备注:内容仅供参考。
