Skip to content

Commit f2d36ca

Browse files
Fix decimal truncation & stock display precision (#20)
* fix(dev): resolve SELinux volume mount permissions for PostgreSQL and MinIO containers * fix(inventory): correct comma parsing and gram rounding errors Fix two inventory bugs where commas were truncated in decimal quantities and grams displayed with incorrect rounding. Added unit tests to prevent regressions.
1 parent acc3754 commit f2d36ca

3 files changed

Lines changed: 210 additions & 150 deletions

File tree

lib/craftplan/types/unit.ex

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,5 +128,11 @@ defmodule Craftplan.Types.Unit do
128128
# Helper function to format numbers nicely
129129
defp format_number(value) when is_integer(value), do: "#{value}"
130130
defp format_number(value) when value == trunc(value), do: "#{trunc(value)}"
131-
defp format_number(value), do: :erlang.float_to_binary(value, decimals: 1)
131+
132+
defp format_number(value) do
133+
value
134+
|> :erlang.float_to_binary(decimals: 3)
135+
|> String.trim_trailing("0")
136+
|> String.trim_trailing(".")
137+
end
132138
end

lib/craftplan_web/live/manage/inventory_live/form_component_movement.ex

Lines changed: 104 additions & 148 deletions
Original file line numberDiff line numberDiff line change
@@ -4,215 +4,171 @@ defmodule CraftplanWeb.InventoryLive.FormComponentMovement do
44

55
alias AshPhoenix.Form
66
alias Craftplan.Inventory
7+
alias Decimal, as: D
8+
9+
@impl true
10+
def update(assigns, socket) do
11+
{:ok,
12+
socket
13+
|> assign(assigns)
14+
|> assign_new(:mode, fn -> :add end)
15+
|> assign_new(:calculated_new_total, fn -> nil end)
16+
|> assign_new(:form, fn -> build_form(assigns.current_user) end)}
17+
end
718

819
@impl true
920
def render(assigns) do
1021
~H"""
1122
<div>
1223
<.simple_form
1324
for={@form}
14-
id="movement-movment-form"
25+
id="movement-form"
1526
phx-target={@myself}
1627
phx-change="validate"
1728
phx-submit="save"
1829
>
1930
<div class="mb-4">
20-
<div class="mb-2">
21-
<div class="flex items-center">
22-
<button
23-
type="button"
24-
phx-click="toggle_adjustment_type"
25-
phx-value-type="set_total"
26-
phx-target={@myself}
27-
class={[
28-
@adjustment_type == "set_total" && "bg-stone-200 text-stone-800",
29-
@adjustment_type != "set_total" && "bg-stone-50 text-stone-700 hover:bg-stone-100",
30-
"flex cursor-pointer items-center rounded-l-md border-y border-l border-stone-300 px-3 py-1 text-xs font-medium disabled:cursor-default disabled:bg-stone-100 disabled:text-stone-400"
31-
]}
32-
>
33-
Set Total
34-
</button>
35-
<button
36-
type="button"
37-
phx-click="toggle_adjustment_type"
38-
phx-value-type="add"
39-
phx-target={@myself}
40-
class={[
41-
@adjustment_type == "add" && "bg-stone-200 text-stone-800",
42-
@adjustment_type != "add" && "bg-stone-50 text-stone-700 hover:bg-stone-100",
43-
"flex cursor-pointer items-center border-y border-stone-300 px-3 py-1 text-xs font-medium disabled:cursor-default disabled:bg-stone-100 disabled:text-stone-400"
44-
]}
45-
>
46-
Add
47-
</button>
48-
<button
49-
type="button"
50-
phx-click="toggle_adjustment_type"
51-
phx-value-type="subtract"
52-
phx-target={@myself}
53-
class={[
54-
@adjustment_type == "subtract" && "bg-stone-200 text-stone-800",
55-
@adjustment_type != "subtract" && "bg-stone-50 text-stone-700 hover:bg-stone-100",
56-
"flex cursor-pointer items-center rounded-r-md border-y border-r border-stone-300 px-3 py-1 text-xs font-medium disabled:cursor-default disabled:bg-stone-100 disabled:text-stone-400"
57-
]}
58-
>
59-
Subtract
60-
</button>
61-
</div>
31+
<div class="inline-flex rounded-md border border-stone-300">
32+
<button
33+
type="button"
34+
phx-click="set_mode"
35+
phx-value-mode="add"
36+
phx-target={@myself}
37+
class={[
38+
"rounded-l-md px-4 py-1.5 text-xs font-medium transition-colors",
39+
@mode == :add && "bg-stone-800 text-white",
40+
@mode != :add && "bg-white text-stone-600 hover:bg-stone-50"
41+
]}
42+
>
43+
Add
44+
</button>
45+
<button
46+
type="button"
47+
phx-click="set_mode"
48+
phx-value-mode="subtract"
49+
phx-target={@myself}
50+
class={[
51+
"rounded-r-md border-l border-stone-300 px-4 py-1.5 text-xs font-medium transition-colors",
52+
@mode == :subtract && "bg-stone-800 text-white",
53+
@mode != :subtract && "bg-white text-stone-600 hover:bg-stone-50"
54+
]}
55+
>
56+
Subtract
57+
</button>
6258
</div>
6359
</div>
6460
65-
<div :if={@adjustment_type == "add" || @adjustment_type == "subtract"}>
66-
<.focus_wrap id="adjustment_quantity_focus_wrap">
67-
<.input
68-
field={@form[:quantity]}
69-
type="number"
70-
min="0"
71-
label="Quantity"
72-
inline_label={@material.unit}
73-
phx-change="validate"
74-
id="adjustment_quantity"
75-
/>
76-
</.focus_wrap>
77-
<.input field={@form[:operation]} type="hidden" value={@adjustment_type} />
78-
</div>
79-
80-
<div :if={@adjustment_type == "set_total"}>
81-
<.input
82-
field={@form[:quantity]}
83-
type="number"
84-
min="0"
85-
label="New Total"
86-
inline_label={@material.unit}
87-
phx-change="validate"
88-
/>
89-
</div>
61+
<.input
62+
field={@form[:quantity]}
63+
type="number"
64+
min="0"
65+
step="any"
66+
label={if @mode == :add, do: "Quantity to add", else: "Quantity to subtract"}
67+
inline_label={@material.unit}
68+
id="movement-quantity"
69+
/>
9070
9171
<.input field={@form[:reason]} type="textarea" label="Notes" class="mt-3" />
9272
<.input field={@form[:material_id]} type="hidden" value={@material.id} />
9373
94-
<div class="mt-4 rounded-md border border-stone-200 bg-stone-50 p-3 text-stone-700">
95-
<div class="text-sm">
96-
<span :if={is_number(@calculated_new_total)}>
97-
<span class="font-medium">New stock will be:</span>
98-
<span class="font-bold text-stone-900">
99-
{format_amount(@material.unit, @calculated_new_total)}
100-
</span>
101-
</span>
102-
<span :if={!is_number(@calculated_new_total)}>
103-
Enter a quantity to see the new stock level
74+
<div class="mt-4 rounded-md border border-stone-200 bg-stone-50 p-3 text-sm text-stone-700">
75+
<%= if @calculated_new_total do %>
76+
<span class="font-medium">New stock will be: </span>
77+
<span class={[
78+
"font-bold",
79+
negative?(@calculated_new_total) && "text-red-600",
80+
!negative?(@calculated_new_total) && "text-stone-900"
81+
]}>
82+
{format_preview(@material.unit, @calculated_new_total)}
10483
</span>
105-
</div>
84+
<% else %>
85+
Enter a quantity to see the new stock level
86+
<% end %>
10687
</div>
10788
10889
<:actions>
109-
<.button variant={:primary} phx-disable-with="Saving...">
110-
Save
111-
</.button>
90+
<.button variant={:primary} phx-disable-with="Saving...">Save</.button>
11291
</:actions>
11392
</.simple_form>
11493
</div>
11594
"""
11695
end
11796

11897
@impl true
119-
def update(assigns, socket) do
120-
{:ok,
121-
socket
122-
|> assign(assigns)
123-
|> assign(adjustment_type: "set_total")
124-
|> assign(:calculated_new_total, nil)
125-
|> assign_form()}
126-
end
98+
def handle_event("set_mode", %{"mode" => mode}, socket) do
99+
mode = String.to_existing_atom(mode)
100+
quantity = parse_quantity(socket.assigns.form.params["quantity"])
101+
current_stock = socket.assigns.material.current_stock
127102

128-
@impl true
129-
def handle_event("toggle_adjustment_type", %{"type" => adjustment_type}, socket) do
130-
quantity = parse_integer(socket.assigns.form.params["quantity"])
131-
calculated_new_total = calculate_new_total(socket, adjustment_type, quantity)
132-
133-
{:noreply, assign(socket, adjustment_type: adjustment_type, calculated_new_total: calculated_new_total)}
103+
{:noreply,
104+
socket
105+
|> assign(:mode, mode)
106+
|> assign(:calculated_new_total, new_total(current_stock, mode, quantity))}
134107
end
135108

136109
@impl true
137-
def handle_event("validate", %{"movement" => movement_params}, socket) do
138-
quantity = parse_integer(movement_params["quantity"])
139-
calculated_new_total = calculate_new_total(socket, socket.assigns.adjustment_type, quantity)
110+
def handle_event("validate", %{"movement" => params}, socket) do
111+
quantity = parse_quantity(params["quantity"])
112+
current_stock = socket.assigns.material.current_stock
140113

141114
{:noreply,
142115
socket
143-
|> assign(form: Form.validate(socket.assigns.form, movement_params))
144-
|> assign(:calculated_new_total, calculated_new_total)}
116+
|> assign(:form, Form.validate(socket.assigns.form, params))
117+
|> assign(:calculated_new_total, new_total(current_stock, socket.assigns.mode, quantity))}
145118
end
146119

