伝票合計の計算と保存

ここでは以下の内容について学習できます。

InvoiceAmountの拡張

伝票合計はInvoice$amountで管理します。InvoiceAmountに以下のメソッドを追加します。

class InvoiceAmount
{
    // (省略)

    private $oldBlance = null;

    public function calc(Collection $rows, $base)
    {
        if ($this->oldBlance === null) {
            $this->oldBlance = $this->balance;
        }
        $this->reset();
        $ar = $rows->getNativeArray();
        foreach($ar as $row) {
            $this->increment($row);
        }
        $this->balance = $base + $this->total();
    }

    public function increment(InvoiceItem $row)
    {
        if ($row->line_type === InvoiceItem::SALES) {
            $this->sales += $row->amount;
            $this->tax += $row->tax;
        } elseif ($row->line_type === InvoiceItem::PAYMENT) {
            $this->payment += $row->amount;
        }
    }

    public function decrement(InvoiceItem $row)
    {
        if ($row->line_type === InvoiceItem::SALES) {
            $this->sales -= $row->amount;
            $this->tax -= $row->tax;
        } elseif ($row->line_type === InvoiceItem::PAYMENT) {
            $this->payment -= $row->amount;
        }
    }
}

$oldBlanceは、既存の伝票を読み取り編集して保存する際に、残高の差額を求めるために使用されます。

calcforeachではコレクションのgetNativeArrayでPHPネイティブの配列を取り出してループしています。コレクションを直接foreachに渡すより高速に処理できます。

伝票番号の採番

ここで、直前の売掛金額を求める前に、伝票番号の採番を行います。自分の伝票番号が決まらないと直前の伝票を特定できないためです。

伝票番号はauto-incrementで得られるものと同様な番号とします。本来はauto-incrementによる採番でよいのですが、この例では学習のために自前で採番しています。

InvoiceクラスにassignInvoiceNumberメソッドを実装します。

class Invoice extends Model
{
    // (省略)

    private function assignInvoiceNumber()
    {
        // プライマリーキー順で最後の伝票を探す
        $it = Invoice::serverCursor(0, QueryExecuter::SEEK_LAST);

        // 見つかったら +1 した番号をセットする
        if ($it->valid()) {
            $inv = $it->current();
            $this->id = $inv->id + 1;
        } else {
            $this->id = 1;
        }
    }

ここでロックについて説明します。

今回はsaveWithTransactionbeginTransactionMULTILOCK_GAPを指定しています。そのため、serverCursorによる最後の伝票へのアクセスで、そのレコードとその後ろを示すsupernumが排他ロックされます。

また、MULTILOCKなのでトランザクションを終了するまでロックは開放されません。つまり、トランザクションを終了するまで、他の誰も伝票の挿入は行えません。そのためここで得た伝票番号は一意であることが保証されます。

Note: アクセスが頻繁なシステムでは、このような採番はあまりお勧めしません。複数ユーザーが同時に伝票を挿入できないからです。

サンプルとして採用しましたが、全体で1つの採番は非常にコストの高い処理です。採番にはさまざまな方法やアルゴリズムがありますが、それはまたの機会にしたいと思います。

保存時に直前の残高を取得する

伝票を保存する際には、直前の残高を求めて最終残高も計算します。処理の流れは以下のようになります。

  1. 直前の残高を取得
  2. 伝票の合計を計算
  3. 最終残高の計算
  4. 保存
class Invoice extends Model
{
    // (省略)

    private $baseBalance = 0;

    // 直前の残高を取得
    private function readBaseBalance()
    {
        $this->baseBalance = 0;

        // サーバーカーソルを取得し、この伝票の直前へシークする
        $it = Invoice::serverCursor(1, QueryExecuter::SEEK_LESSTHAN, Transactd::ROW_LOCK_S);

        // 同じ顧客か確認して残高を取得
        if ($it->valid()) {
            $inv = $it->current();
            if ($inv->customer_id === $this->customer_id) {
                $this->baseBalance = $inv->amount->balance;
            }
        }

        // サーバーカーソルを返す
        return $it;
    }
}

QueryExecuter::SEEK_LESSTHANは、指定したインデックス順で、keyValueの直前のレコードに移動します。直前のレコードが同じ顧客だったら残高を読みます。異なる顧客ならば、その顧客の初めての伝票なので、残高は0です。また、この処理はレコードを読み取るだけで更新は行わないのでTransactd::ROW_LOCK_Sを指定します。

この一連の処理では、Transactd::ROW_LOCK_Sを使用したからといって処理の同時実効性が向上するわけではありませんが、不要な排他ロックは可能な限り行わないように習慣づけるために例を示しました。

Invoiceの保存にサーバーカーソルを使用するよう変更する

残高の取得でサーバーカーソルを取得しているので、これを使って保存するように変更します。追加と更新は分けて行います。また、追加の場合は行番号を振ります。このInvoiceのキャッシュも更新します。

class Invoice extends Model
{
    // (省略)

