diff --git a/lib/scheduler/scheduler_agent.ex b/lib/scheduler/scheduler_agent.ex index 47c4f25..6d2068d 100644 --- a/lib/scheduler/scheduler_agent.ex +++ b/lib/scheduler/scheduler_agent.ex @@ -8,6 +8,10 @@ defmodule TheronsErp.Scheduler.SchedulerAgent do GenServer.start_link(__MODULE__, %{location_id: location}) end + def add_sales_order(pid, sales_order) do + GenServer.call(pid, {:add_sales_order, sales_order}) + end + def add_peer(pid, {_location, _product, _pid} = peer) do GenServer.call(pid, {:add_peer, peer}) end @@ -36,6 +40,10 @@ defmodule TheronsErp.Scheduler.SchedulerAgent do GenServer.call(pid, {:propagate_push_route, quantity, product_id}) end + def propagate_pull_route(pid, quantity, product_id) do + GenServer.call(pid, {:propagate_pull_route, quantity, product_id}) + end + def add_purchase_order(pid, purchase_order) do GenServer.call(pid, {:add_purchase_order, purchase_order}) end @@ -62,6 +70,7 @@ defmodule TheronsErp.Scheduler.SchedulerAgent do state = Map.put(state, :new_movements, []) state = Map.put(state, :stale_movements, %{}) state = Map.put(state, :purchase_orders, []) + state = Map.put(state, :sales_orders, []) {:ok, state} end @@ -97,6 +106,12 @@ defmodule TheronsErp.Scheduler.SchedulerAgent do end) end + defp get_from_peer_for_product(state, product_id) do + Enum.find(state.peers, fn {{_, product_id_m}, _} -> + product_id_m == product_id + end) + end + def handle_call({:set_product_inventory, {product, amount}}, _from, state) do {:reply, :ok, put_inv(product, amount, state)} end @@ -113,7 +128,8 @@ defmodule TheronsErp.Scheduler.SchedulerAgent do {:reply, :ok, Map.put(state, :stale_movements, [movement | state.stale_movements])} end - def handle_call({:propagate_push_route, quantity, product_id}, _from, state) do + def handle_call({:propagate_push_route, quantity, product_id}, _from, state) + when not is_nil(quantity) do # TODO modify a stale movement if possible. peer = get_to_peer_for_product(state, product_id) @@ -135,6 +151,27 @@ defmodule TheronsErp.Scheduler.SchedulerAgent do end end + def handle_call({:propagate_pull_route, quantity, product_id}, _from, state) do + peer = get_from_peer_for_product(state, product_id) + + if peer do + {{location_id, _product_id}, pid} = peer + + movement = %{ + quantity: quantity, + product_id: product_id, + to_inventory_id: state.inventory_id, + from_inventory_id: location_id + } + + propagate_pull_route(pid, quantity, product_id) + + {:reply, :ok, add_movements(state, movement)} + else + {:reply, :ok, state} + end + end + def handle_call({:add_purchase_order, purchase_order}, _from, state) do accts = generate_po_accts(purchase_order) @@ -214,7 +251,7 @@ defmodule TheronsErp.Scheduler.SchedulerAgent do from_location_id: from_inventory_id, to_location_id: to_inventory_id, product_id: product_id, - quantity: Money.new(quantity, :XIT) + quantity: Decimal.new(quantity) })} end @@ -236,6 +273,51 @@ defmodule TheronsErp.Scheduler.SchedulerAgent do {:reply, :ok, state} end + def handle_call({:add_sales_order, sales_order}, _from, state) do + # Add sales order to the scheduler + accts = generate_so_accts(sales_order) + + movements = + for item <- sales_order.sales_lines do + peer = get_from_peer_for_product(state, item.product_id) + + if peer do + {{location_id, _product_id}, pid} = peer + propagate_pull_route(pid, item.quantity, item.product_id) + + [ + %{ + quantity: item.quantity, + product_id: item.product_id, + from_inventory_id: state.location_id, + to_inventory_id: accts[item.id].id + }, + %{ + quantity: item.quantity, + product_id: item.product_id, + to_inventory_id: state.location_id, + from_inventory_id: location_id + } + ] + else + [ + %{ + quantity: item.quantity, + product_id: item.product_id, + to_inventory_id: accts[item.id].id, + from_inventory_id: state.location_id + } + ] + end + end + |> List.flatten() + + {:reply, :ok, + state + |> Map.put(:sales_orders, [sales_order | state.sales_orders]) + |> add_movements(movements)} + end + defp find_route_for_product(product_id, state) do state.to_peers[product_id] end @@ -258,6 +340,24 @@ defmodule TheronsErp.Scheduler.SchedulerAgent do end end + defp get_so_identifier(so, so_item) do + "so.#{so.id}.#{so_item.id}" + end + + defp generate_so_accts(so) do + for item <- so.sales_lines, into: %{} do + acct = + TheronsErp.Ledger.Account + |> Ash.Changeset.for_create(:open, %{ + currency: "XIT", + identifier: get_so_identifier(so, item) + }) + |> Ash.create!() + + {item.id, acct} + end + end + defp get_inv_identifier(type, location_id, product) do TheronsErp.Inventory.Movement.get_inv_identifier(type, location_id, product.identifier) end diff --git a/lib/therons_erp/inventory.ex b/lib/therons_erp/inventory.ex index d694670..423806f 100644 --- a/lib/therons_erp/inventory.ex +++ b/lib/therons_erp/inventory.ex @@ -6,6 +6,7 @@ defmodule TheronsErp.Inventory do end resources do + resource TheronsErp.Inventory.ProductRoutes resource TheronsErp.Inventory.Routes resource TheronsErp.Inventory.Movement resource TheronsErp.Inventory.Location diff --git a/lib/therons_erp/inventory/movement.ex b/lib/therons_erp/inventory/movement.ex index 0029810..cc90db2 100644 --- a/lib/therons_erp/inventory/movement.ex +++ b/lib/therons_erp/inventory/movement.ex @@ -82,7 +82,7 @@ defmodule TheronsErp.Inventory.Movement do %{ from_account_id: from_account_id, to_account_id: to_account_id, - amount: amount + amount: Money.new(amount, :XIT) } ) |> Ash.create() @@ -94,7 +94,7 @@ defmodule TheronsErp.Inventory.Movement do attributes do uuid_primary_key :id - attribute :quantity, :money + attribute :quantity, :decimal attribute :manually_created, :boolean, default: false diff --git a/lib/therons_erp/inventory/product.ex b/lib/therons_erp/inventory/product.ex index 0f7ef68..00d0e2e 100644 --- a/lib/therons_erp/inventory/product.ex +++ b/lib/therons_erp/inventory/product.ex @@ -31,8 +31,7 @@ defmodule TheronsErp.Inventory.Product do :category_id, :saleable, :purchaseable, - :cost, - :route_id + :cost ] end @@ -52,8 +51,7 @@ defmodule TheronsErp.Inventory.Product do :category_id, :saleable, :purchaseable, - :cost, - :route_id + :cost ] end @@ -94,7 +92,13 @@ defmodule TheronsErp.Inventory.Product do relationships do belongs_to :category, TheronsErp.Inventory.ProductCategory - belongs_to :route, TheronsErp.Inventory.Routes + many_to_many :routes, TheronsErp.Inventory.Routes do + through TheronsErp.Inventory.ProductRoutes + source_attribute :id + source_attribute_on_join_resource :product_id + destination_attribute :id + destination_attribute_on_join_resource :routes_id + end has_one :replenishments, TheronsErp.Purchasing.Replenishment end diff --git a/lib/therons_erp/inventory/product_routes.ex b/lib/therons_erp/inventory/product_routes.ex new file mode 100644 index 0000000..5b5bf1b --- /dev/null +++ b/lib/therons_erp/inventory/product_routes.ex @@ -0,0 +1,31 @@ +defmodule TheronsErp.Inventory.ProductRoutes do + use Ash.Resource, + otp_app: :therons_erp, + domain: TheronsErp.Inventory, + data_layer: AshPostgres.DataLayer + + postgres do + table "product_routes" + repo TheronsErp.Repo + end + + actions do + read :read do + primary? true + end + + create :create do + accept [:product_id, :routes_id] + primary? true + end + + destroy :destroy do + primary? true + end + end + + relationships do + belongs_to :product, TheronsErp.Inventory.Product, primary_key?: true, allow_nil?: false + belongs_to :routes, TheronsErp.Inventory.Routes, primary_key?: true, allow_nil?: false + end +end diff --git a/lib/therons_erp/inventory/routes.ex b/lib/therons_erp/inventory/routes.ex index 88f3a12..0b70b1a 100644 --- a/lib/therons_erp/inventory/routes.ex +++ b/lib/therons_erp/inventory/routes.ex @@ -43,8 +43,13 @@ defmodule TheronsErp.Inventory.Routes do end relationships do - has_many :products, TheronsErp.Inventory.Product do - destination_attribute :route_id + many_to_many :products, TheronsErp.Inventory.Product do + through TheronsErp.Inventory.ProductRoutes + source_attribute :id + source_attribute_on_join_resource :routes_id + + destination_attribute :id + destination_attribute_on_join_resource :product_id end end end diff --git a/lib/therons_erp/scheduler.ex b/lib/therons_erp/scheduler.ex index 78a050b..87c4692 100644 --- a/lib/therons_erp/scheduler.ex +++ b/lib/therons_erp/scheduler.ex @@ -42,20 +42,22 @@ defmodule TheronsErp.Scheduler do products = TheronsErp.Inventory.Product |> Ash.Query.for_read(:list) - |> Ash.read!(load: [:route]) + |> Ash.read!(load: [:routes]) for product <- products do - route = product.route - - if route do - for r <- route.routes do - to = location_map[r.to_location_id] - from = location_map[r.from_location_id] - - if route.type == :push do - SchedulerAgent.add_to_peer(from, {r.to_location_id, product.id, to}) - else - SchedulerAgent.add_peer(to, {r.from_location_id, product.id, from}) + routes = product.routes + + for route <- routes do + if route do + for r <- route.routes do + to = location_map[r.to_location_id] + from = location_map[r.from_location_id] + + if route.type == :push do + SchedulerAgent.add_to_peer(from, {r.to_location_id, product.id, to}) + else + SchedulerAgent.add_peer(to, {r.from_location_id, product.id, from}) + end end end end @@ -79,10 +81,12 @@ defmodule TheronsErp.Scheduler do sales_orders = TheronsErp.Sales.SalesOrder |> Ash.Query.filter(state in [:ready, :invoiced]) - |> Ash.read!(load: [:sales_lines]) + |> Ash.read!(load: [sales_lines: [product: [:routes]]]) for sales_order <- sales_orders do - SchedulerAgent.add_sales_order() + for sales_line <- sales_order.sales_lines do + SchedulerAgent.add_sales_line(sales_line) + end end for {_loc, agent} <- location_map do diff --git a/priv/repo/migrations/20250225192853_many_to_many_routes.exs b/priv/repo/migrations/20250225192853_many_to_many_routes.exs new file mode 100644 index 0000000..a72e10c --- /dev/null +++ b/priv/repo/migrations/20250225192853_many_to_many_routes.exs @@ -0,0 +1,55 @@ +defmodule TheronsErp.Repo.Migrations.ManyToManyRoutes do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:products) do + remove :route_id + end + + create table(:product_routes, primary_key: false) do + add :product_id, + references(:products, + column: :id, + name: "product_routes_product_id_fkey", + type: :uuid, + prefix: "public" + ), + primary_key: true, + null: false + + add :routes_id, + references(:routes, + column: :id, + name: "product_routes_routes_id_fkey", + type: :uuid, + prefix: "public" + ), + primary_key: true, + null: false + end + end + + def down do + drop constraint(:product_routes, "product_routes_product_id_fkey") + + drop constraint(:product_routes, "product_routes_routes_id_fkey") + + drop table(:product_routes) + + alter table(:products) do + add :route_id, + references(:routes, + column: :id, + name: "products_route_id_fkey", + type: :uuid, + prefix: "public" + ) + end + end +end diff --git a/priv/resource_snapshots/repo/product_routes/20250225192853.json b/priv/resource_snapshots/repo/product_routes/20250225192853.json new file mode 100644 index 0000000..b7108a9 --- /dev/null +++ b/priv/resource_snapshots/repo/product_routes/20250225192853.json @@ -0,0 +1,77 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": true, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "product_routes_product_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "products" + }, + "size": null, + "source": "product_id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": true, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "product_routes_routes_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "routes" + }, + "size": null, + "source": "routes_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "448C968039F1B594E9813BD9BF7F778169DF043EFC7144A2E574CB71312C1061", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.TheronsErp.Repo", + "schema": null, + "table": "product_routes" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/products/20250225192853.json b/priv/resource_snapshots/repo/products/20250225192853.json new file mode 100644 index 0000000..289bfdf --- /dev/null +++ b/priv/resource_snapshots/repo/products/20250225192853.json @@ -0,0 +1,148 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "primary_key?": true, + "references": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": true, + "primary_key?": false, + "references": null, + "size": null, + "source": "identifier", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "sales_price", + "type": "money_with_currency" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "cost", + "type": "money_with_currency" + }, + { + "allow_nil?": true, + "default": "\"goods\"", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "type", + "type": "text" + }, + { + "allow_nil?": true, + "default": "true", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "saleable", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "false", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "purchaseable", + "type": "boolean" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "products_category_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "product_categories" + }, + "size": null, + "source": "category_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "887A5E02516CD286E2C9B5F3BF77D8B243ECA3D8C4991CECE5FA3F3EEF401F45", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.TheronsErp.Repo", + "schema": null, + "table": "products" +} \ No newline at end of file diff --git a/test/support/generator.ex b/test/support/generator.ex index 901fbfa..2c85fed 100644 --- a/test/support/generator.ex +++ b/test/support/generator.ex @@ -49,6 +49,18 @@ defmodule TheronsErp.Generator do ) end + def product_routes(opts \\ []) do + changeset_generator( + TheronsErp.Inventory.ProductRoutes, + :create, + defaults: [ + product_id: nil, + routes_id: nil + ], + overrides: opts + ) + end + def routes(opts \\ []) do seed_generator( %TheronsErp.Inventory.Routes{ diff --git a/test/therons_erp/scheduling_test.exs b/test/therons_erp/scheduling_test.exs index aa8803c..ae8ab72 100644 --- a/test/therons_erp/scheduling_test.exs +++ b/test/therons_erp/scheduling_test.exs @@ -76,8 +76,9 @@ defmodule TheronsErp.SchedulingTest do |> Ash.load!(:product) # Add route to product - Ash.Changeset.for_update(po_item.product, :update, %{route_id: route.id}) - |> Ash.update!() + # Ash.Changeset.for_update(po_item.product, :update, %{route_id: route.id}) + # |> Ash.update!() + product_routes = generate(product_routes(product_id: po_item.product.id, routes_id: route.id)) Scheduler.schedule() @@ -114,5 +115,11 @@ defmodule TheronsErp.SchedulingTest do assert Money.equal?(get_balance_of_ledger(loc_b, po_item.product), Money.new(2, :XIT)) end + test "errors generated for overdrawn accounts" + + test "argument allows error if sales order cannot be fulfilled" + + test "argument allows error if manufacturing order cannot be filled" + test "forbid cycles" end