Feature/weight based products #29

Merged
aefril merged 3 commits from feature/weight-based-products into main 2026-09-06 17:13:53 +02:00
5 changed files with 196 additions and 9 deletions
Showing only changes of commit ebf666c004 - Show all commits
+18 -8
View File
@@ -71,10 +71,14 @@ pendek yang benar-benar ingin ditampilkan. Daftar satuan dibaca lewat `GET /api/
| Field | Tipe | Keterangan |
|---|---|---|
| `sell_by` | `"unit"` \| `"weight"` | Opsional, default `"unit"`. Nilai lain ditolak validator. |
| `unit_id` | UUID | Opsional di backend, tapi **wajib secara praktik** untuk `sell_by: "weight"` — lihat §8. |
| `sell_by` | `"unit"` \| `"weight"` | Opsional, default `"unit"`. Nilai lain **ditolak** dengan pesan jelas. |
| `unit_id` | UUID | **Wajib** saat `sell_by: "weight"`, ditolak backend bila kosong. Opsional untuk produk satuan. |
| `price` | number | Harga per satu satuan. Rp 4.500 per ons, bukan harga per ikan. |
Pada `PUT /api/v1/products/:id`, `unit_id` **tidak perlu dikirim ulang** bila produknya
sudah punya satuan — mengubah `sell_by` menjadi `"weight"` saja sudah cukup. Yang ditolak
adalah kondisi akhirnya: produk yang dijual per timbangan tanpa satuan.
Keduanya juga bisa diubah lewat `PUT /api/v1/products/:id` dengan bentuk yang sama, dan
ikut terbaca di setiap response produk (`GET /api/v1/products`, `/products/all`,
`/products/:id`).
@@ -84,8 +88,11 @@ ikut terbaca di setiap response produk (`GET /api/v1/products`, `/products/all`,
- Kunci `sell_by` **setelah produk punya transaksi**. Mengubah produk lama dari `unit`
ke `weight` tidak mengubah baris order yang sudah ada — baris lama tetap dihitung per
cacah — tapi akan membingungkan pengguna yang melihat riwayatnya.
- Saat `weight` dipilih, jadikan pemilih satuan sebagai field **wajib** di form.
- Saat `weight` dipilih, jadikan pemilih satuan sebagai field **wajib** di form. Backend
juga menolaknya, tapi ditangkap di form lebih baik daripada baru gagal saat simpan.
- Ubah label harga mengikuti satuan yang dipilih: *"Harga per ons"*.
- Untuk produk satuan, pemilih satuan boleh disembunyikan — `unit_id` opsional dan belum
dikonsumsi apa pun di POS.
---
@@ -213,6 +220,14 @@ Semua error mengikuti amplop standar. Pesan validasi baru muncul dengan kode `90
Pesan menyebut **nama produk**, sehingga bisa ditampilkan apa adanya ke kasir.
### Setup produk (Backoffice)
| Pesan (`cause`) | Penyebab | Perbaikan di klien |
|---|---|---|
| `unit_id is required when sell_by is 'weight'` | Produk timbangan dibuat tanpa satuan | Wajibkan pemilih satuan saat Timbangan dipilih |
| `sell_by must be either 'unit' or 'weight'` | Nilai `sell_by` di luar dua itu | Kirim persis `"unit"` atau `"weight"` |
| `product '…' is sold by weight and requires a unit_id` | Update membuat produk jadi timbangan tanpa satuan | Kirim `unit_id` bersama perubahan `sell_by` |
---
## 7. Kompatibilitas mundur
@@ -231,11 +246,6 @@ permintaannya ditolak dengan pesan jelas, bukan gagal diam-diam.
## 8. Batasan yang diketahui
> **Perlu ditangani di frontend.** Backend **belum** memaksa `unit_id` terisi saat
> `sell_by: "weight"`. Produk timbangan tanpa satuan akan tersimpan, tapi baris ordernya
> keluar dengan `unit_abbreviation: null` — struk tidak bisa mencetak "ons". Sampai
> validasi itu ditambahkan di backend, **Backoffice wajib mewajibkannya di form**.
- **Pembulatan uang ke 2 desimal.** `4,237 ons × Rp 4.500` tersimpan `Rp 19.066,50`,
bukan dibulatkan ke rupiah utuh. Bila kasir harus menerima rupiah penuh, ini perlu
diputuskan dan diubah di backend lebih dulu (`RoundMoney`, satu tempat).
+5
View File
@@ -25,7 +25,9 @@ func ProductEntityToModel(entity *entities.Product) *models.Product {
BusinessType: constants.BusinessType(entity.BusinessType),
ImageURL: entity.ImageURL,
PrinterType: entity.PrinterType,
UnitID: entity.UnitID,
SellBy: entity.SellBy,
HasIngredients: entity.HasIngredients,
Metadata: map[string]interface{}(entity.Metadata),
IsActive: entity.IsActive,
CreatedAt: entity.CreatedAt,
@@ -50,6 +52,9 @@ func ProductModelToEntity(model *models.Product) *entities.Product {
BusinessType: string(model.BusinessType),
ImageURL: model.ImageURL,
PrinterType: model.PrinterType,
UnitID: model.UnitID,
SellBy: model.SellBy,
HasIngredients: model.HasIngredients,
Metadata: entities.Metadata(model.Metadata),
IsActive: model.IsActive,
CreatedAt: model.CreatedAt,
+8
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/mappers"
@@ -191,6 +192,13 @@ func (p *ProductProcessorImpl) UpdateProduct(ctx context.Context, id uuid.UUID,
mappers.UpdateProductEntityFromRequest(existingProduct, req)
// Checked after the merge, not on the request: switching a product to sell_by
// "weight" is valid when it already carries a unit, and clearing the unit is
// invalid when it is already sold by weight. Only the merged product shows either.
if existingProduct.SellBy == constants.SellByWeight && existingProduct.UnitID == nil {
return nil, fmt.Errorf("product '%s' is sold by weight and requires a unit_id", existingProduct.Name)
}
if err := p.productRepo.Update(ctx, existingProduct); err != nil {
return nil, fmt.Errorf("failed to update product: %w", err)
}
+35 -1
View File
@@ -63,6 +63,32 @@ func (v *ProductValidatorImpl) ValidateCreateProductRequest(req *contract.Create
return errors.New("printer_type cannot exceed 50 characters"), constants.MalformedFieldErrorCode
}
if err, code := validateSellBy(req.SellBy, req.UnitID); err != nil {
return err, code
}
return nil, ""
}
// validateSellBy checks how a product is sold and that it carries what that choice
// needs. A weight-based product without a unit would produce order lines with no unit
// to print, so the receipt could show "4,2" with no idea of what.
//
// unitID is the unit the request would leave on the product: for an update that does
// not touch unit_id, pass the product's current one.
func validateSellBy(sellBy *string, unitID *uuid.UUID) (error, string) {
if sellBy == nil {
return nil, ""
}
if !constants.IsValidSellBy(*sellBy) {
return errors.New("sell_by must be either 'unit' or 'weight'"), constants.MalformedFieldErrorCode
}
if *sellBy == constants.SellByWeight && unitID == nil {
return errors.New("unit_id is required when sell_by is 'weight'"), constants.MissingFieldErrorCode
}
return nil, ""
}
@@ -74,7 +100,8 @@ func (v *ProductValidatorImpl) ValidateUpdateProductRequest(req *contract.Update
// At least one field should be provided for update
if req.CategoryID == nil && req.SKU == nil && req.Name == nil && req.Description == nil &&
req.Price == nil && req.Cost == nil && req.BusinessType == nil && req.ImageURL == nil &&
req.PrinterType == nil && req.Metadata == nil && req.IsActive == nil {
req.PrinterType == nil && req.PrintToChecker == nil && req.UnitID == nil &&
req.SellBy == nil && req.Metadata == nil && req.IsActive == nil {
return errors.New("at least one field must be provided for update"), constants.MissingFieldErrorCode
}
@@ -111,6 +138,13 @@ func (v *ProductValidatorImpl) ValidateUpdateProductRequest(req *contract.Update
return errors.New("printer_type cannot exceed 50 characters"), constants.MalformedFieldErrorCode
}
// Only the value is checked here. Whether the product ends up with a unit depends on
// what it already has, which this request cannot see — the processor checks that
// against the stored product.
if req.SellBy != nil && !constants.IsValidSellBy(*req.SellBy) {
return errors.New("sell_by must be either 'unit' or 'weight'"), constants.MalformedFieldErrorCode
}
return nil, ""
}
@@ -0,0 +1,130 @@
package validator
import (
"testing"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"github.com/google/uuid"
)
func strPtr(s string) *string { return &s }
func baseCreateRequest() *contract.CreateProductRequest {
return &contract.CreateProductRequest{
CategoryID: uuid.New(),
Name: "Ikan Tude",
Price: 4500,
}
}
func TestValidateCreateProductRequestSellBy(t *testing.T) {
unitID := uuid.New()
tests := []struct {
name string
sellBy *string
unitID *uuid.UUID
wantErr bool
wantMsg string
}{
{
name: "omitted sell_by is allowed and defaults to unit",
sellBy: nil, unitID: nil,
},
{
name: "unit product needs no unit_id",
sellBy: strPtr(constants.SellByUnit), unitID: nil,
},
{
name: "weight product with a unit is accepted",
sellBy: strPtr(constants.SellByWeight), unitID: &unitID,
},
{
name: "weight product without a unit is rejected",
// Otherwise its order lines would have no unit to print on the receipt.
sellBy: strPtr(constants.SellByWeight), unitID: nil,
wantErr: true,
wantMsg: "unit_id is required when sell_by is 'weight'",
},
{
name: "unknown sell_by is rejected rather than silently corrected",
// The struct tags on this contract are not enforced — this validator is
// hand-written — so the check has to be explicit.
sellBy: strPtr("pisang"), unitID: &unitID,
wantErr: true,
wantMsg: "sell_by must be either 'unit' or 'weight'",
},
}
v := NewProductValidator()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := baseCreateRequest()
req.SellBy = tt.sellBy
req.UnitID = tt.unitID
err, code := v.ValidateCreateProductRequest(req)
if !tt.wantErr {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
return
}
if err == nil {
t.Fatal("expected an error, got none")
}
if err.Error() != tt.wantMsg {
t.Errorf("message = %q, want %q", err.Error(), tt.wantMsg)
}
if code == "" {
t.Error("expected an error code")
}
})
}
}
// An update carrying only sell_by must not be turned away as an empty update.
func TestValidateUpdateProductRequestAcceptsSellByAlone(t *testing.T) {
v := NewProductValidator()
err, _ := v.ValidateUpdateProductRequest(&contract.UpdateProductRequest{
SellBy: strPtr(constants.SellByWeight),
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
err, _ = v.ValidateUpdateProductRequest(&contract.UpdateProductRequest{})
if err == nil {
t.Error("an update with no fields at all should be rejected")
}
}
func TestValidateUpdateProductRequestRejectsUnknownSellBy(t *testing.T) {
v := NewProductValidator()
err, _ := v.ValidateUpdateProductRequest(&contract.UpdateProductRequest{
SellBy: strPtr("timbangan"),
})
if err == nil {
t.Fatal("expected an error for an unknown sell_by")
}
}
// The update path deliberately does NOT require unit_id on the request: a product that
// already has a unit can be switched to sell_by "weight" without resending it. That
// pairing is checked by the processor against the stored product.
func TestValidateUpdateProductRequestDefersUnitCheck(t *testing.T) {
v := NewProductValidator()
err, _ := v.ValidateUpdateProductRequest(&contract.UpdateProductRequest{
SellBy: strPtr(constants.SellByWeight),
})
if err != nil {
t.Fatalf("update should not require unit_id on the request, got: %v", err)
}
}