ระบบขายที่เราเขียนกันช่วงแรก ๆ มักเริ่มจากคอลัมน์ stock ในตาราง products ขายออกก็ลบ รับเข้าก็บวก เขียนง่ายและอ่านเร็ว จนถึงวันที่นับของจริงแล้วตัวเลขไม่ตรง ระบบบอกว่าสินค้า A เหลือ 48 ชิ้น แต่บนชั้นมี 46 แล้วเราไม่มีทางรู้เลยว่าสองชิ้นนั้นหายไปตอนไหน
ระบบขายหน้าร้านที่เราดูแลอยู่ตัวหนึ่งเก็บสต็อกอีกแบบ ทุกครั้งที่รับเข้า ขาย โอนระหว่างคลัง หรือปรับยอดหลังนับ ระบบเขียนรายการเคลื่อนไหว (stock movement) หนึ่งแถว ยอดคงเหลือคือผลรวมของแถวเหล่านั้น โพสต์นี้เล่าโครงสร้างนี้ใน Laravel 13 ตั้งแต่ migration, action ที่บันทึกรายการ, คำสั่งเทียบยอด จนถึงรายงานมูลค่าสต็อกรายเดือน ตัวอย่างใช้สินค้าทั่วไป
ยอดช่องเดียวบอกได้แค่ว่าตอนนี้เหลือเท่าไร
โค้ดตัดสต็อกที่เราคุ้นมือหน้าตาแบบนี้
$product->decrement('stock', $item->quantity);
ใช้ได้ดีตราบที่ไม่มีใครถามอะไรเพิ่ม พอเริ่มมีคำถามก็ตอบไม่ได้
- ยอดผิดตั้งแต่ตอนไหน ไม่มีประวัติให้ไล่ ใครแก้ยอดตรงจากหน้าหลังบ้านก็เขียนทับของเดิมไปเลย
- สิ้นเดือนที่แล้วเหลือเท่าไร ฝ่ายบัญชีถามเป็นประจำ แต่คอลัมน์เก็บได้แค่ค่าล่าสุด
- ขายพร้อมกันสองเครื่อง โค้ดที่อ่านยอดมาเช็คว่าพอขายก่อนแล้วค่อยเขียนกลับ ทับกันเองได้ หัวข้อล็อกแถวข้างล่างมีตัวเลขที่ลองวัดไว้
ทุกการเปลี่ยนแปลงเป็นหนึ่งแถวที่ไม่แก้ทีหลัง
แทนที่จะแก้ตัวเลขช่องเดียว เราเขียนแถวใหม่ทุกครั้งที่ของขยับ สินค้า A ที่คลังหลักตลอดสองเดือนเป็นแบบนี้
| วันที่ | รายการ | เอกสาร | จำนวน | คงเหลือ |
|---|---|---|---|---|
| 1 ก.ย. | รับเข้า ชิ้นละ 100 บาท | PO-001 | +50 | 50 |
| 3 ก.ย. | ขาย | INV-014 | −12 | 38 |
| 2 ต.ค. | รับเข้า ชิ้นละ 130 บาท | PO-002 | +20 | 58 |
| 5 ต.ค. | โอนไปสาขา 2 | TR-003 | −10 | 48 |
| 8 ต.ค. | ขาย | INV-020 | −5 | 43 |
| 9 ต.ค. | ยกเลิก INV-020 | INV-020 | +5 | 48 |
| 10 ต.ค. | ปรับยอดหลังนับ | ADJ-002 | −2 | 46 |
คอลัมน์คงเหลือคือผลรวมสะสมของคอลัมน์จำนวน ไม่ได้เก็บไว้ที่ไหน ทีนี้ถ้านับแล้วไม่ตรง เราไล่ทีละแถวได้ว่าของออกไปกับเอกสารใบไหน กติกามีสามข้อ
- จำนวนติดเครื่องหมาย เข้าเป็นบวก ออกเป็นลบ ยอดคงเหลือจึงเป็น
SUM()ธรรมดา - ไม่แก้และไม่ลบแถวเดิม ยกเลิก INV-020 ก็เขียน +5 กลับเข้าไป ประวัติยังเห็นว่าเคยขายแล้วยกเลิก
- ทุกแถวชี้กลับไปที่เอกสารต้นทาง ใบรับสินค้า ใบขาย ใบโอน หรือใบปรับยอด
โอนของไปสาขา 2 คือสองแถว −10 ที่คลังหลักกับ +10 ที่สาขา 2 ยอดรวมทุกคลังจึงเท่าเดิม ส่วนปรับยอดหลังนับ บันทึกแค่ส่วนต่างระหว่างที่นับได้กับยอดในระบบ
สร้างตาราง stock_movements กับ stock_balances
Schema::create('stock_movements', function (Blueprint $table) {
$table->id();
$table->foreignId('product_id')->constrained();
$table->foreignId('warehouse_id')->constrained();
$table->string('type');
$table->integer('quantity');
$table->decimal('unit_cost', 12, 2)->nullable();
$table->nullableMorphs('source');
$table->foreignId('user_id')->nullable()->constrained();
$table->dateTime('moved_at');
$table->string('note')->nullable();
$table->timestamps();
$table->index(['product_id', 'warehouse_id', 'moved_at']);
});
Schema::create('stock_balances', function (Blueprint $table) {
$table->id();
$table->foreignId('product_id')->constrained();
$table->foreignId('warehouse_id')->constrained();
$table->integer('quantity')->default(0);
$table->unique(['product_id', 'warehouse_id']);
});
enum MovementType: string
{
case Receive = 'receive';
case Sale = 'sale';
case TransferOut = 'transfer_out';
case TransferIn = 'transfer_in';
case Adjust = 'adjust';
case Reverse = 'reverse';
}
class StockMovement extends Model
{
protected $guarded = [];
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
public function warehouse(): BelongsTo
{
return $this->belongsTo(Warehouse::class);
}
public function source(): MorphTo
{
return $this->morphTo();
}
protected function casts(): array
{
return [
'type' => MovementType::class,
'quantity' => 'integer',
'unit_cost' => 'float',
'moved_at' => 'datetime',
];
}
}
คอลัมน์ที่ควรรู้ใน stock_movements
quantityเป็นintegerเพราะตัวอย่างนับเป็นชิ้น ถ้าขายตามน้ำหนักหรือความยาว เปลี่ยนเป็นdecimalได้เลยunit_costต้นทุนต่อหน่วย ใส่เฉพาะตอนรับเข้า รายงานมูลค่าสต็อกท้ายโพสต์ใช้ค่านี้sourceชี้ไปที่เอกสารต้นทางผ่าน polymorphic relation (source_typeกับsource_id) เอกสารกี่ประเภทก็ใช้ตารางเดียวmoved_atวันที่ของเอกสาร ไม่ใช่เวลาที่กดบันทึก ใบรับสินค้าที่ลงย้อนหลังจะได้ไปอยู่ในเดือนที่ถูก- index
(product_id, warehouse_id, moved_at)รองรับการรวมยอดรายสินค้าต่อคลังและการดูยอดย้อนหลัง
type เก็บเป็น backed enum ใครยังไม่เคยใช้ enum ใน Laravel อ่านต่อได้ที่ PHP Enum ใช้ยังไง ส่วน stock_balances คือยอดปัจจุบันที่เก็บไว้อ่านเร็ว อธิบายในหัวข้อถัดไป
ยอดคงเหลือคือผลรวมของ quantity
ยอดปัจจุบัน และยอด ณ สิ้นเดือนที่ฝ่ายบัญชีถาม เป็น query เดียวกัน ต่างกันแค่เงื่อนไขวันที่
$onHand = StockMovement::query()
->where('product_id', $product->id)
->where('warehouse_id', $warehouse->id)
->sum('quantity');
$endOfSeptember = StockMovement::query()
->where('product_id', $product->id)
->where('warehouse_id', $warehouse->id)
->where('moved_at', '<=', '2026-09-30 23:59:59')
->sum('quantity');
หน้าประวัติที่มีคอลัมน์คงเหลือเหมือนตารางข้างบน ใช้ window function SUM() OVER ให้ฐานข้อมูลบวกสะสมให้ทีละแถว
$history = StockMovement::query()
->where('product_id', $product->id)
->where('warehouse_id', $warehouse->id)
->select('*')
->selectRaw('SUM(quantity) OVER (ORDER BY moved_at, id) AS running_balance')
->orderBy('moved_at')
->orderBy('id')
->get();
Window function ใช้ได้ตั้งแต่ MySQL 8.0, MariaDB 10.2 และ PostgreSQL ทุกรุ่นที่ยังมี support ถ้า hosting ยังเป็น MySQL 5.7 ก็ดึงแถวมาแล้วบวกสะสมใน PHP แทน และถ้ากรองช่วงวันที่ ยอดสะสมจะเริ่มจากศูนย์ ต้องบวกยอดยกมาก่อนวันแรกของช่วงเข้าไปเอง
แต่ถ้าหน้ารายการสินค้าต้องโชว์ยอดของสินค้าเป็นพันตัว การ SUM() แถวทั้งหมดตั้งแต่วันแรกทุกครั้งที่เปิดหน้าก็จะช้าลงเรื่อย ๆ ตามจำนวน movement เราจึงเก็บยอดปัจจุบันไว้อีกที่ด้วย
เก็บยอดปัจจุบันไว้ด้วย แต่อัปเดตใน transaction เดียวกับ movement
ทางกลางที่เราใช้คือให้ stock_movements เป็นข้อมูลจริง (source of truth) และให้ stock_balances เป็นยอดที่คำนวณเก็บไว้ (cache) ทุกครั้งที่เขียน movement ก็อัปเดตยอดใน transaction เดียวกัน ถ้าขั้นไหนพัง ทั้งสองตารางก็ย้อนกลับพร้อมกัน
Transaction ทำให้ทุกคำสั่งข้างในสำเร็จพร้อมกันหรือยกเลิกพร้อมกัน ส่วน lockForUpdate() สั่ง SELECT ... FOR UPDATE ล็อกแถวที่อ่านไว้ transaction อื่นที่จะล็อกแถวเดียวกันต้องรอจนเรา commit
เริ่มจากเมธอดที่หาแถวยอดของสินค้าในคลังนั้นแล้วล็อกไว้ ถ้ายังไม่มีแถวก็สร้างก่อน
class StockBalance extends Model
{
public $timestamps = false;
protected $guarded = [];
public static function lockFor(int $productId, int $warehouseId): self
{
$locked = fn () => static::query()
->where('product_id', $productId)
->where('warehouse_id', $warehouseId)
->lockForUpdate();
$balance = $locked()->first();
if ($balance !== null) {
return $balance;
}
static::query()->insertOrIgnore([
'product_id' => $productId,
'warehouse_id' => $warehouseId,
'quantity' => 0,
]);
return $locked()->sole();
}
protected function casts(): array
{
return ['quantity' => 'integer'];
}
}
ลำดับตรงนี้สำคัญกับ MySQL รอบแรกเราเขียน insertOrIgnore() ก่อนทุกครั้งแล้วค่อยล็อก ปรากฏว่า InnoDB ตั้ง shared lock ไว้ที่แถวที่ key ซ้ำ พอสอง transaction ถือ shared lock อยู่ทั้งคู่แล้วต่างคนต่างขอ FOR UPDATE ก็ deadlock ทันที ลองขายพร้อมกัน 20 ครั้งล้มไป 14 ครั้ง เปลี่ยนเป็นล็อกก่อน แล้ว insert เฉพาะตอนยังไม่มีแถว ปัญหานี้ก็หายไป insertOrIgnore() ยังจำเป็นอยู่ เผื่อสองคำขอสร้างแถวของสินค้าใหม่พร้อมกัน
แล้วทุกการเคลื่อนไหวผ่าน action ตัวเดียว
class RecordStockMovement
{
public function handle(
Product $product,
Warehouse $warehouse,
MovementType $type,
int $quantity,
?Model $source = null,
?float $unitCost = null,
?CarbonInterface $movedAt = null,
?string $note = null,
): StockMovement {
return DB::transaction(function () use ($product, $warehouse, $type, $quantity, $source, $unitCost, $movedAt, $note) {
$balance = StockBalance::lockFor($product->id, $warehouse->id);
if ($quantity < 0 && $balance->quantity + $quantity < 0) {
throw new InsufficientStockException(
"{$product->name} ที่ {$warehouse->name} เหลือ {$balance->quantity} ชิ้น ตัดออก ".abs($quantity).' ชิ้นไม่ได้'
);
}
$movement = StockMovement::create([
'product_id' => $product->id,
'warehouse_id' => $warehouse->id,
'type' => $type,
'quantity' => $quantity,
'unit_cost' => $unitCost,
'source_type' => $source?->getMorphClass(),
'source_id' => $source?->getKey(),
'user_id' => auth()->id(),
'moved_at' => $movedAt ?? now(),
'note' => $note,
]);
$balance->increment('quantity', $quantity);
return $movement;
}, attempts: 3);
}
}
increment() ส่ง UPDATE ... SET quantity = quantity + ? ซึ่งฐานข้อมูลบวกให้ในคำสั่งเดียวอยู่แล้ว ถ้าแค่บวกลบยอดก็ไม่ต้องล็อก ที่ต้องล็อกเพราะเราเช็คก่อนว่าของพอขายไหม ระหว่างที่เช็คกับตอนที่เขียน ต้องไม่มีใครตัดสต็อกตัวเดียวกันแทรกเข้ามา
ลองบน Mac ด้วย MySQL 8.4 และ PostgreSQL 17 ให้สินค้า A มี 10 ชิ้น แล้วยิง 20 process ขายชิ้นละ 1 พร้อมกัน
| วิธี | ขายผ่าน | ปฏิเสธ | ยอดในตารางสุดท้าย |
|---|---|---|---|
อ่านยอด เช็ค แล้ว update() ไม่ล็อก |
20 | 0 | 9 |
RecordStockMovement ล็อกแถวก่อน |
10 | 10 | 0 |
แบบไม่ล็อก ทุก process อ่านได้ 10 พร้อมกัน เห็นว่าพอขาย แล้วต่างคนต่างเขียน 9 ทับกัน ขายไปยี่สิบชิ้นจากของที่มีสิบ ทั้งสองฐานข้อมูลได้ผลเหมือนกัน
ฝั่งที่เรียกใช้ก็ส่งแค่สิ่งที่เกิดขึ้น เช่นตอนยืนยันใบขาย
app(RecordStockMovement::class)->handle(
$item->product,
$order->warehouse,
MovementType::Sale,
-$item->quantity,
source: $order,
movedAt: $order->document_date,
);
ใบขายที่มีหลายรายการ ให้ครอบทั้งใบด้วย DB::transaction() และเรียงรายการตาม product_id ก่อนบันทึก ใบขายสองใบที่มีสินค้าชุดเดียวกันจะได้ล็อกตามลำดับเดียวกัน
กติกาที่ต้องถือไว้คือทุกที่ที่แตะสต็อกต้องผ่าน action นี้ ทั้ง controller, job นำเข้า และหน้าหลังบ้าน ถ้าหลังบ้านใช้ Filament ปุ่มปรับยอดใน resource ก็เรียก action ตัวเดียวกัน ไม่เปิดให้แก้ stock_balances ผ่านฟอร์ม
โอนระหว่างคลัง ล็อกยอดทั้งสองคลังเรียงตาม id ก่อน
โอนคือสองแถวที่ต้องสำเร็จพร้อมกัน ครอบด้วย transaction อีกชั้น
class TransferStock
{
public function __construct(private RecordStockMovement $recordStockMovement) {}
public function handle(Product $product, Warehouse $from, Warehouse $to, int $quantity, ?Model $source = null): void
{
DB::transaction(function () use ($product, $from, $to, $quantity, $source) {
collect([$from, $to])
->sortBy('id')
->each(fn (Warehouse $warehouse) => StockBalance::lockFor($product->id, $warehouse->id));
$this->recordStockMovement->handle($product, $from, MovementType::TransferOut, -$quantity, $source);
$this->recordStockMovement->handle($product, $to, MovementType::TransferIn, $quantity, $source);
}, attempts: 3);
}
}
สามบรรทัดที่ล็อกทั้งสองคลังก่อนคือจุดที่พลาดง่ายที่สุด ถ้าปล่อยให้ RecordStockMovement ล็อกเองตามลำดับ ใบโอนจากคลังหลักไปสาขา 2 ล็อกคลังหลักก่อน ส่วนใบที่โอนกลับล็อกสาขา 2 ก่อน ต่างฝ่ายต่างรอแถวที่อีกฝ่ายถืออยู่ ฐานข้อมูลก็ตัดสินว่า deadlock แล้วยกเลิกฝั่งหนึ่ง ลองให้ 8 process โอนสวนกันรวม 160 ครั้ง
| ไม่เรียงล็อก | เรียงล็อกตาม id | |
|---|---|---|
| PostgreSQL 17 | ล้ม 114 ครั้ง ใช้เวลาเกือบ 4 นาที | ผ่านครบ ไม่ถึง 1 วินาที |
| MySQL 8.4 | ล้ม 33 ครั้ง | ผ่านครบ ไม่ถึง 1 วินาที |
PostgreSQL ช้าเพราะรอ deadlock_timeout ซึ่งค่าเริ่มต้นคือ 1 วินาที ก่อนเริ่มตรวจหา deadlock ทุกครั้ง ส่วน attempts: 3 ให้ DB::transaction() ลองใหม่เมื่อเจอ deadlock แต่ Laravel ลองใหม่เฉพาะ transaction ชั้นนอกสุด (ชั้นในโยน DeadlockException ขึ้นไป ดูได้ใน Illuminate\Database\Concerns\ManagesTransactions) ลองซ้ำช่วยได้แค่บางส่วน เรียงล็อกให้เหมือนกันทุกที่ช่วยได้จริงกว่า
ปรับยอดหลังนับสต็อก บันทึกเฉพาะส่วนต่าง
พนักงานกรอกจำนวนที่นับได้ ระบบคำนวณส่วนต่างจากยอดที่ล็อกไว้ แล้วเขียนเป็น movement หนึ่งแถว
class AdjustStock
{
public function __construct(private RecordStockMovement $recordStockMovement) {}
public function handle(Product $product, Warehouse $warehouse, int $counted, string $note): ?StockMovement
{
return DB::transaction(function () use ($product, $warehouse, $counted, $note) {
$difference = $counted - StockBalance::lockFor($product->id, $warehouse->id)->quantity;
if ($difference === 0) {
return null;
}
return $this->recordStockMovement->handle(
$product, $warehouse, MovementType::Adjust, $difference, note: $note,
);
});
}
}
ล็อกตั้งแต่ตอนอ่านยอดมาคำนวณ ถ้ามีใบขายแทรกระหว่างนั้น ส่วนต่างจะคิดจากยอดเก่า แล้วยอดหลังปรับก็ไม่ตรงกับที่นับได้
ยกเลิกเอกสาร เขียนรายการกลับทาง ไม่ลบของเดิม
class ReverseStockMovements
{
public function __construct(private RecordStockMovement $recordStockMovement) {}
public function handle(Model $source): void
{
DB::transaction(function () use ($source) {
StockMovement::query()
->whereMorphedTo('source', $source)
->where('type', '!=', MovementType::Reverse)
->with(['product', 'warehouse'])
->get()
->each(fn (StockMovement $movement) => $this->recordStockMovement->handle(
$movement->product,
$movement->warehouse,
MovementType::Reverse,
-$movement->quantity,
$source,
unitCost: $movement->unit_cost,
note: "ยกเลิกรายการ #{$movement->id}",
));
});
}
}
with(['product', 'warehouse']) โหลดสินค้ากับคลังมาทีเดียว ไม่ query ทีละแถว (เรื่อง N+1 อ่านต่อในEager Loading ใน Laravel) แถวกลับทางยกต้นทุนของแถวเดิมมาด้วย รายงานรายเดือนจะได้หักยอดรับเข้าออกถูกตัว ส่วนการเปลี่ยนสถานะเอกสารเป็นยกเลิก ให้ทำใน transaction เดียวกันและเช็คสถานะก่อน ใบที่ยกเลิกไปแล้วจะได้ไม่กลับรายการซ้ำ
stock:reconcile คำนวณยอดใหม่จากรายการเคลื่อนไหว
ถึงทุกเส้นทางจะผ่าน action แล้ว ยอดใน stock_balances ก็ยังเพี้ยนได้ เช่นมีคนรัน UPDATE ตรงในฐานข้อมูล มีโค้ดเก่าที่ยังแก้ยอดเอง หรือกู้ backup มาแค่บางตาราง เพราะ movement คือข้อมูลจริง เราจึงคำนวณยอดใหม่จาก movement ได้เสมอ
Artisan::command('stock:reconcile {--fix : แก้ยอดในตาราง stock_balances ให้ตรงกับรายการเคลื่อนไหว}', function () {
$key = fn ($row) => "{$row->product_id}:{$row->warehouse_id}";
$computed = StockMovement::query()
->select('product_id', 'warehouse_id')
->selectRaw('SUM(quantity) AS quantity')
->groupBy('product_id', 'warehouse_id')
->get()
->keyBy($key);
$stored = StockBalance::query()->get()->keyBy($key);
$drifts = $computed->keys()
->merge($stored->keys())
->unique()
->map(fn (string $pair) => [
'product_id' => (int) str($pair)->before(':')->toString(),
'warehouse_id' => (int) str($pair)->after(':')->toString(),
'stored' => (int) ($stored->get($pair)?->quantity ?? 0),
'computed' => (int) ($computed->get($pair)?->quantity ?? 0),
])
->reject(fn (array $row) => $row['stored'] === $row['computed']);
if ($drifts->isEmpty()) {
$this->components->info('ยอดคงเหลือตรงกับรายการเคลื่อนไหวทุกรายการ');
return;
}
$this->table(['product_id', 'warehouse_id', 'ยอดในตาราง', 'ยอดจากรายการ'], $drifts->all());
if (! $this->option('fix')) {
$this->components->warn("ยอดไม่ตรง {$drifts->count()} รายการ รันซ้ำพร้อม --fix เพื่อแก้");
return;
}
$drifts->each(fn (array $row) => DB::transaction(function () use ($row) {
$balance = StockBalance::lockFor($row['product_id'], $row['warehouse_id']);
$balance->update([
'quantity' => (int) StockMovement::query()
->where('product_id', $row['product_id'])
->where('warehouse_id', $row['warehouse_id'])
->sum('quantity'),
]);
}));
$this->components->info("แก้ยอดแล้ว {$drifts->count()} รายการ");
})->purpose('เทียบยอดคงเหลือกับผลรวมของรายการเคลื่อนไหว');
สมมติมีคนแก้ยอดสินค้า A ที่คลังหลักในตารางเป็น 99 ตรง ๆ รันเฉย ๆ ได้รายงาน ยังไม่แก้อะไร
php artisan stock:reconcile
+------------+--------------+------------+--------------+
| product_id | warehouse_id | ยอดในตาราง | ยอดจากรายการ |
+------------+--------------+------------+--------------+
| 1 | 1 | 99 | 46 |
+------------+--------------+------------+--------------+
WARN ยอดไม่ตรง 1 รายการ รันซ้ำพร้อม --fix เพื่อแก้.
ตอนแก้ (--fix) เราล็อกแถวยอดก่อน แล้วค่อย SUM() ใหม่ ใบขายที่เข้ามาระหว่างนั้นจะรอจนแก้เสร็จ ยอดที่เขียนกลับจึงไม่ทับรายการที่เพิ่งเข้ามา ตั้งให้รันแบบรายงานทุกคืนก็ได้ ยอดไม่ตรงเมื่อไรค่อยมาดูว่าเพี้ยนจากอะไรก่อนสั่ง --fix
Schedule::command('stock:reconcile')->dailyAt('02:00');
อีกทางที่ดูตรงไปตรงมาคือลบ movement ทิ้งทั้งหมด แล้วยืนยันเอกสารซื้อขายใหม่ทีละใบ ผมเองก็เคยเขียนแบบนั้น ข้อเสียคือเราลบข้อมูลจริงทิ้งเพื่อสร้าง cache ใหม่ ถ้าพังกลางทางก็เหลือแค่ครึ่งเดียว เอกสารเก่าโดนคำนวณด้วยโค้ดวันนี้ และลืมเอกสารบางประเภท เช่นใบปรับยอดหรือใบโอน ได้ง่ายมาก คำนวณจาก movement ที่มีอยู่แล้วปลอดภัยกว่า
รายงานมูลค่าสต็อกรายเดือน ย้ายจาก SQL view มาเป็นตาราง
รายงานที่ฝ่ายบัญชีใช้ทุกเดือนคือมูลค่าสต็อกต่อสินค้า ยอดยกมา รับเข้า จ่ายออก คงเหลือ ต้นทุนเฉลี่ย และมูลค่าคงเหลือ ต้นทุนเฉลี่ยแบบถัวเฉลี่ยถ่วงน้ำหนัก (weighted average) ของเดือนนี้คิดจากมูลค่ายกมาบวกมูลค่ารับเข้า หารด้วยจำนวนยกมาบวกจำนวนรับเข้า มูลค่ายกมาก็คือมูลค่าคงเหลือของเดือนก่อน ซึ่งคิดจากเดือนก่อนหน้านั้นอีกที ต่อกันเป็นสายย้อนไปถึงเดือนแรก
รุ่นแรกในระบบที่เราดูแลเขียนรายงานนี้เป็น SQL view อ่านจาก movement ตรง ๆ ข้อดีคือได้ตัวเลขล่าสุดเสมอและไม่ต้องมี job แต่ view ไม่เก็บผลลัพธ์ ทุกครั้งที่เปิดรายงาน ฐานข้อมูลต้องไล่ movement ทั้งหมดตั้งแต่วันแรกใหม่ และต้นทุนที่ต้องยกจากเดือนก่อนก็เขียนใน view ได้ยาก เพราะแต่ละเดือนต้องใช้ผลของเดือนก่อน
รุ่นที่ใช้อยู่เปลี่ยนเป็นตารางจริง แล้วให้ job คำนวณทีละเดือนเรียงจากเก่าไปใหม่ เดือนไหนก็อ่านยอดยกมาจากแถวของเดือนก่อนที่คำนวณเก็บไว้แล้ว หน้ารายงานอ่านแค่ไม่กี่สิบแถวต่อปี
Schema::create('stock_value_reports', function (Blueprint $table) {
$table->id();
$table->date('month');
$table->foreignId('product_id')->constrained();
$table->integer('opening_qty');
$table->decimal('opening_value', 14, 2);
$table->integer('received_qty');
$table->decimal('received_value', 14, 2);
$table->integer('issued_qty');
$table->integer('closing_qty');
$table->decimal('average_cost', 12, 4);
$table->decimal('closing_value', 14, 2);
$table->timestamps();
$table->unique(['month', 'product_id']);
});
class CalculateStockValueReport implements ShouldQueue
{
use Queueable;
public int $tries = 10;
public int $maxExceptions = 2;
public function __construct(public ?string $fromMonth = null) {}
public function middleware(): array
{
return [(new WithoutOverlapping('stock-value-report'))->releaseAfter(30)->expireAfter(600)];
}
public function handle(): void
{
$firstMovedAt = StockMovement::query()->min('moved_at');
if ($firstMovedAt === null) {
return;
}
$month = CarbonImmutable::parse($this->fromMonth ?? $firstMovedAt)->startOfMonth();
while ($month->lte(now())) {
$this->calculateMonth($month);
$month = $month->addMonthNoOverflow();
}
}
private function calculateMonth(CarbonImmutable $month): void
{
$previous = StockValueReport::query()
->whereDate('month', $month->subMonthNoOverflow()->toDateString())
->get()
->keyBy('product_id');
$movements = StockMovement::query()
->whereBetween('moved_at', [$month->startOfMonth(), $month->endOfMonth()])
->groupBy('product_id')
->select('product_id')
->selectRaw('SUM(CASE WHEN unit_cost IS NOT NULL THEN quantity ELSE 0 END) AS received_qty')
->selectRaw('SUM(CASE WHEN unit_cost IS NOT NULL THEN quantity * unit_cost ELSE 0 END) AS received_value')
->selectRaw('SUM(CASE WHEN unit_cost IS NULL THEN -quantity ELSE 0 END) AS issued_qty')
->get()
->keyBy('product_id');
$reports = $previous->keys()
->merge($movements->keys())
->unique()
->map(function (int $productId) use ($month, $previous, $movements) {
$openingQty = $previous->get($productId)?->closing_qty ?? 0;
$openingValue = (float) ($previous->get($productId)?->closing_value ?? 0);
$receivedQty = (int) ($movements->get($productId)?->received_qty ?? 0);
$receivedValue = (float) ($movements->get($productId)?->received_value ?? 0);
$issuedQty = (int) ($movements->get($productId)?->issued_qty ?? 0);
$availableQty = $openingQty + $receivedQty;
$averageCost = $availableQty > 0 ? ($openingValue + $receivedValue) / $availableQty : 0;
$closingQty = $availableQty - $issuedQty;
return [
'month' => $month->toDateString(),
'product_id' => $productId,
'opening_qty' => $openingQty,
'opening_value' => $openingValue,
'received_qty' => $receivedQty,
'received_value' => $receivedValue,
'issued_qty' => $issuedQty,
'closing_qty' => $closingQty,
'average_cost' => round($averageCost, 4),
'closing_value' => round($closingQty * $averageCost, 2),
];
})
->reject(fn (array $row) => $row['closing_qty'] === 0 && $row['received_qty'] === 0 && $row['issued_qty'] === 0)
->values();
DB::transaction(function () use ($month, $reports) {
StockValueReport::upsert($reports->all(), uniqueBy: ['month', 'product_id']);
StockValueReport::query()
->whereDate('month', $month->toDateString())
->whereNotIn('product_id', $reports->pluck('product_id'))
->delete();
});
}
}
สิ่งที่ job นี้ทำ ไล่ทีละส่วน
- รายงานเป็นยอดรวมทุกคลัง แถวโอนออกกับโอนเข้าหักล้างกันพอดี จึงไม่กระทบตัวเลข
- แถวที่มี
unit_costคือรับเข้า รวมถึงแถวกลับทางของใบรับที่ยกเลิก ซึ่งยกต้นทุนเดิมมาด้วย ขาย ปรับยอด และยกเลิกใบขายไม่มีต้นทุน จึงนับเป็นจ่ายออก - เขียนผลด้วย
upsert()ตาม unique key(month, product_id)เรื่อง upsert ละเอียดกว่านี้อ่านได้ในบันทึกข้อมูลทีละมาก ๆ ด้วย upsert() แล้วลบแถวของสินค้าที่เดือนนั้นไม่มีแล้ว ทั้งหมดอยู่ใน transaction เดียว หน้ารายงานจะไม่เห็นข้อมูลครึ่ง ๆ กลาง ๆ WithoutOverlappingกันไม่ให้สอง job เขียนตารางเดียวกันพร้อมกัน Laravel ส่ง job ที่มาทีหลังกลับเข้า queue ให้รอ 30 วินาที การส่งกลับนับเป็นหนึ่ง attempt ด้วย จึงตั้ง$triesไว้สูง และใช้$maxExceptionsคุมจำนวนครั้งที่ล้มจริงแทน
ข้อมูลตัวอย่างในตารางต้นโพสต์ บวกยอด 10 ชิ้นที่โอนไปสาขา 2 ได้รายงานแบบนี้
| เดือน | ยกมา | มูลค่ายกมา | รับเข้า | มูลค่ารับเข้า | จ่ายออก | คงเหลือ | ต้นทุนเฉลี่ย | มูลค่าคงเหลือ |
|---|---|---|---|---|---|---|---|---|
| ก.ย. | 0 | 0 | 50 | 5,000 | 12 | 38 | 100.00 | 3,800.00 |
| ต.ค. | 38 | 3,800 | 20 | 2,600 | 2 | 56 | 110.34 | 6,179.31 |
คงเหลือ 56 ชิ้นคือคลังหลัก 46 กับสาขา 2 อีก 10 ส่วนจ่ายออกเดือนตุลาคมเหลือ 2 ชิ้น เพราะใบขาย 5 ชิ้นที่ยกเลิกหักล้างกันเอง เหลือแค่ส่วนต่างจากการนับ
สั่งคำนวณหลังเอกสาร commit และรันทั้งหมดซ้ำทุกคืน
ยืนยันหรือยกเลิกเอกสารเมื่อไร ก็สั่งคำนวณใหม่ตั้งแต่เดือนของเอกสารนั้น เดือนหลังจากนั้นต้องคำนวณใหม่ด้วยเพราะยอดยกมาเปลี่ยน
CalculateStockValueReport::dispatch($order->document_date->format('Y-m-01'))->afterCommit();
afterCommit() ให้ job รอจน transaction ของเอกสาร commit ก่อนค่อยเข้า queue ถ้าไม่รอ worker อาจหยิบ job ไปคำนวณก่อนที่ movement ของเอกสารใบนั้นจะเห็นในฐานข้อมูล รายละเอียดอยู่ในสั่ง Job ให้รอ Database Transaction commit ก่อน
แล้วตั้งให้คำนวณทั้งหมดซ้ำตอนกลางคืน เผื่อ job ไหนล้มไปเงียบ ๆ
Schedule::job(new CalculateStockValueReport)->dailyAt('03:00');
หน้ารายงานก็แค่อ่านตาราง
$reports = StockValueReport::query()
->whereYear('month', 2026)
->with('product')
->orderBy('month')
->get();
ยอดช่องเดียว รายการเคลื่อนไหว หรือเก็บทั้งสองอย่าง
| ยอดช่องเดียว | movement อย่างเดียว | movement + ยอดในตาราง | |
|---|---|---|---|
| ไล่ย้อนว่ายอดมาจากไหน | ไม่ได้ | ได้ | ได้ |
| ยอด ณ วันที่ย้อนหลัง | ไม่ได้ | ได้ | ได้ |
| อ่านยอดปัจจุบัน | เร็ว | ช้าลงตามจำนวนแถว | เร็ว |
| กันขายเกินของที่มี | ล็อกแถวสินค้า | ต้องหาแถวอื่นมาล็อก | ล็อกแถวยอด |
| ต้องดูแลเพิ่ม | ไม่มี | index และตารางที่โตเรื่อย ๆ | สองตาราง และคำสั่ง reconcile |
ร้านที่ขายผ่านเครื่องเดียว คลังเดียว และไม่มีใครถามยอดย้อนหลัง ยอดช่องเดียวก็พอ พอมีหลายคลัง หลายเครื่องขาย หรือฝ่ายบัญชีต้องปิดยอดสิ้นเดือน แบบที่เก็บทั้งสองอย่างคุ้มกับตารางที่เพิ่มขึ้น ระบบที่เราดูแลอยู่ก็เป็นแบบนี้ และถ้าวันหนึ่งยอดในตารางเพี้ยน เราก็ยังมี movement ให้คำนวณกลับมาได้เสมอ