    public function save($options = 0, $forceInsert = false)
    {
        InvoiceItem::$handler = new InvoiceItemSaveHandler();

        // 採番
        if ($this->id === 0) {
            $this->assignInvoiceNumber();
            $forceInsert = true;
        }

        // 直前の残高を取得
        $it = $this->readBaseBalance();

        // 合計の計算
        $this->amount->calc($this->items, $this->baseBalance);

        // サーバーカーソルを使ってこのinvoiceを保存
        if ($forceInsert) {
            // 行番号を振る
            $this->items->renumber('row');
            $it->insert($this);
        } else {
            $it = $this->serverCursor(1, QueryExecuter::SEEK_EQUAL);
            $it->validOrFail();
            $it->update($this);
        }

        // 行の保存
        $this->items->save();
        $this->updateCache(); // キャッシュを更新
    }

deleteメソッドも同様にサーバーカーソルを使用して削除するようにします。

    public function delete($options = null)
    {
        InvoiceItem::$handler = new InvoiceItemSaveHandler();
        $it = $this->serverCursor(1, QueryExecuter::SEEK_EQUAL);
        $it->validOrFail();
        $it->delete();
        $this->items->delete();
        $this->updateCache(true /* clear */); // キャッシュをクリア
    }

これで、伝票番号を採番し、合計金額と残高も含めて保存、削除できるようになりました。また、キャッシュも更新できました。

変更競合の検出

現在編集している伝票が、保存するに前に他のユーザーによって変更されていたらどうなるでしょうか?

伝票や在庫、日計などの計算値には不整合は生じません。しかし、その変更が読み取られないまま保存してしまうので、他のユーザーによる変更は消失してしまいます。

これを防止するために、読み込んだ時点での更新日時と、更新する際の現在の更新日時を比べることで、他のユーザーによる変更を検出し例外をスローするようにします。

サーバーカーソルを使った更新では、最新のロックしたレコードを取得するので、その日時を読み込んだ時の日時と比較できます。

日時を比較して例外をスローするメソッドconflictFailを作成します。また、比較対象を変更されては困るので、$update_atプロパティをprotectedにしましょう。

class Invoice extends Model
{
    // (省略)

    protected $update_at;

    private function conflictFail($inv)
    {
        if ($this->update_at !== $inv->update_at) {
            throw new Exception('This invoice is already changed by other user.');
        }
    }

作成したconflictFailsavedeleteメソッド内のロック読み取りの直後に追加します。

    public function save($options = 0, $forceInsert = false)
    {
        // (省略)

        // サーバーカーソルを使ってこのinvoiceを保存
        if ($forceInsert) {
            // 行番号を振る
            $this->items->renumber('row');
            $it->insert($this);
        } else {
            $it = $this->serverCursor(1, QueryExecuter::SEEK_EQUAL);
            $it->validOrFail();
            $this->conflictFail($it->current()); // 変更競合の検出をする
            $it->update($this);
        }

        // (省略)
    }

    public function delete($options = null)
    {
        InvoiceItem::$handler = new InvoiceItemSaveHandler();
        $it = $this->serverCursor(1, QueryExecuter::SEEK_EQUAL);
        $it->validOrFail();
        $this->conflictFail($it->current()); // 変更競合の検出をする
        $it->delete();
        $this->items->delete();
        $this->updateCache(true /* clear */); // キャッシュをクリア
    }

Note: MySQL/MariaDBの自動更新タイムスタンプは2016-12-06 11:49:26.466757のようにmicrosecondまで記録されます。ロックによってシリアライズされた2回以上の更新の時刻が同じになることはほぼないので、変更されたかどうかを検出することができます。

ただし、これは100%確実な方法ではありません。タイムスタンプはシステム時刻を元にしますが、システム時刻はユーザーやNTPなどによって変更されます。また、OSやマシンの分解能に依存します。今回はこの後タイムスタンプ更新の抑止例を示すために採用しています。

100%の確実性が必要な場合は、unsigned integerversionといったカラムを設けて、更新の度にインクリメントし、update_atの代わりにversionを比較するようにしてください。次のセクションで例を示します。

なお、MySQL 5.5以前のバージョンではmicrosecondまでのタイムスタンプをサポートしていないので、タイムスタンプによる方法は使用できません。

バージョンフィールドによる検出例

参考までに、versionカラムを使った競合検出の例を示します。変更するのはconflictFail関数のみです。この関数をincrementVersionOrFailという名前に変更します。

// 事前に`invoicies`テーブルに2バイト`unsigned integer`の`version`というカラムを追加してあるものとします。

class Invoice extends Model
{
    // (省略)

