Dari Konsep ke Kode: Membangun Modul Penerimaan Barang yang Andal
Dari Konsep ke Kode: Membangun Modul Penerimaan Barang yang Andal
Di balik antarmuka web yang ramah pengguna, terdapat kerja sunyi yang menentukan keandalan sistem: bagaimana sebuah modul bisnis seperti Penerimaan Barang dirancang bukan hanya untuk berfungsi, tetapi untuk bertahan dan terintegrasi dengan mulus.
Proyeknya dimulai dengan satu halaman PDF. Bukan PRD yang lengkap, bukan user story yang rapi. Cuma satu halaman—print out dari dokumen prosedur manual rumah sakit—yang menjelaskan langkah-langkah penerimaan barang: cek fisik, cocokkan dengan Surat Jalan, input ke sistem, cetak tanda terima. Terlihat sederhana. Tapi bagi kita yang di balik layar, sederhana itu adalah ilusi. Di balik lima langkah itu, ada puluhan keputusan teknis yang harus diambil, ratusan edge case yang harus diantisipasi, dan satu tujuan: membuat modul ini tidak menjadi beban di masa depan.
Kerja sunyi pertama terjadi jauh sebelum kode pertama ditulis: di papan tulis (atau Miro board). Kita menggambar blok. Bukan diagram UML yang sempurna, tapi gambaran tentang bagaimana data akan mengalir. Dari mana datangnya data PO (Purchase Order)? Dari modul Purchasing, melalui API. Lalu data itu harus disimpan di mana? Kita butuh tabel `goods_receipts` sebagai header, dan `goods_receipt_items` sebagai detail. Itu sudah jelas. Tapi di sinilah detailnya dimulai: apakah status penerimaan itu enum (`DRAFT`, `PARTIAL`, `COMPLETED`, `CANCELLED`)? Bagaimana dengan relasi ke tabel inventory? Apakah stok langsung bertambah saat penerimaan disubmit, atau menunggu approval? Setiap keputusan ini adalah komitmen. Salah pilih, dan nanti akan ada tech debt yang menyakitkan.
Kami memutuskan untuk menggunakan pola yang sudah distandardisasi di kodebase: Clean Architecture (versi sederhana). Itu berarti ada lapisan-lapisan: Controller (menangani HTTP request), Service (logika bisnis), Repository (akses database), dan Model (representasi data). Keputusan ini terdengar klise, tapi itu adalah kerja sunyi yang paling penting: konsistensi. Dengan mengikuti pola yang sama seperti modul lain, developer baru (atau diri kita sendiri setahun lagi) bisa langsung paham di mana mencari apa. Tidak perlu kreativitas dalam struktur folder; kreativitas itu untuk menyelesaikan masalah bisnis yang unik.
Di Service layer, inilah tempat kerja sunyi yang sesungguhnya. Fungsi `createGoodsReceipt()` tidak boleh sekadar `INSERT INTO`. Ia harus:
- Memulai transaksi database. Kenapa? Karena penerimaan barang melibatkan banyak operasi: buat header, buat beberapa item, update status PO, mungkin update stok. Jika salah satu gagal, semua harus dibatalkan (rollback). Tanpa transaksi, kamu bisa punya header tanpa item, atau stok bertambah tapi catatan penerimaannya hilang. Itu mimpi buruk untuk direkonsiliasi.
- Validasi otorisasi. Bukan cuma cek "apakah user login?". Tapi "apakah user ini punya role `WAREHOUSE_STAFF`? Dan apakah dia punya akses ke `warehouse_id` yang bersangkutan?" Ini dilakukan di awal. Gagalkan dengan cepat jika tidak berhak.
- Validasi bisnis. Apakah PO-nya masih berstatus `APPROVED`? Apakah kuantitas yang diterima tidak melebihi kuantitas yang dipesan? Apakah batch number (jika ada) sudah diisi? Logika ini sering berantakan jika ditaruh di Controller. Di Service, ia bisa di-test secara terpisah, tanpa perlu mock HTTP request.
- Menangani error dengan bermartabat. Bukan `throw new Error("Something went wrong")`. Tapi error yang diklasifikasikan: `ValidationError`, `AuthorizationError`, `PONotFoundError`. Error jenis ini bisa ditangkap di Controller dan diubah menjadi response HTTP yang sesuai (400, 403, 404) dengan pesan yang jelas.
Contoh snippet dari service (dalam JavaScript-ish pseudocode):
async function createGoodsReceipt(user, poId, receiptData) {
// Mulai transaksi
const transaction = await db.startTransaction();
try {
// 1. Otorisasi
if (!user.roles.includes('WAREHOUSE_STAFF')) {
throw new AuthorizationError('User tidak memiliki akses.');
}
// 2. Ambil data PO & validasi
const purchaseOrder = await PurchaseOrderRepo.findById(poId, { transaction });
if (!purchaseOrder || purchaseOrder.status !== 'APPROVED') {
throw new ValidationError('PO tidak ditemukan atau belum disetujui.');
}
// 3. Buat record Goods Receipt
const goodsReceipt = await GoodsReceiptRepo.create({
po_id: poId,
received_by: user.id,
status: 'DRAFT',
...receiptData
}, { transaction });
// 4. Proses setiap item yang diterima
for (const item of receiptData.items) {
// Validasi per item
const poItem = purchaseOrder.items.find(pi => pi.id === item.po_item_id);
if (!poItem) { throw new ValidationError(`Item PO tidak valid.`); }
if (item.quantity_received > poItem.quantity_ordered) {
throw new ValidationError(`Kuantitas diterima melebihi pesanan.`);
}
// Simpan item penerimaan
await GoodsReceiptItemRepo.create({
receipt_id: goodsReceipt.id,
...item
}, { transaction });
// **Opsional, tapi kritis: Update Inventory**
if (receiptData.status === 'COMPLETED') {
await InventoryRepo.incrementStock(
item.product_id,
item.warehouse_location_id,
item.quantity_received,
{ transaction }
);
}
}
// 5. Update status PO jika semua sudah diterima
const isFullyReceived = ... // logika pengecekan
if (isFullyReceived) {
await PurchaseOrderRepo.updateStatus(poId, 'RECEIVED', { transaction });
}
// Jika semua sukses, commit transaksi
await transaction.commit();
return goodsReceipt;
} catch (error) {
// Jika ada error, rollback SEMUA perubahan
await transaction.rollback();
// Re-throw error untuk ditangani layer controller
throw error;
}
}
Ini baru satu fungsi. Tapi lihat berapa banyak kerja sunyi di dalamnya: manajemen transaksi, validasi berlapis, error handling yang menjaga integritas data. Pengguna hanya klik "Simpan". Mereka tidak akan pernah tahu tentang transaksi database yang baru saja di-rollback dengan anggun karena ada kuantitas yang salah.
Kerja sunyi berikutnya ada di Repository layer. Di sini, kita tidak boleh asal menulis query SQL. Kita harus memastikan query-nya efisien (gunakan index!), dan—yang sering terlupakan—mengembalikan data dalam format yang konsisten. Fungsi `findById` di `GoodsReceiptRepo` harus selalu mengembalikan object dengan struktur yang sama, lengkap dengan relasi `items` jika diperlukan. Inkonsistensi di layer ini akan menyebabkan kebingungan di semua layer di atasnya.
Lalu ada kerja sunyi yang paling tidak seksi: testing. Unit test untuk service, integration test untuk API endpoint, dan bahkan script untuk load test sederhana. Test bukan untuk mencapai coverage 100% demi angka. Test adalah simulasi kegagalan. Apa yang terjadi jika PO tidak ditemukan? Apa yang terjadi jika network tiba-tiba putus saat update inventory? Test memaksa kita memikirkan skenario-skenario itu sebelum terjadi di production. Menulis test adalah kerja sunyi yang membosankan, tidak ada yang melihat, tapi itulah yang membedakan kode yang "cuma jalan" dengan kode yang "siap untuk dunia nyata".
Setelah semua kode selesai dan test hijau, kerja sunyi belum berakhir. Ada deployment. Ada migration database. Script migration harus idempotent (bisa di-run berulang tanpa error) dan reversible (punya `down` migration). Ini lagi-lagi tentang antisipasi: apa yang terjadi jika deployment gagal di tengah jalan dan kita perlu rollback?
Ketika modul akhirnya live, pengguna hanya melihat form input yang bersih, tabel data, dan tombol "Simpan". Mereka tidak melihat transaksi database, lapisan otorisasi, atau suite test yang berjalan di CI/CD. Mereka tidak tahu bahwa sebuah klik sederhana telah melalui setidaknya tujuh lapisan validasi dan logika. Tapi suatu hari, ketika ada staff gudang yang tidak sengaja mencoba menerima barang untuk PO yang sudah kadaluarsa, sistem akan menolak dengan pesan yang jelas—tanpa merusak data apa pun. Atau ketika server tiba-tiba restart di tengah proses penerimaan, transaksi akan rollback dan data tetap konsisten.
Itulah tujuan dari semua kerja sunyi ini: keandalan yang tidak terlihat. Bukan fitur yang wah, tapi ketiadaan kejutan yang buruk. Membangun modul seperti ini tidak membuat kita dipuji sebagai "hero". Justru, keberhasilan kita diukur dari tidak adanya orang yang memperhatikan modul ini. Karena artinya, modul itu bekerja dengan baik, tanpa masalah, tanpa membutuhkan perhatian khusus.
Dan seperti banyak hal dalam kerja sunyi sistem, nilai sebenarnya dari sebuah modul tidak terlihat pada saat semuanya berjalan baik, tetapi pada kemampuannya untuk tetap konsisten, aman, dan dapat dipelihara saat dibutuhkan di balik layar.
Tanya Jawab Seputar Implementasi
Q: Kenapa repot-repot pakai transaksi? Apakah tidak memperlambat?
A> Transaksi memang ada overhead, tapi itu diperlukan untuk atomicity (semua atau tidak sama sekali). Tanpanya, risiko data corrupt jauh lebih mahal daripada sedikit penurunan kecepatan. Untuk operasi kritikal seperti penerimaan barang yang mengupdate banyak tabel, transaksi adalah harga yang harus dibayar untuk tidur nyenyak.
Q: Apakah semua validasi harus di Service? Bagaimana dengan validasi sederhana seperti "required field"?
A> Validasi teknis (required, format email, dll) bisa dilakukan di level Controller/DTO dengan library validation. Validasi bisnis ("apakah stok cukup?", "apakah PO masih aktif?") HARUS ada di Service. Service adalah source of truth untuk aturan bisnis.
Q: Bagaimana memutuskan kapan stok di-update? Saat status jadi 'COMPLETED' atau langsung?
A> Ini keputusan bisnis. Jika proses penerimaan punya tahap 'DRAFT' (baru input) lalu 'APPROVED' (disetujui atasan), maka update stok seharusnya di trigger oleh status 'APPROVED' atau 'COMPLETED', bukan 'DRAFT'. Logika ini harus didiskusikan dengan user dan didokumentasikan dengan jelas di kode.
Q: Test coverage berapa persen yang cukup?
A> Jangan kejar persen. Kejar skenario. Pastikan jalur utama (happy path) dan jalur error utama (validation error, not found, unauthorized) ter-cover. Coverage 70% dengan test yang bermakna jauh lebih baik daripada 95% dengan test yang asal.
Q: Apakah perlu membuat generic Error class sendiri (seperti AuthorizationError)?
A> Sangat disarankan. Itu memungkinkan error handling yang lebih granular di Controller. Kamu bisa punya satu middleware yang menangkap `AuthorizationError` dan mengembalikan HTTP 403, menangkap `ValidationError` untuk HTTP 400, dan `GenericError` untuk 500. Kodenya lebih bersih dan terstruktur.
Q: Bagaimana menangani konflik jika dua user menerima barang untuk PO yang sama bersamaan?
A> Ini masalah concurrency. Solusi bisa dengan optimistic lock (gunakan `version` field di tabel PO, cek saat update) atau pessimistic lock (lock row PO untuk update di awal transaksi). Pilihannya tergantung volume dan seberapa sering konflik terjadi. Ini kerja sunyi tambahan yang sering baru terpikir saat sudah di production.
Q: Kapan pekerjaan pada sebuah modul seperti ini dianggap "selesai"?
A> Ketika: (1) Semua requirement bisnis terpenuhi, (2) Kode telah di-review dan memenuhi standar internal, (3) Test yang relevan telah dibuat dan passed, (4) Dokumentasi teknis (minimal README) dan user (jika perlu) telah ditulis, (5) Deployment plan sudah clear. "Selesai" adalah disiplin, bukan kelelahan.
From Concept to Code: Building a Reliable Goods Receipt Module
Behind the user-friendly web interface lies the quiet work that determines system reliability: how a business module like Goods Receipt is designed not just to function, but to endure and integrate seamlessly.
The project began with a one-page PDF. Not a complete PRD, not neat user stories. Just one page—a printout from the hospital's manual procedure document—explaining the steps of receiving goods: physical check, match with the Delivery Order, input into the system, print receipt. It looked simple. But for those of us behind the screen, simplicity is an illusion. Behind those five steps, there are dozens of technical decisions to be made, hundreds of edge cases to anticipate, and one goal: to ensure this module doesn't become a burden in the future.
The first quiet work happened long before the first line of code was written: on the whiteboard (or Miro board). We drew blocks. Not perfect UML diagrams, but a picture of how data would flow. Where does the PO (Purchase Order) data come from? From the Purchasing module, via API. Then where should that data be stored? We need a `goods_receipts` table as the header, and `goods_receipt_items` as details. That was clear. But this is where the details begin: Is the receipt status an enum (`DRAFT`, `PARTIAL`, `COMPLETED`, `CANCELLED`)? What about the relation to the inventory table? Does stock increase immediately upon submit, or does it wait for approval? Each of these decisions is a commitment. Choose wrong, and there will be painful tech debt later.
We decided to use the standardized pattern in the codebase: Clean Architecture (a simplified version). That means there are layers: Controller (handles HTTP requests), Service (business logic), Repository (database access), and Model (data representation). This decision sounds cliché, but it's the most important quiet work: consistency. By following the same pattern as other modules, a new developer (or ourselves a year later) can immediately understand where to find what. No need for creativity in folder structure; creativity is for solving unique business problems.
The Service layer is where the real quiet work happens. The `createGoodsReceipt()` function cannot just be an `INSERT INTO`. It must:
- Start a database transaction. Why? Because receiving goods involves many operations: create header, create several items, update PO status, maybe update stock. If one fails, all must be canceled (rollback). Without a transaction, you could have a header without items, or stock increased but the receipt record is missing. That's a reconciliation nightmare.
- Validate authorization. Not just checking "is the user logged in?". But "does this user have the `WAREHOUSE_STAFF` role? And do they have access to the relevant `warehouse_id`?" Do this early. Fail fast if not authorized.
- Validate business rules. Is the PO still in `APPROVED` status? Is the quantity received not exceeding the quantity ordered? Is the batch number (if any) filled? This logic often gets messy if placed in the Controller. In the Service, it can be tested separately, without mocking HTTP requests.
- Handle errors with dignity. Not `throw new Error("Something went wrong")`. But classified errors: `ValidationError`, `AuthorizationError`, `PONotFoundError`. These error types can be caught in the Controller and transformed into appropriate HTTP responses (400, 403, 404) with clear messages.
Example snippet from the service (in JavaScript-ish pseudocode):
async function createGoodsReceipt(user, poId, receiptData) {
// Start transaction
const transaction = await db.startTransaction();
try {
// 1. Authorization
if (!user.roles.includes('WAREHOUSE_STAFF')) {
throw new AuthorizationError('User does not have access.');
}
// 2. Fetch PO data & validate
const purchaseOrder = await PurchaseOrderRepo.findById(poId, { transaction });
if (!purchaseOrder || purchaseOrder.status !== 'APPROVED') {
throw new ValidationError('PO not found or not approved.');
}
// 3. Create Goods Receipt record
const goodsReceipt = await GoodsReceiptRepo.create({
po_id: poId,
received_by: user.id,
status: 'DRAFT',
...receiptData
}, { transaction });
// 4. Process each received item
for (const item of receiptData.items) {
// Validate per item
const poItem = purchaseOrder.items.find(pi => pi.id === item.po_item_id);
if (!poItem) { throw new ValidationError(`Invalid PO item.`); }
if (item.quantity_received > poItem.quantity_ordered) {
throw new ValidationError(`Quantity received exceeds ordered.`);
}
// Save receipt item
await GoodsReceiptItemRepo.create({
receipt_id: goodsReceipt.id,
...item
}, { transaction });
// **Optional, but critical: Update Inventory**
if (receiptData.status === 'COMPLETED') {
await InventoryRepo.incrementStock(
item.product_id,
item.warehouse_location_id,
item.quantity_received,
{ transaction }
);
}
}
// 5. Update PO status if fully received
const isFullyReceived = ... // checking logic
if (isFullyReceived) {
await PurchaseOrderRepo.updateStatus(poId, 'RECEIVED', { transaction });
}
// If all succeed, commit transaction
await transaction.commit();
return goodsReceipt;
} catch (error) {
// If any error, rollback ALL changes
await transaction.rollback();
// Re-throw error to be handled by controller layer
throw error;
}
}
This is just one function. But look at how much quiet work is inside: transaction management, layered validation, error handling that preserves data integrity. The user just clicks "Save". They will never know about the database transaction that just gracefully rolled back because of a wrong quantity.
The next quiet work is in the Repository layer. Here, we must not write raw SQL queries carelessly. We must ensure queries are efficient (use indexes!), and—often forgotten—return data in a consistent format. The `findById` function in `GoodsReceiptRepo` must always return an object with the same structure, complete with `items` relations if needed. Inconsistency at this layer will cause confusion in all layers above it.
Then there is the least sexy quiet work: testing. Unit tests for services, integration tests for API endpoints, and even scripts for simple load testing. Tests are not for achieving 100% coverage for the sake of a number. Tests are simulations of failure. What happens if the PO is not found? What happens if the network suddenly drops during inventory update? Tests force us to think about those scenarios before they happen in production. Writing tests is boring quiet work, no one sees it, but that's what distinguishes code that "just runs" from code that is "ready for the real world."
After all the code is done and tests are green, the quiet work isn't over. There's deployment. There are database migrations. Migration scripts must be idempotent (can be run repeatedly without error) and reversible (have a `down` migration). This is again about anticipation: what happens if the deployment fails midway and we need to rollback?
When the module finally goes live, users only see a clean input form, data tables, and a "Save" button. They don't see the database transaction, the authorization layers, or the test suite running in CI/CD. They don't know that a simple click has gone through at least seven layers of validation and logic. But one day, when a warehouse staff accidentally tries to receive goods for an expired PO, the system will reject it with a clear message—without corrupting any data. Or when the server suddenly restarts mid-receipt process, the transaction will rollback and data will remain consistent.
That's the goal of all this quiet work: unseen reliability. Not flashy features, but the absence of bad surprises. Building a module like this doesn't get us praised as "heroes." In fact, our success is measured by no one paying attention to this module. Because it means the module is working well, without issues, without needing special attention.
And like many things in the quiet work of systems, the true value of a module isn't seen when everything is running well, but in its ability to remain consistent, secure, and maintainable when needed behind the screen.
Q&A on Implementation
Q: Why bother with transactions? Doesn't it slow things down?
A> Transactions do have overhead, but they are necessary for atomicity (all or nothing). Without it, the risk of data corruption is far more costly than a slight performance dip. For critical operations like goods receipt that update many tables, transactions are the price for peace of mind.
Q: Should all validation be in the Service? What about simple validation like "required field"?
A> Technical validation (required, email format, etc.) can be done at the Controller/DTO level with a validation library. Business validation ("is stock sufficient?", "is the PO still active?") MUST be in the Service. The Service is the source of truth for business rules.
Q: How to decide when to update stock? When status becomes 'COMPLETED' or immediately?
A> This is a business decision. If the receiving process has a 'DRAFT' stage (just input) then 'APPROVED' (approved by supervisor), then stock update should be triggered by the 'APPROVED' or 'COMPLETED' status, not 'DRAFT'. This logic must be discussed with users and clearly documented in the code.
Q: What test coverage percentage is enough?
A> Don't chase percentages. Chase scenarios. Ensure the main path (happy path) and major error paths (validation error, not found, unauthorized) are covered. 70% coverage with meaningful tests is far better than 95% with trivial tests.
Q: Is it necessary to create custom Error classes (like AuthorizationError)?
A> Highly recommended. It allows for more granular error handling in the Controller. You can have a single middleware that catches `AuthorizationError` and returns HTTP 403, catches `ValidationError` for HTTP 400, and `GenericError` for 500. The code is cleaner and more structured.
Q: How to handle concurrency if two users receive goods for the same PO simultaneously?
A> This is a concurrency problem. Solutions can include optimistic locking (use a `version` field in the PO table, check during update) or pessimistic locking (lock the PO row for update at the start of the transaction). The choice depends on volume and how often conflicts occur. This is additional quiet work often only thought of when already in production.
Q: When is work on a module like this considered "done"?
A> When: (1) All business requirements are met, (2) Code has been reviewed and meets internal standards, (3) Relevant tests have been created and passed, (4) Technical documentation (at least a README) and user docs (if needed) have been written, (5) The deployment plan is clear. "Done" is a discipline, not exhaustion.
Thank you for stopping by! If you enjoy the content and would like to show your support, how about treating me to a cup of coffee? �� It’s a small gesture that helps keep me motivated to continue creating awesome content. No pressure, but your coffee would definitely make my day a little brighter. ☕️ Buy Me Coffee

Post a Comment for "Dari Konsep ke Kode: Membangun Modul Penerimaan Barang yang Andal"
Post a Comment
You are welcome to share your ideas with us in comments!