diff --git a/docs/integration-weight-based-products.md b/docs/integration-weight-based-products.md index 61b5b16..1d9d658 100644 --- a/docs/integration-weight-based-products.md +++ b/docs/integration-weight-based-products.md @@ -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). diff --git a/internal/mappers/product_mapper.go b/internal/mappers/product_mapper.go index 57f92ba..c9d1aa4 100644 --- a/internal/mappers/product_mapper.go +++ b/internal/mappers/product_mapper.go @@ -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, diff --git a/internal/processor/product_processor.go b/internal/processor/product_processor.go index f778833..e08ea7c 100644 --- a/internal/processor/product_processor.go +++ b/internal/processor/product_processor.go @@ -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) } diff --git a/internal/validator/product_validator.go b/internal/validator/product_validator.go index 82d4e22..a5943bb 100644 --- a/internal/validator/product_validator.go +++ b/internal/validator/product_validator.go @@ -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, "" } diff --git a/internal/validator/product_validator_sell_by_test.go b/internal/validator/product_validator_sell_by_test.go new file mode 100644 index 0000000..6a6dd56 --- /dev/null +++ b/internal/validator/product_validator_sell_by_test.go @@ -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) + } +}