    protected $version;

    private function incrementVersionOrFail($inv)
    {
        if ($this->version !== $inv->version) {
            throw new Exception('This invoice is already changed by other user.');
        }
        ++$this->version; // increment
    }

残高を更新する

伝票が追加されたり、古い伝票が変更されると、顧客の残高が変化します。

invoicesテーブルのレコードには残高も記録しているので、変更された伝票より後のその顧客の伝票はすべて更新する必要があります。

Note: この残高の記録方法は、即座に残高がわかる反面、使い方によっては更新処理が多くなる欠点があります。しかし、古い伝票の変更や挿入が少ない場合やそれらを禁止している場合は、メリットの方が大きくなります。

伝票が挿入されたら、同じ顧客のそれ以降の伝票の残高を変更するようにします。このとき、増減額がわかれば、InvoiceItemを読まずにInvoiceを更新するだけで済みます。そこで、伝票の保存時に増減額を求め、その値を以降の伝票の更新に使用します。

増減額を求める

InvoiceAmountには$oldBalanceで変更前の残高が保存されています。これと変更後の残高を比べれば増減額が簡単に求められます。

class InvoiceAmount
{
    // (省略)

    public function difference()
    {
        return $this->balance - $this->oldBlance;
    }
}

残高を更新する

同じ顧客の、以降の伝票すべての残高を更新するupdateBalanceAmountメソッドを作成します。更新にはサーバーカーソルを使用します。

class Invoice extends Model
{
    // (省略)

    private function updateBalanceAmount($difference)
    {
        if ($difference !== 0) {
            // 現在の伝票の直後の伝票に移動
            $it = $this->serverCursor(1, QueryExecuter::SEEK_GREATER);
            foreach($it as $inv) {
                // customer_idの確認
                if ($inv->customer_id !== $this->customer_id) {
                    break;
                }
                // 残高を更新
                $inv->amount->balance += $difference;
                $it->update($inv); // No cache update
                // キャッシュの更新
                $inv->updateCache();
            }
        }
    }
}

foreach$it->valid()falseを返すとループを終了します。すなわち最後のレコードに達するとループを抜けます。また、異なる顧客の伝票が見つかったらループを抜けるようにしています。

残高の更新はInvociesaveメソッドとdeleteメソッド内に追加し、1トランザクションに含まれるようにします。

    public function save($options = 0, $forceInsert = false)
    {
        // (省略)

        // 以降の伝票の残高更新
        $this->updateBalanceAmount($this->amount->difference());
    }

    public function delete($options = null)
    {
        // (省略)

        // 以降の伝票の残高更新
        $this->updateBalanceAmount(0 - $this->amount->difference());
    }

このように、サーバーカーソルを使うと、SQLによって増減を計算するのではなく、 PHPでイテレートしながら値を確認し、対応する処理を書くことができます。

タイムスタンプ更新の抑止

上記で行った残高の更新では、レコードのタイムスタンプが更新されます。しかし、残高更新では伝票の内容そのものは変更しないので、変更の競合検出は不要です。そこで、残高の更新ではタイムスタンプの変更を抑止しましょう。

サーバーカーソルのsetTimestampModeTransactd::TIMESTAMP_VALUE_CONTROLを渡すとタイムスタンプ更新を抑止できます。これを使ってupdateBalanceAmountメソッドを修正します。

    private function updateBalanceAmount($difference)
    {
        if ($difference !== 0) {
            $it = $this->serverCursor(1, QueryExecuter::SEEK_GREATER);
            try {
                $it->setTimestampMode(Transactd::TIMESTAMP_VALUE_CONTROL); // タイムスタンプの変更を抑止
                foreach($it as $inv) {
                    // (省略)
                }
                $it->setTimestampMode(Transactd::TIMESTAMP_ALWAYS); // タイムスタンプ変更抑止を解除
            } catch (Exception $e) {
                $it->setTimestampMode(Transactd::TIMESTAMP_ALWAYS); // タイムスタンプ変更抑止を解除
                throw $e;
            }
        }
    }

これで、伝票の挿入や削除に対応した残高管理ができるようになりました。最後に日計の集計を行えば完成です。

ソースコード

ここまでのソースコードはGitHub Gistからダウンロードできます。

  1. 在庫の増減
  2. 日計の記録