147-
def handle_event("save", %{"movement" => movement_params}, socket) do
148-
movement_params = prepare_movement_params(movement_params, socket.assigns.adjustment_type)
120+
@impl true
121+
def handle_event("save", %{"movement" => params}, socket) do
122+
params = sign_quantity(params, socket.assigns.mode)
149123

150-
case Form.submit(socket.assigns.form, params: movement_params) do
124+
case Form.submit(socket.assigns.form, params: params) do
151125
{:ok, movement} ->
152126
send(self(), {:saved, movement})
153127

154128
{:noreply,
155129
socket
156-
|> put_flash(:info, "Material #{socket.assigns.form.source.type}d successfully")
130+
|> put_flash(:info, "Stock adjustment recorded")
157131
|> push_patch(to: socket.assigns.patch)}
158132

159133
{:error, form} ->
160134
{:noreply, assign(socket, :form, form)}
161135
end
162136
end
163137

164-
defp calculate_new_total(socket, adjustment_type, quantity) do
165-
current_quantity = socket.assigns.material.current_stock
166-
167-
case adjustment_type do
168-
"add" ->
169-
if is_integer(quantity) do
170-
result = Decimal.add(current_quantity, quantity)
171-
Decimal.to_float(result)
172-
end
173-
174-
"subtract" ->
175-
if is_integer(quantity) do
176-
result = Decimal.sub(current_quantity, quantity)
177-
result = if Decimal.compare(result, 0) == :lt, do: Decimal.new(0), else: result
178-
Decimal.to_float(result)
179-
end
180-
181-
"set_total" ->
182-
if is_integer(quantity), do: quantity * 1.0
138+
defp new_total(_current_stock, _mode, nil), do: nil
139+
defp new_total(current_stock, :add, quantity), do: D.add(current_stock, quantity)
140+
defp new_total(current_stock, :subtract, quantity), do: D.sub(current_stock, quantity)
141+
142+
defp sign_quantity(params, :subtract) do
143+
case parse_quantity(params["quantity"]) do
144+
nil -> params
145+
qty -> Map.put(params, "quantity", D.to_string(D.negate(qty)))
183146
end
184147
end
185148

