feat: dukung penjualan produk per timbangan

Produk kini punya cara jual `unit` (cacah) atau `weight` (timbangan),
mengikuti kontrak backend di docs/integration-weight-based-products.md.

- tipe SellBy, domain/infrastructure unit baru, dan endpoint /api/v1/units
- dialog input berat saat produk timbangan ditambahkan ke keranjang
- tiap penimbangan jadi baris keranjang sendiri; total baris dihitung
  berat x harga satuan, sedangkan produk cacah tetap digabung
- request ke backend mengirim quantity 1 dan membawa berat
- migrasi DB v3: kolom sell_by, unit_id, unit_name, unit_abbreviation
  di tabel products supaya satuan tetap tampil saat offline
- tampilan keranjang, struk, split bill, void, dan refund memakai berat
- test/weight_based_products_test.dart menutup perilaku di atas (9 test)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
efrilm
2026-09-06 23:03:31 +07:00
co-authored by Claude Opus 5
parent 9777f63426
commit 1505a83dd3
72 changed files with 5388 additions and 584 deletions
@@ -24,6 +24,25 @@ class ListProductDto with _$ListProductDto {
);
}
/// Satuan yang menempel pada produk.
///
/// Backend hanya dijamin mengirim `unit_id`; sebagian response ikut menyertakan
/// objek `unit` lengkap. Kalau ada, dipakai langsung supaya POS tidak perlu
/// menunggu master satuan termuat.
@freezed
class ProductUnitDto with _$ProductUnitDto {
const ProductUnitDto._();
const factory ProductUnitDto({
@JsonKey(name: "id") String? id,
@JsonKey(name: "name") String? name,
@JsonKey(name: "abbreviation") String? abbreviation,
}) = _ProductUnitDto;
factory ProductUnitDto.fromJson(Map<String, dynamic> json) =>
_$ProductUnitDtoFromJson(json);
}
@freezed
class ProductDto with _$ProductDto {
const ProductDto._();
@@ -46,11 +65,28 @@ class ProductDto with _$ProductDto {
@JsonKey(name: "created_at") String? createdAt,
@JsonKey(name: "updated_at") String? updatedAt,
@JsonKey(name: "variants") List<ProductVariantDto>? variants,
@JsonKey(name: "sell_by") String? sellBy,
@JsonKey(name: "unit_id") String? unitId,
@JsonKey(name: "unit_name") String? unitName,
@JsonKey(name: "unit_abbreviation") String? unitAbbreviation,
@JsonKey(name: "unit") ProductUnitDto? unit,
}) = _ProductDto;
factory ProductDto.fromJson(Map<String, dynamic> json) =>
_$ProductDtoFromJson(json);
String get _resolvedUnitId =>
(unitId?.isNotEmpty == true ? unitId : unit?.id) ?? '';
String get _resolvedUnitName =>
(unitName?.isNotEmpty == true ? unitName : unit?.name) ?? '';
String get _resolvedUnitAbbreviation =>
(unitAbbreviation?.isNotEmpty == true
? unitAbbreviation
: unit?.abbreviation) ??
'';
/// Mapping ke domain
Product toDomain() => Product(
id: id ?? '',
@@ -70,6 +106,10 @@ class ProductDto with _$ProductDto {
createdAt: createdAt ?? '',
updatedAt: updatedAt ?? '',
variants: variants?.map((v) => v.toDomain()).toList() ?? [],
sellBy: SellBy.normalize(sellBy),
unitId: _resolvedUnitId,
unitName: _resolvedUnitName,
unitAbbreviation: _resolvedUnitAbbreviation,
);
Map<String, dynamic> toMap() => {
@@ -89,6 +129,10 @@ class ProductDto with _$ProductDto {
'is_active': isActive == true ? 1 : 0,
'created_at': createdAt,
'updated_at': updatedAt,
'sell_by': SellBy.normalize(sellBy),
'unit_id': _resolvedUnitId,
'unit_name': _resolvedUnitName,
'unit_abbreviation': _resolvedUnitAbbreviation,
};
factory ProductDto.fromMap(
@@ -117,6 +161,10 @@ class ProductDto with _$ProductDto {
createdAt: map['created_at'] as String?,
updatedAt: map['updated_at'] as String?,
variants: variants,
sellBy: map['sell_by'] as String?,
unitId: map['unit_id'] as String?,
unitName: map['unit_name'] as String?,
unitAbbreviation: map['unit_abbreviation'] as String?,
);
factory ProductDto.fromDomain(Product product) => ProductDto(
@@ -139,6 +187,10 @@ class ProductDto with _$ProductDto {
variants: product.variants
.map((v) => ProductVariantDto.fromDomain(v))
.toList(),
sellBy: product.sellBy,
unitId: product.unitId,
unitName: product.unitName,
unitAbbreviation: product.unitAbbreviation,
);
}