186-
defp prepare_movement_params(params, adjustment_type) when adjustment_type in ["add", "subtract"] do
187-
quantity = String.to_integer(params["quantity"] || "0")
188-
189-
quantity =
190-
case adjustment_type do
191-
"subtract" -> -quantity
192-
_ -> quantity
193-
end
149+
defp sign_quantity(params, :add), do: params
194150

195-
Map.put(params, "quantity", to_string(quantity))
151+
defp parse_quantity(value) when is_binary(value) and value != "" do
152+
case D.parse(value) do
153+
{d, ""} -> d
154+
_ -> nil
155+
end
196156
end
197157

198-
defp prepare_movement_params(params, _), do: params
158+
defp parse_quantity(_), do: nil
199159

200-
defp assign_form(socket) do
201-
form =
202-
Form.for_create(Inventory.Movement, :adjust_stock,
203-
as: "movement",
204-
actor: socket.assigns.current_user
205-
)
160+
defp negative?(value), do: D.compare(value, D.new(0)) == :lt
206161

207-
assign(socket, form: to_form(form))
162+
defp format_preview(unit, %D{} = value) do
163+
format_amount(unit, D.to_float(value))
208164
end
209165

210-
defp parse_integer(value) when is_binary(value) do
211-
case Integer.parse(value) do
212-
{int, _} -> int
213-
:error -> nil
214-
end
166+
defp build_form(current_user) do
167+
Inventory.Movement
168+
|> Form.for_create(:adjust_stock,
169+
as: "movement",
170+
actor: current_user
171+
)
172+
|> to_form()
215173
end
216-
217-
defp parse_integer(_), do: nil
218174
end

0 commit comments

Comments
 (0)