[Rails] Fertilizer recipe solver for the FAPG.
Simpler, Better, Faster... Stronger
Start small, address critical customer needs, /then/ scale.
Changed files
- app/controllers/beds_controller.rb
- app/controllers/crops_controller.rb
- app/controllers/dashboard_controller.rb
- app/controllers/fertilizer_products_controller.rb
- app/controllers/fertilizers_controller.rb
- app/controllers/nutrient_measurement_controller.rb
- app/controllers/nutrient_profiles_controller.rb
- app/controllers/rafts_controller.rb
- app/controllers/recipes_controller.rb
- app/helpers/application_helper.rb
- app/helpers/beds_helper.rb
- app/helpers/dashboard_helper.rb
- app/helpers/nutrients_helper.rb
- app/helpers/recipes_helper.rb
- app/javascript/application.js
- app/javascript/controllers/application.js
- app/javascript/controllers/hello_controller.js
- app/javascript/controllers/index.js
- app/models/bed.rb
- app/models/crop.rb
- app/models/fertilizer_component.rb
- app/models/fertilizer_composition.rb
- app/models/fertilizer_product.rb
- app/models/nutrient.rb
- app/models/nutrient_profile.rb
- app/models/raft.rb
- app/services/fertilizer_recipe_calculator.rb
- app/services/target_nutrient_calculator.rb
- app/views/dashboard/_nutrient_profile_allocator.html.erb
- app/views/dashboard/_raft_allocation.html.erb
- app/views/dashboard/index.html.erb
- app/views/layouts/application.html.erb
- config/importmap.rb
- config/routes.rb
- db/data/dolibarr_plant_requirements.csv
- db/data/nutrient_profiles.csv
- db/data/nutrient_requirements.csv
- db/migrate/20250901112954_rename_crops_to_nutrient_profiles.rb
- db/schema.rb
- db/seeds.rb
- db/seeds/1_nutrients.rb
- db/seeds/2_nutrient_measurements.rb
- db/seeds/3_crops.rb
- db/seeds/4_beds_and_rafts.rb
- db/seeds/5_fertilizer_components.rb.bkp
- db/seeds/6_fertilizer_products.rb
- db/seeds/NutrientMeasurement.rb
- db/seeds/NutrientProfile.rb
app/controllers/beds_controller.rb
@@ -1,66 +0,0 @@
1
Removed:
class BedsController < ApplicationController
2
Removed:
before_action :set_bed, only: %i[ edit update ]
3
Removed:
before_action :get_crops, only: %i[ index edit update ]
4
Removed:
5
Removed:
def index
6
Removed:
@beds = Bed.all
7
Removed:
end
8
Removed:
9
Removed:
def edit
10
Removed:
end
11
Removed:
12
Removed:
def update
13
Removed:
if @bed.update(bed_params)
14
Removed:
redirect_to beds_path, notice: "Bed #{@bed.id} successfully updated."
15
Removed:
else
16
Removed:
render :edit, status: :unprocessable_entity
17
Removed:
end
18
Removed:
end
19
Removed:
20
Removed:
def bulk_assign_crops
21
Removed:
crop = Crop.find(params[:crop_id])
22
Removed:
Raft.update_all(crop_id: crop.id)
23
Removed:
redirect_back fallback_location: root_path, notice: "All rafts set to #{crop.name}."
24
Removed:
end
25
Removed:
26
Removed:
27
Removed:
def reset_seed_crops
28
Removed:
# mirrors seed logic
29
Removed:
tomatoes = Crop.find_by!(name: "tomatoes")
30
Removed:
hot_peppers = Crop.find_by!(name: "hot peppers")
31
Removed:
chives = Crop.find_by!(name: "chives")
32
Removed:
italian_basil = Crop.find_by!(name: "italian basil")
33
Removed:
cabbage_chinese = Crop.find_by!(name: "cabbage, chinese")
34
Removed:
lettuce = Crop.find_by!(name: "lettuce")
35
Removed:
36
Removed:
Bed.includes(:rafts).find_each do |bed|
37
Removed:
default_crop = case bed.location
38
Removed:
when 1..2 then tomatoes
39
Removed:
when 3 then hot_peppers
40
Removed:
when 4 then chives
41
Removed:
when 5 then italian_basil
42
Removed:
when 6..7 then cabbage_chinese
43
Removed:
else lettuce
44
Removed:
end
45
Removed:
bed.rafts.update_all(crop_id: default_crop.id)
46
Removed:
end
47
Removed:
redirect_back fallback_location: root_path, notice: "Raft crops reset to default seed layout."
48
Removed:
end
49
Removed:
50
Removed:
private
51
Removed:
52
Removed:
def set_bed
53
Removed:
@bed = Bed.find(params[:id])
54
Removed:
end
55
Removed:
56
Removed:
def get_crops
57
Removed:
@crops = Crop.order(:name)
58
Removed:
end
59
Removed:
60
Removed:
def bed_params
61
Removed:
params.require(:bed).permit(
62
Removed:
:location,
63
Removed:
rafts_attributes: %i[id crop_id]
64
Removed:
)
65
Removed:
end
66
Removed:
end
app/controllers/crops_controller.rb
@@ -1,58 +0,0 @@
1
Removed:
class CropsController < ApplicationController
2
Removed:
before_action :set_crop, only: %i[ show edit update destroy ]
3
Removed:
4
Removed:
def index
5
Removed:
@crops = Crop.all
6
Removed:
end
7
Removed:
8
Removed:
def show
9
Removed:
end
10
Removed:
11
Removed:
def new
12
Removed:
@crop = Crop.new
13
Removed:
end
14
Removed:
15
Removed:
def edit
16
Removed:
end
17
Removed:
18
Removed:
def create
19
Removed:
@crop = Crop.new(crop_params)
20
Removed:
21
Removed:
if @crop.save
22
Removed:
redirect_to @crop, notice: "Crop was successfully created."
23
Removed:
else
24
Removed:
render :new, status: :unprocessable_entity
25
Removed:
end
26
Removed:
end
27
Removed:
28
Removed:
def update
29
Removed:
respond_to do |format|
30
Removed:
if @crop.update(crop_params)
31
Removed:
format.html { redirect_to @crop, notice: "Crop was successfully updated.", status: :see_other }
32
Removed:
format.json { render :show, status: :ok, location: @crop }
33
Removed:
else
34
Removed:
format.html { render :edit, status: :unprocessable_entity }
35
Removed:
format.json { render json: @crop.errors, status: :unprocessable_entity }
36
Removed:
end
37
Removed:
end
38
Removed:
end
39
Removed:
40
Removed:
def destroy
41
Removed:
@crop.destroy!
42
Removed:
43
Removed:
respond_to do |format|
44
Removed:
format.html { redirect_to crops_path, notice: "Crop was successfully destroyed.", status: :see_other }
45
Removed:
format.json { head :no_content }
46
Removed:
end
47
Removed:
end
48
Removed:
49
Removed:
private
50
Removed:
51
Removed:
def set_crop
52
Removed:
@crop = Crop.find(params.expect(:id))
53
Removed:
end
54
Removed:
55
Removed:
def crop_params
56
Removed:
params.expect(crop: [ :name, :crop_type, :nno3, :p, :k, :ca, :mg, :s, :na, :cl, :si, :fe, :zn, :b, :mn, :cu, :mo, :nnh4 ])
57
Removed:
end
58
Removed:
end
app/controllers/dashboard_controller.rb
@@ -1,17 +1,19 @@
1
1
class DashboardController < ApplicationController
2
2
def index
3
3
# Raft allocation by crop type
4
Removed:
@raft_data = raft_data_series
4
Added:
# @raft_data = raft_data_series
5
5
6
Added:
@nutrient_profiles = NutrientProfile.order(:name)
7
Added:
6
8
# Nutrient target table
7
Removed:
@latest_measurement = NutrientMeasurement.order(measured_on: :desc, created_at: :desc).first
8
Removed:
@target = TargetNutrientCalculator.call
9
Added:
# @latest_measurement = NutrientMeasurement.order(measured_on: :desc, created_at: :desc).first
10
Added:
# @target = TargetNutrientCalculator.call
9
11
10
12
# Measurement history table
11
Removed:
@measurements = NutrientMeasurement.order(measured_on: :desc).limit(10)
13
Added:
# @measurements = NutrientMeasurement.order(measured_on: :desc).limit(10)
12
14
13
Removed:
@npk_measurement_data = measurement_data_series(:nno3, :p, :k)
14
Removed:
@ammonium_measurement_data = measurement_data_series(:nnh4)
15
Added:
# @npk_measurement_data = measurement_data_series(:nno3, :p, :k)
16
Added:
# @ammonium_measurement_data = measurement_data_series(:nnh4)
15
17
end
16
18
17
19
private
app/controllers/fertilizer_products_controller.rb
@@ -1,70 +0,0 @@
1
Removed:
class FertilizerProductsController < ApplicationController
2
Removed:
before_action :set_fertilizer_product, only: %i[ show edit update destroy ]
3
Removed:
4
Removed:
# GET /fertilizer_products or /fertilizer_products.json
5
Removed:
def index
6
Removed:
@fertilizer_products = FertilizerProduct.all
7
Removed:
end
8
Removed:
9
Removed:
# GET /fertilizer_products/1 or /fertilizer_products/1.json
10
Removed:
def show
11
Removed:
end
12
Removed:
13
Removed:
# GET /fertilizer_products/new
14
Removed:
def new
15
Removed:
@fertilizer_product = FertilizerProduct.new
16
Removed:
end
17
Removed:
18
Removed:
# GET /fertilizer_products/1/edit
19
Removed:
def edit
20
Removed:
end
21
Removed:
22
Removed:
# POST /fertilizer_products or /fertilizer_products.json
23
Removed:
def create
24
Removed:
@fertilizer_product = FertilizerProduct.new(fertilizer_product_params)
25
Removed:
26
Removed:
respond_to do |format|
27
Removed:
if @fertilizer_product.save
28
Removed:
format.html { redirect_to @fertilizer_product, notice: "Fertilizer product was successfully created." }
29
Removed:
format.json { render :show, status: :created, location: @fertilizer_product }
30
Removed:
else
31
Removed:
format.html { render :new, status: :unprocessable_entity }
32
Removed:
format.json { render json: @fertilizer_product.errors, status: :unprocessable_entity }
33
Removed:
end
34
Removed:
end
35
Removed:
end
36
Removed:
37
Removed:
# PATCH/PUT /fertilizer_products/1 or /fertilizer_products/1.json
38
Removed:
def update
39
Removed:
respond_to do |format|
40
Removed:
if @fertilizer_product.update(fertilizer_product_params)
41
Removed:
format.html { redirect_to @fertilizer_product, notice: "Fertilizer product was successfully updated.", status: :see_other }
42
Removed:
format.json { render :show, status: :ok, location: @fertilizer_product }
43
Removed:
else
44
Removed:
format.html { render :edit, status: :unprocessable_entity }
45
Removed:
format.json { render json: @fertilizer_product.errors, status: :unprocessable_entity }
46
Removed:
end
47
Removed:
end
48
Removed:
end
49
Removed:
50
Removed:
# DELETE /fertilizer_products/1 or /fertilizer_products/1.json
51
Removed:
def destroy
52
Removed:
@fertilizer_product.destroy!
53
Removed:
54
Removed:
respond_to do |format|
55
Removed:
format.html { redirect_to fertilizer_products_path, notice: "Fertilizer product was successfully destroyed.", status: :see_other }
56
Removed:
format.json { head :no_content }
57
Removed:
end
58
Removed:
end
59
Removed:
60
Removed:
private
61
Removed:
# Use callbacks to share common setup or constraints between actions.
62
Removed:
def set_fertilizer_product
63
Removed:
@fertilizer_product = FertilizerProduct.find(params.expect(:id))
64
Removed:
end
65
Removed:
66
Removed:
# Only allow a list of trusted parameters through.
67
Removed:
def fertilizer_product_params
68
Removed:
params.expect(fertilizer_product: [ :name, :purity ])
69
Removed:
end
70
Removed:
end
app/controllers/fertilizers_controller.rb
@@ -1,70 +0,0 @@
1
Removed:
class FertilizersController < ApplicationController
2
Removed:
before_action :set_fertilizer, only: %i[ show edit update destroy ]
3
Removed:
4
Removed:
# GET /fertilizers or /fertilizers.json
5
Removed:
def index
6
Removed:
@fertilizers = Fertilizer.all
7
Removed:
end
8
Removed:
9
Removed:
# GET /fertilizers/1 or /fertilizers/1.json
10
Removed:
def show
11
Removed:
end
12
Removed:
13
Removed:
# GET /fertilizers/new
14
Removed:
def new
15
Removed:
@fertilizer = Fertilizer.new
16
Removed:
end
17
Removed:
18
Removed:
# GET /fertilizers/1/edit
19
Removed:
def edit
20
Removed:
end
21
Removed:
22
Removed:
# POST /fertilizers or /fertilizers.json
23
Removed:
def create
24
Removed:
@fertilizer = Fertilizer.new(fertilizer_params)
25
Removed:
26
Removed:
respond_to do |format|
27
Removed:
if @fertilizer.save
28
Removed:
format.html { redirect_to @fertilizer, notice: "Fertilizer was successfully created." }
29
Removed:
format.json { render :show, status: :created, location: @fertilizer }
30
Removed:
else
31
Removed:
format.html { render :new, status: :unprocessable_entity }
32
Removed:
format.json { render json: @fertilizer.errors, status: :unprocessable_entity }
33
Removed:
end
34
Removed:
end
35
Removed:
end
36
Removed:
37
Removed:
# PATCH/PUT /fertilizers/1 or /fertilizers/1.json
38
Removed:
def update
39
Removed:
respond_to do |format|
40
Removed:
if @fertilizer.update(fertilizer_params)
41
Removed:
format.html { redirect_to @fertilizer, notice: "Fertilizer was successfully updated.", status: :see_other }
42
Removed:
format.json { render :show, status: :ok, location: @fertilizer }
43
Removed:
else
44
Removed:
format.html { render :edit, status: :unprocessable_entity }
45
Removed:
format.json { render json: @fertilizer.errors, status: :unprocessable_entity }
46
Removed:
end
47
Removed:
end
48
Removed:
end
49
Removed:
50
Removed:
# DELETE /fertilizers/1 or /fertilizers/1.json
51
Removed:
def destroy
52
Removed:
@fertilizer.destroy!
53
Removed:
54
Removed:
respond_to do |format|
55
Removed:
format.html { redirect_to fertilizers_path, notice: "Fertilizer was successfully destroyed.", status: :see_other }
56
Removed:
format.json { head :no_content }
57
Removed:
end
58
Removed:
end
59
Removed:
60
Removed:
private
61
Removed:
# Use callbacks to share common setup or constraints between actions.
62
Removed:
def set_fertilizer
63
Removed:
@fertilizer = Fertilizer.find(params.expect(:id))
64
Removed:
end
65
Removed:
66
Removed:
# Only allow a list of trusted parameters through.
67
Removed:
def fertilizer_params
68
Removed:
params.expect(fertilizer: [ :name, :formula, :nno3, :nnh4, :p, :k, :ca, :mg, :s, :na, :cl, :si, :fe, :zn, :b, :mn, :cu, :mo ])
69
Removed:
end
70
Removed:
end
app/controllers/nutrient_measurement_controller.rb
@@ -1,4 +0,0 @@
1
Removed:
class NutrientMeasurementController < ApplicationController
2
Removed:
def index
3
Removed:
end
4
Removed:
end
app/controllers/nutrient_profiles_controller.rb
@@ -0,0 +1,58 @@
1
Added:
class NutrientProfilesController < ApplicationController
2
Added:
before_action :set_crop, only: %i[ show edit update destroy ]
3
Added:
4
Added:
def index
5
Added:
@crops = Crop.all
6
Added:
end
7
Added:
8
Added:
def show
9
Added:
end
10
Added:
11
Added:
def new
12
Added:
@crop = Crop.new
13
Added:
end
14
Added:
15
Added:
def edit
16
Added:
end
17
Added:
18
Added:
def create
19
Added:
@crop = Crop.new(crop_params)
20
Added:
21
Added:
if @crop.save
22
Added:
redirect_to @crop, notice: "Crop was successfully created."
23
Added:
else
24
Added:
render :new, status: :unprocessable_entity
25
Added:
end
26
Added:
end
27
Added:
28
Added:
def update
29
Added:
respond_to do |format|
30
Added:
if @crop.update(crop_params)
31
Added:
format.html { redirect_to @crop, notice: "Crop was successfully updated.", status: :see_other }
32
Added:
format.json { render :show, status: :ok, location: @crop }
33
Added:
else
34
Added:
format.html { render :edit, status: :unprocessable_entity }
35
Added:
format.json { render json: @crop.errors, status: :unprocessable_entity }
36
Added:
end
37
Added:
end
38
Added:
end
39
Added:
40
Added:
def destroy
41
Added:
@crop.destroy!
42
Added:
43
Added:
respond_to do |format|
44
Added:
format.html { redirect_to crops_path, notice: "Crop was successfully destroyed.", status: :see_other }
45
Added:
format.json { head :no_content }
46
Added:
end
47
Added:
end
48
Added:
49
Added:
private
50
Added:
51
Added:
def set_crop
52
Added:
@crop = Crop.find(params.expect(:id))
53
Added:
end
54
Added:
55
Added:
def crop_params
56
Added:
params.expect(crop: [ :name, :crop_type, :nno3, :p, :k, :ca, :mg, :s, :na, :cl, :si, :fe, :zn, :b, :mn, :cu, :mo, :nnh4 ])
57
Added:
end
58
Added:
end
app/controllers/rafts_controller.rb
@@ -1,29 +0,0 @@
1
Removed:
class RaftsController < ApplicationController
2
Removed:
before_action :set_raft, only: %i[ edit update ]
3
Removed:
before_action :set_crop, only: %i[ edit update ]
4
Removed:
5
Removed:
def edit
6
Removed:
end
7
Removed:
8
Removed:
def update
9
Removed:
if @raft.update(raft_params)
10
Removed:
redirect_to beds_path, notice: "Raft #{@raft.id} successfully updated."
11
Removed:
else
12
Removed:
render :edit, status: :unprocessable_entity
13
Removed:
end
14
Removed:
end
15
Removed:
16
Removed:
private
17
Removed:
18
Removed:
def set_raft
19
Removed:
@raft = Raft.find(params[:id])
20
Removed:
end
21
Removed:
22
Removed:
def set_crop
23
Removed:
@crops = Crop.order(:name)
24
Removed:
end
25
Removed:
26
Removed:
def raft_params
27
Removed:
params.require(:raft).permit(:location, :bed_id, :crop_id)
28
Removed:
end
29
Removed:
end
app/controllers/recipes_controller.rb
@@ -1,8 +0,0 @@
1
Removed:
class RecipesController < ApplicationController
2
Removed:
def show
3
Removed:
@latest = NutrientMeasurement.order(:measured_on).last || NutrientMeasurement.new
4
Removed:
@target = TargetNutrientCalculator.call
5
Removed:
volume = (params[:volume].presence || 100_000).to_i
6
Removed:
@recipe = FertilizerRecipeCalculator.call(@latest, @target, water_volume_l: volume)
7
Removed:
end
8
Removed:
end
app/helpers/application_helper.rb
@@ -1,2 +0,0 @@
1
Removed:
module ApplicationHelper
2
Removed:
end
app/helpers/beds_helper.rb
@@ -1,2 +0,0 @@
1
Removed:
module BedsHelper
2
Removed:
end
app/helpers/dashboard_helper.rb
@@ -1,8 +0,0 @@
1
Removed:
module DashboardHelper
2
Removed:
def fmt2(v) = number_with_precision(v, precision: 2)
3
Removed:
4
Removed:
# Total nitrogen (NO3-N + NH4-N)
5
Removed:
def total_n(measurement)
6
Removed:
(measurement.nno3.to_f) + (measurement.nnh4.to_f)
7
Removed:
end
8
Removed:
end
app/helpers/nutrients_helper.rb
@@ -1,21 +0,0 @@
1
Removed:
module NutrientsHelper
2
Removed:
NUTRIENTS = %i[nno3 p k ca mg s na cl si fe zn b mn cu mo nnh4].freeze
3
Removed:
4
Removed:
def percent_delta(measured, target)
5
Removed:
return 0.0 if target.to_f.zero?
6
Removed:
((measured.to_f - target.to_f) / target.to_f) * 100.0
7
Removed:
end
8
Removed:
9
Removed:
def fmt(v)
10
Removed:
number_with_precision(v, precision: 2, strip_insignificant_zeros: true)
11
Removed:
end
12
Removed:
13
Removed:
def delta_badge_class(delta)
14
Removed:
d = delta.abs
15
Removed:
case d
16
Removed:
when d < 0 then "bg-info"
17
Removed:
when 0..5 then "bg-secondary"
18
Removed:
else "bg-warning"
19
Removed:
end
20
Removed:
end
21
Removed:
end
app/helpers/recipes_helper.rb
@@ -1,16 +0,0 @@
1
Removed:
module RecipesHelper
2
Removed:
def commercial_name_for(component)
3
Removed:
# Try to find a FertilizerProduct via a join, but gracefully fallback.
4
Removed:
if defined?(FertilizerProduct) && defined?(FertilizerComposition)
5
Removed:
prod = FertilizerProduct.joins(:fertilizer_compositions)
6
Removed:
.where(fertilizer_compositions: { fertilizer_component_id: component.id })
7
Removed:
.first
8
Removed:
return prod.name if prod&.name.present?
9
Removed:
end
10
Removed:
component.name.presence || component.formula
11
Removed:
end
12
Removed:
13
Removed:
def fmt_kg(v)
14
Removed:
number_with_precision(v.to_f, precision: 2, strip_insignificant_zeros: true)
15
Removed:
end
16
Removed:
end
app/javascript/application.js
@@ -1,1 +1,3 @@
1
1
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
2
Added:
import "@hotwired/turbo-rails"
3
Added:
import "controllers"
app/javascript/controllers/application.js
@@ -0,0 +1,9 @@
1
Added:
import { Application } from "@hotwired/stimulus"
2
Added:
3
Added:
const application = Application.start()
4
Added:
5
Added:
// Configure Stimulus development experience
6
Added:
application.debug = false
7
Added:
window.Stimulus = application
8
Added:
9
Added:
export { application }
app/javascript/controllers/hello_controller.js
@@ -0,0 +1,7 @@
1
Added:
import { Controller } from "@hotwired/stimulus"
2
Added:
3
Added:
export default class extends Controller {
4
Added:
connect() {
5
Added:
this.element.textContent = "Hello World!"
6
Added:
}
7
Added:
}
app/javascript/controllers/index.js
@@ -0,0 +1,4 @@
1
Added:
// Import and register all your controllers from the importmap via controllers/**/*_controller
2
Added:
import { application } from "controllers/application"
3
Added:
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
4
Added:
eagerLoadControllersFrom("controllers", application)
app/models/bed.rb
@@ -1,5 +0,0 @@
1
Removed:
class Bed < ApplicationRecord
2
Removed:
has_many :rafts, -> { order(:location) }, dependent: :destroy
3
Removed:
accepts_nested_attributes_for :rafts
4
Removed:
validates :location, presence: true, uniqueness: true
5
Removed:
end
app/models/crop.rb
@@ -1,4 +0,0 @@
1
Removed:
class Crop < ApplicationRecord
2
Removed:
has_many :rafts
3
Removed:
enum :crop_type, { leafy: 0, fruit: 1, herb: 2 }
4
Removed:
end
app/models/fertilizer_component.rb
@@ -1,3 +0,0 @@
1
Removed:
class FertilizerComponent < ApplicationRecord
2
Removed:
validates :name, presence: true
3
Removed:
end
app/models/fertilizer_composition.rb
@@ -1,6 +0,0 @@
1
Removed:
class FertilizerComposition < ApplicationRecord
2
Removed:
belongs_to :fertilizer_product
3
Removed:
belongs_to :fertilizer_component
4
Removed:
5
Removed:
validates :percent_w, numericality: { greater_than: 0, less_than_or_equal_to: 100 }
6
Removed:
end
app/models/fertilizer_product.rb
@@ -1,7 +0,0 @@
1
Removed:
class FertilizerProduct < ApplicationRecord
2
Removed:
has_many :fertilizer_compositions, dependent: :destroy
3
Removed:
has_many :fertilizer_components, through: :fertilizer_compositions
4
Removed:
5
Removed:
validates :name, presence: true, uniqueness: true
6
Removed:
validates :purity, numericality: { greater_than: 0, less_than_or_equal_to: 100 }
7
Removed:
end
app/models/nutrient.rb
@@ -1,7 +0,0 @@
1
Removed:
class Nutrient < ApplicationRecord
2
Removed:
validates :formula, presence: true, uniqueness: true
3
Removed:
validates :name, presence: true
4
Removed:
5
Removed:
before_update { raise ActiveRecord::ReadOnlyRecord }
6
Removed:
before_destroy { raise ActiveRecord::ReadOnlyRecord }
7
Removed:
end
app/models/nutrient_profile.rb
@@ -0,0 +1,2 @@
1
Added:
class NutrientProfile < ApplicationRecord
2
Added:
end
app/models/raft.rb
@@ -1,5 +0,0 @@
1
Removed:
class Raft < ApplicationRecord
2
Removed:
belongs_to :bed
3
Removed:
belongs_to :crop, optional: true
4
Removed:
validates :location, presence: true, uniqueness: { scope: :bed_id }
5
Removed:
end
app/services/fertilizer_recipe_calculator.rb
@@ -1,136 +0,0 @@
1
Removed:
class FertilizerRecipeCalculator
2
Removed:
NUTRIENTS = %i[nno3 p k ca mg s nnh4].freeze
3
Removed:
4
Removed:
def self.call(latest, target, water_volume_l: 100_000)
5
Removed:
new(latest, target, water_volume_l).call
6
Removed:
end
7
Removed:
8
Removed:
def initialize(latest, target, water_volume_l)
9
Removed:
@latest = latest || NutrientMeasurement.new
10
Removed:
@target = target || NutrientMeasurement.new
11
Removed:
@vol_l = water_volume_l.to_f
12
Removed:
end
13
Removed:
14
Removed:
# Returns a Hash { FertilizerComponent => qty_kg }
15
Removed:
def call
16
Removed:
deltas_mg_per_l = NUTRIENTS.index_with do |k|
17
Removed:
[ (value(@target, k) - value(@latest, k)), 0 ].max
18
Removed:
end
19
Removed:
20
Removed:
# mg/L * L = mg -> kg
21
Removed:
needed_kg = deltas_mg_per_l.transform_values { |mg_l| mg_l * @vol_l / 1_000_000.0 }
22
Removed:
23
Removed:
recipe = Hash.new(0.0)
24
Removed:
25
Removed:
# 1) P via MAP or DAP (accounts NH4-N)
26
Removed:
if (p_need = needed_kg[:p]) > 0
27
Removed:
map = fc_by_formula("MAP") || fc_by_name_like("monoammonium phosphate")
28
Removed:
dap = fc_by_formula("DAP") || fc_by_name_like("diammonium phosphate")
29
Removed:
carrier = [ map, dap ].compact.find { |c| positive?(c.p) }
30
Removed:
if carrier
31
Removed:
kg = kg_for(p_need, carrier.p)
32
Removed:
recipe[carrier] += kg
33
Removed:
needed_kg[:nnh4] = [ needed_kg[:nnh4] - kg * pct(carrier.nnh4), 0 ].max if positive?(carrier.nnh4)
34
Removed:
needed_kg[:p] = 0
35
Removed:
end
36
Removed:
end
37
Removed:
38
Removed:
# 2) NO3-N via KNO3 then Ca(NO3)2 (accounts K/Ca)
39
Removed:
if (n_need = needed_kg[:nno3]) > 0
40
Removed:
kno3 = fc_by_formula("KNO3") || fc_by_name_like("potassium nitrate")
41
Removed:
can = fc_by_formula("Ca(NO3)2") || fc_by_name_like("calcium nitrate")
42
Removed:
43
Removed:
if kno3&.nno3.to_f > 0
44
Removed:
kg = kg_for(n_need, kno3.nno3)
45
Removed:
recipe[kno3] += kg
46
Removed:
needed_kg[:k] = [ needed_kg[:k] - kg * pct(kno3.k), 0 ].max
47
Removed:
n_need -= kg * pct(kno3.nno3)
48
Removed:
end
49
Removed:
50
Removed:
if n_need > 0 && can&.nno3.to_f > 0
51
Removed:
kg = kg_for(n_need, can.nno3)
52
Removed:
recipe[can] += kg
53
Removed:
needed_kg[:ca] = [ needed_kg[:ca] - kg * pct(can.ca), 0 ].max
54
Removed:
n_need = 0
55
Removed:
end
56
Removed:
57
Removed:
needed_kg[:nno3] = [ n_need, 0 ].max
58
Removed:
end
59
Removed:
60
Removed:
# 3) K via K2SO4 (accounts S)
61
Removed:
if (k_need = needed_kg[:k]) > 0
62
Removed:
sop = fc_by_formula("K2SO4") || fc_by_name_like("potassium sulfate")
63
Removed:
if sop&.k.to_f > 0
64
Removed:
kg = kg_for(k_need, sop.k)
65
Removed:
recipe[sop] += kg
66
Removed:
needed_kg[:s] = [ needed_kg[:s] - kg * pct(sop.s), 0 ].max
67
Removed:
needed_kg[:k] = 0
68
Removed:
end
69
Removed:
end
70
Removed:
71
Removed:
# 4) Ca via Ca(NO3)2 (accounts NO3-N)
72
Removed:
if (ca_need = needed_kg[:ca]) > 0
73
Removed:
can = recipe.keys.find { |c| norm_formula(c) == "ca(no3)2" } ||
74
Removed:
fc_by_formula("Ca(NO3)2") || fc_by_name_like("calcium nitrate")
75
Removed:
if can&.ca.to_f > 0
76
Removed:
kg = kg_for(ca_need, can.ca)
77
Removed:
recipe[can] += kg
78
Removed:
needed_kg[:nno3] = [ needed_kg[:nno3] - kg * pct(can.nno3), 0 ].max
79
Removed:
needed_kg[:ca] = 0
80
Removed:
end
81
Removed:
end
82
Removed:
83
Removed:
# 5) Mg via MgSO4 (accounts S)
84
Removed:
if (mg_need = needed_kg[:mg]) > 0
85
Removed:
mgs = fc_by_formula("MgSO4") || fc_by_name_like("magnesium sulfate")
86
Removed:
if mgs&.mg.to_f > 0
87
Removed:
kg = kg_for(mg_need, mgs.mg)
88
Removed:
recipe[mgs] += kg
89
Removed:
needed_kg[:s] = [ needed_kg[:s] - kg * pct(mgs.s), 0 ].max
90
Removed:
needed_kg[:mg] = 0
91
Removed:
end
92
Removed:
end
93
Removed:
94
Removed:
# 6) S via K2SO4 or MgSO4 (whichever we already used or find)
95
Removed:
if (s_need = needed_kg[:s]) > 0
96
Removed:
sop = recipe.keys.find { |c| norm_formula(c) == "k2so4" } ||
97
Removed:
fc_by_formula("K2SO4") || fc_by_name_like("potassium sulfate")
98
Removed:
mgs = recipe.keys.find { |c| norm_formula(c) == "mgso4" } ||
99
Removed:
fc_by_formula("MgSO4") || fc_by_name_like("magnesium sulfate")
100
Removed:
carrier = [ sop, mgs ].compact.find { |c| positive?(c.s) }
101
Removed:
if carrier
102
Removed:
kg = kg_for(s_need, carrier.s)
103
Removed:
recipe[carrier] += kg
104
Removed:
needed_kg[:k] = [ needed_kg[:k] - kg * pct(carrier.k), 0 ].max if positive?(carrier.k)
105
Removed:
needed_kg[:mg] = [ needed_kg[:mg] - kg * pct(carrier.mg), 0 ].max if positive?(carrier.mg)
106
Removed:
needed_kg[:s] = 0
107
Removed:
end
108
Removed:
end
109
Removed:
110
Removed:
recipe.delete_if { |_c, kg| kg < 0.01 }
111
Removed:
recipe
112
Removed:
end
113
Removed:
114
Removed:
private
115
Removed:
116
Removed:
def value(obj, key) = obj.public_send(key).to_f
117
Removed:
118
Removed:
def fc_by_formula(formula)
119
Removed:
FertilizerComponent.where("LOWER(formula) = ?", formula.to_s.downcase).first
120
Removed:
end
121
Removed:
122
Removed:
def fc_by_name_like(name)
123
Removed:
FertilizerComponent.where("LOWER(name) LIKE ?", "%#{name.downcase}%").first
124
Removed:
end
125
Removed:
126
Removed:
def kg_for(need_kg_element, percent_in_product)
127
Removed:
return 0.0 unless positive?(percent_in_product)
128
Removed:
need_kg_element / pct(percent_in_product)
129
Removed:
end
130
Removed:
131
Removed:
def pct(v) = v.to_f / 100.0
132
Removed:
133
Removed:
def positive?(v) = v.to_f > 0.0
134
Removed:
135
Removed:
def norm_formula(c) = c.formula.to_s.downcase.gsub(/\s+/, "")
136
Removed:
end
app/services/target_nutrient_calculator.rb
@@ -1,33 +0,0 @@
1
Removed:
class TargetNutrientCalculator
2
Removed:
# Derive nutrient columns from the NutrientMeasurement table
3
Removed:
NUTRIENT_COLUMNS = (NutrientMeasurement.column_names - %w[id measured_on created_at updated_at])
4
Removed:
.map!(&:to_sym)
5
Removed:
.freeze
6
Removed:
7
Removed:
# Returns an unsaved NutrientMeasurement with target concentrations (e.g., mg/L)
8
Removed:
def self.call
9
Removed:
rafts = Raft.includes(:crop).where.not(crop_id: nil)
10
Removed:
total = rafts.count
11
Removed:
return empty_measurement if total.zero?
12
Removed:
13
Removed:
sums = Hash.new(0.0)
14
Removed:
15
Removed:
rafts.each do |raft|
16
Removed:
NUTRIENT_COLUMNS.each do |col|
17
Removed:
v = raft.crop.public_send(col)
18
Removed:
sums[col] += v.to_f if v
19
Removed:
end
20
Removed:
end
21
Removed:
22
Removed:
targets = sums.transform_values { |s| s / total }
23
Removed:
NutrientMeasurement.new({ measured_on: Date.current }.merge(targets))
24
Removed:
end
25
Removed:
26
Removed:
private
27
Removed:
28
Removed:
def empty_measurement
29
Removed:
NutrientMeasurement.new(
30
Removed:
{ measured_on: Date.current }.merge(NUTRIENT_COLUMNS.index_with { 0.0 })
31
Removed:
)
32
Removed:
end
33
Removed:
end
app/views/dashboard/_nutrient_profile_allocator.html.erb
@@ -0,0 +1,187 @@
1
Added:
<%# Props: nutrient_profiles: ActiveRecord::Relation<NutrientProfile> %>
2
Added:
<%# Fallback if controller didn't set @nutrient_profiles yet %>
3
Added:
<% profiles = (local_assigns[:nutrient_profiles] || []).presence || [] %>
4
Added:
5
Added:
<%# We'll render a form purely for structure (no real submit yet) %>
6
Added:
<%= form_with url: "#", method: :post, local: true, html: { id: "np-mix-form", "data-controller": "np-mix" } do %>
7
Added:
<div class="card shadow">
8
Added:
<div class="card-body">
9
Added:
10
Added:
<div class="d-flex justify-content-between align-items-center mb-2">
11
Added:
<div class="small text-muted">
12
Added:
Choisissez des <strong>profils de croissance</strong> et répartissez-les pour totaliser <strong>100%</strong>.
13
Added:
</div>
14
Added:
<div>
15
Added:
Somme : <span id="np-mix-sum" class="badge bg-secondary">0%</span>
16
Added:
</div>
17
Added:
</div>
18
Added:
19
Added:
<div id="np-mix-rows" class="vstack gap-2">
20
Added:
<%# Rows are injected by JS from the template below, including defaults %>
21
Added:
</div>
22
Added:
23
Added:
<div class="mt-3 d-flex gap-2">
24
Added:
<button type="button" class="btn btn-outline-primary" id="np-mix-add">
25
Added:
+ Ajouter un profil
26
Added:
</button>
27
Added:
28
Added:
<%# Placeholder "save" button for later backend wiring; disabled until total == 100 %>
29
Added:
<button type="submit" class="btn btn-success ms-auto" id="np-mix-save" disabled>
30
Added:
Enregistrer (à venir)
31
Added:
</button>
32
Added:
</div>
33
Added:
</div>
34
Added:
</div>
35
Added:
36
Added:
<%# --- Hidden template for a single row --- %>
37
Added:
<template id="np-mix-row-template">
38
Added:
<div class="np-mix-row d-flex align-items-center gap-2 border rounded p-2">
39
Added:
<button type="button" class="btn btn-outline-danger btn-sm np-mix-delete" aria-label="Supprimer la ligne">
40
Added:
Suppr.
41
Added:
</button>
42
Added:
43
Added:
<div class="flex-grow-1">
44
Added:
<select name="mix[items][][profile_id]" class="form-select form-select-sm np-mix-select" required>
45
Added:
<% if profiles.any? %>
46
Added:
<% profiles.each do |p| %>
47
Added:
<option value="<%= p.id %>"><%= p.name %></option>
48
Added:
<% end %>
49
Added:
<% else %>
50
Added:
<%# If no collection provided yet, at least show placeholders to demo the UI %>
51
Added:
<option value="">-- Sélectionner un profil --</option>
52
Added:
<option value="gen-croissance">Générique croissance</option>
53
Added:
<option value="tomate-cycle">Tomate (cycle entier)</option>
54
Added:
<option value="gen-floraison">Générique floraison</option>
55
Added:
<% end %>
56
Added:
</select>
57
Added:
</div>
58
Added:
59
Added:
<div class="input-group input-group-sm" style="max-width: 140px;">
60
Added:
<input type="number"
61
Added:
name="mix[items][][percentage]"
62
Added:
class="form-control text-end np-mix-percent"
63
Added:
min="0" max="100" step="1" value="0" required>
64
Added:
<span class="input-group-text">%</span>
65
Added:
</div>
66
Added:
</div>
67
Added:
</template>
68
Added:
69
Added:
<%# --- Defaults to inject on load --- %>
70
Added:
<script type="application/json" id="np-mix-defaults">
71
Added:
{
72
Added:
"items": [
73
Added:
{ "name": "G\u00E9n\u00E9rique croissance", "percent": 50 },
74
Added:
{ "name": "Tomate (cycle entier)", "percent": 30 },
75
Added:
{ "name": "G\u00E9n\u00E9rique floraison", "percent": 20 }
76
Added:
]
77
Added:
}
78
Added:
</script>
79
Added:
80
Added:
<%# --- Tiny inline JS to keep this self-contained (no Stimulus required) --- %>
81
Added:
<script>
82
Added:
(() => {
83
Added:
const rowsContainer = document.getElementById('np-mix-rows');
84
Added:
const addBtn = document.getElementById('np-mix-add');
85
Added:
const saveBtn = document.getElementById('np-mix-save');
86
Added:
const sumBadge = document.getElementById('np-mix-sum');
87
Added:
const tpl = document.getElementById('np-mix-row-template');
88
Added:
const defaultsJSON = document.getElementById('np-mix-defaults')?.textContent || "{}";
89
Added:
const defaults = JSON.parse(defaultsJSON);
90
Added:
91
Added:
function currentSum() {
92
Added:
return Array.from(rowsContainer.querySelectorAll('.np-mix-percent'))
93
Added:
.reduce((acc, el) => acc + (parseFloat(el.value) || 0), 0);
94
Added:
}
95
Added:
96
Added:
function refreshSum() {
97
Added:
const sum = currentSum();
98
Added:
sumBadge.textContent = `${sum}%`;
99
Added:
sumBadge.classList.remove('bg-secondary','bg-danger','bg-success','bg-warning');
100
Added:
101
Added:
if (sum === 100) {
102
Added:
sumBadge.classList.add('bg-success');
103
Added:
saveBtn?.removeAttribute('disabled');
104
Added:
} else if (sum > 100) {
105
Added:
sumBadge.classList.add('bg-danger');
106
Added:
saveBtn?.setAttribute('disabled', 'disabled');
107
Added:
} else {
108
Added:
sumBadge.classList.add('bg-warning');
109
Added:
saveBtn?.setAttribute('disabled', 'disabled');
110
Added:
}
111
Added:
}
112
Added:
113
Added:
function setSelectByName(selectEl, targetName) {
114
Added:
// Try to match by visible name; fall back to first option.
115
Added:
const options = Array.from(selectEl.options);
116
Added:
const found = options.find(o => o.text.trim().toLowerCase() === String(targetName || '').trim().toLowerCase());
117
Added:
if (found) {
118
Added:
selectEl.value = found.value;
119
Added:
}
120
Added:
}
121
Added:
122
Added:
function installRow({ name = null, percent = 0 } = {}) {
123
Added:
const node = tpl.content.firstElementChild.cloneNode(true);
124
Added:
125
Added:
// Hook up events
126
Added:
node.querySelector('.np-mix-delete').addEventListener('click', () => {
127
Added:
node.remove();
128
Added:
refreshSum();
129
Added:
});
130
Added:
131
Added:
const selectEl = node.querySelector('.np-mix-select');
132
Added:
const percentEl = node.querySelector('.np-mix-percent');
133
Added:
134
Added:
// Default selection (by name) and percent
135
Added:
if (name) setSelectByName(selectEl, name);
136
Added:
percentEl.value = percent;
137
Added:
138
Added:
// Input events
139
Added:
selectEl.addEventListener('change', () => { /* reserved for later linkage */ });
140
Added:
percentEl.addEventListener('input', () => {
141
Added:
// Clamp and refresh
142
Added:
let v = parseFloat(percentEl.value);
143
Added:
if (isNaN(v)) v = 0;
144
Added:
v = Math.max(0, Math.min(100, Math.round(v)));
145
Added:
percentEl.value = v;
146
Added:
refreshSum();
147
Added:
});
148
Added:
149
Added:
rowsContainer.appendChild(node);
150
Added:
}
151
Added:
152
Added:
// Init with three defaults
153
Added:
const items = (defaults && defaults.items) ? defaults.items : [];
154
Added:
if (items.length) {
155
Added:
items.forEach(it => installRow({ name: it.name, percent: it.percent }));
156
Added:
} else {
157
Added:
// Fallback: create three blank rows
158
Added:
for (let i = 0; i < 3; i++) installRow();
159
Added:
}
160
Added:
refreshSum();
161
Added:
162
Added:
// Add new blank row
163
Added:
addBtn.addEventListener('click', () => {
164
Added:
installRow({ name: null, percent: 0 });
165
Added:
refreshSum();
166
Added:
// Scroll to the new row on mobile for better UX
167
Added:
rowsContainer.lastElementChild?.scrollIntoView({ behavior: 'smooth', block: 'center' });
168
Added:
});
169
Added:
170
Added:
// Prevent real submit for now (frontend only)
171
Added:
document.getElementById('np-mix-form')?.addEventListener('submit', (e) => {
172
Added:
e.preventDefault();
173
Added:
// Later: wire to Turbo/JSON post. For now just a gentle nudge.
174
Added:
saveBtn.textContent = 'Enregistrer (backend à venir)';
175
Added:
saveBtn.blur();
176
Added:
});
177
Added:
})();
178
Added:
</script>
179
Added:
180
Added:
<style>
181
Added:
/* Small touch targets & tidy spacing on mobile */
182
Added:
@media (max-width: 576px) {
183
Added:
.np-mix-row { padding: .5rem; }
184
Added:
.np-mix-row .btn { padding: .25rem .5rem; }
185
Added:
}
186
Added:
</style>
187
Added:
<% end %>
app/views/dashboard/_raft_allocation.html.erb
@@ -1,7 +1,7 @@
1
1
<div class="card shadow mb-4">
2
2
<div class="card-header d-flex justify-content-between align-items-center">
3
3
<h5 class="mb-0">Crop Allocation</h5>
4
Removed:
<%= link_to "Edit allocation", beds_path, class: "btn btn-sm btn-primary" %>
4
Added:
<%#= link_to "Edit allocation", beds_path, class: "btn btn-sm btn-primary" %>
5
5
</div>
6
6
7
7
<%= bar_chart @raft_data, stacked: true %>
app/views/dashboard/index.html.erb
@@ -1,7 +1,9 @@
1
1
<h1 class="display-1">Ferti</h1>
2
2
3
Removed:
<%= render "raft_allocation" %>
3
Added:
<%= render "nutrient_profile_allocator", nutrient_profiles: @nutrient_profiles %>
4
4
5
Removed:
<%= render "target_table" %>
5
Added:
<%#= render "raft_allocation" %>
6
6
7
Removed:
<%= render "nutrient_measurements" %>
7
Added:
<%#= render "target_table" %>
8
Added:
9
Added:
<%#= render "nutrient_measurements" %>
app/views/layouts/application.html.erb
@@ -14,8 +14,8 @@
14
14
<%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %>
15
15
16
16
<link rel="icon" href="/icon.png" type="image/png">
17
Removed:
<link rel="icon" href="/icon.svg" type="image/svg+xml">
18
Removed:
<link rel="apple-touch-icon" href="/icon.png">
17
Added:
<!-- <link rel="icon" href="/icon.svg" type="image/svg+xml"> -->
18
Added:
<!-- <link rel="apple-touch-icon" href="/icon.png"> -->
19
19
20
20
<%# Includes all stylesheet files in app/assets/stylesheets %>
21
21
<%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
@@ -25,7 +25,6 @@
25
25
26
26
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
27
27
<script src="https://cdn.jsdelivr.net/npm/chartkick@5"></script>
28
Removed:
29
28
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
30
29
31
30
<%= javascript_importmap_tags %>
config/importmap.rb
@@ -1,3 +1,7 @@
1
1
# Pin npm packages by running ./bin/importmap
2
2
3
3
pin "application"
4
Added:
pin "@hotwired/turbo-rails", to: "turbo.min.js"
5
Added:
pin "@hotwired/stimulus", to: "stimulus.min.js"
6
Added:
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
7
Added:
pin_all_from "app/javascript/controllers", under: "controllers"
config/routes.rb
@@ -1,19 +1,19 @@
1
1
Rails.application.routes.draw do
2
2
root "dashboard#index"
3
3
4
Removed:
get "ferti_recipe", to: "recipes#show"
4
Added:
# get "ferti_recipe", to: "recipes#show"
5
5
6
Removed:
resources :beds, only: [ :index, :edit, :update ] do
7
Removed:
collection do
8
Removed:
patch :bulk_assign_crops
9
Removed:
post :reset_seed_crops
10
Removed:
end
6
Added:
# resources :beds, only: [ :index, :edit, :update ] do
7
Added:
# collection do
8
Added:
# patch :bulk_assign_crops
9
Added:
# post :reset_seed_crops
10
Added:
# end
11
11
12
Removed:
resources :rafts, only: [ :index, :edit, :update ], shallow: true
13
Removed:
end
12
Added:
# resources :rafts, only: [ :index, :edit, :update ], shallow: true
13
Added:
# end
14
14
15
Removed:
resources :fertilizer_products
16
Removed:
resources :crops
15
Added:
# resources :fertilizer_products
16
Added:
resources :nutrient_profiles
17
17
resources :nutrient_measurements
18
18
19
19
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
db/data/dolibarr_plant_requirements.csv
@@ -0,0 +1,9 @@
1
Added:
Cycles et productions,NNO3,P,K,Ca,Mg,S,Fe,Zn,B,Mn,Cu,Mo
2
Added:
Formule moyenne générale,160.00,30.00,230.00,100.00,30.00,60.00,5.00,0.15,0.30,0.50,0.15,0.05
3
Added:
Salades,130.00,60.00,300.00,100.00,30.00,60.00,2.00,0.10,0.50,0.50,0.05,0.05
4
Added:
Développement floral,190.00,50.00,210.00,200.00,50.00,66.00,5.00,0.15,0.30,0.50,0.15,0.05
5
Added:
Tomates général,140.00,50.00,352.00,180.00,50.00,168.00,5.00,0.10,0.30,0.80,0.07,0.03
6
Added:
Jeunes tomates,100.00,40.00,200.00,100.00,20.00,53.00,3.00,0.10,0.30,0.80,0.07,0.03
7
Added:
Tomate premiers fruits,130.00,55.00,300.00,150.00,33.00,109.00,3.00,0.10,0.30,0.80,0.07,0.03
8
Added:
Tomate mûre,180.00,65.00,400.00,400.00,45.00,144.00,3.00,0.10,0.30,0.80,0.07,0.03
9
Added:
Framboise - tous stades,70.00,12.00,88.00,90.00,24.00,48.00,0.56,0.33,0.11,0.11,0.03,0.01
db/data/nutrient_profiles.csv
@@ -0,0 +1,9 @@
1
Added:
Nom,NNO3,P,K,Ca,Mg,S,Na,Cl,Si,Fe,Zn,B,Mn,Cu,Mo,NNH4
2
Added:
Générique croissance,160,30,230,100,30,60,0,0,0,5,"0,15","0,3","0,5","0,15","0,05",0
3
Added:
Générique floraison,130,60,300,100,30,60,0,0,0,2,"0,1","0,5","0,5","0,05","0,05",0
4
Added:
Laitue,190,50,210,200,50,66,0,0,0,5,"0,15","0,3","0,5","0,15","0,05",0
5
Added:
Tomate (cycle entier),140,50,352,180,50,168,0,0,0,5,"0,1","0,3","0,8","0,07","0,03",0
6
Added:
Tomate 10-14 jours,100,40,200,100,20,53,0,0,0,3,"0,1","0,3","0,8","0,07","0,03",0
7
Added:
Tomate 1ere grappe,130,55,300,150,33,109,0,0,0,3,"0,1","0,3","0,8","0,07","0,03",0
8
Added:
Tomate à maturité,180,65,400,400,45,144,0,0,0,3,"0,1","0,3","0,8","0,07","0,03",0
9
Added:
Framboise - tous stades,70,12,88,90,24,48,0,0,50,0.56,"0,325",11,"0,11","0,032","0,01",0
db/data/nutrient_requirements.csv
@@ -1,9 +0,0 @@
1
Removed:
Nom,NNO3,P,K,Ca,Mg,S,Na,Cl,Si,Fe,Zn,B,Mn,Cu,Mo,NNH4
2
Removed:
Générique croissance,160,30,230,100,30,60,0,0,0,5,"0,15","0,3","0,5","0,15","0,05",0
3
Removed:
Générique floraison,130,60,300,100,30,60,0,0,0,2,"0,1","0,5","0,5","0,05","0,05",0
4
Removed:
Laitue,190,50,210,200,50,66,0,0,0,5,"0,15","0,3","0,5","0,15","0,05",0
5
Removed:
Tomate (cycle entier),140,50,352,180,50,168,0,0,0,5,"0,1","0,3","0,8","0,07","0,03",0
6
Removed:
Tomate 10-14 jours,100,40,200,100,20,53,0,0,0,3,"0,1","0,3","0,8","0,07","0,03",0
7
Removed:
Tomate 1ere grappe,130,55,300,150,33,109,0,0,0,3,"0,1","0,3","0,8","0,07","0,03",0
8
Removed:
Tomate à maturité,180,65,400,400,45,144,0,0,0,3,"0,1","0,3","0,8","0,07","0,03",0
9
Removed:
Framboise - tous stades,70,12,88,90,24,48,0,0,50,0.56,"0,325",11,"0,11","0,032","0,01",0
db/migrate/20250901112954_rename_crops_to_nutrient_profiles.rb
@@ -0,0 +1,10 @@
1
Added:
class RenameCropsToNutrientProfiles < ActiveRecord::Migration[8.0]
2
Added:
def change
3
Added:
rename_table :crops, :nutrient_profiles
4
Added:
5
Added:
rename_column :rafts, :crop_id, :crop_nutrient_need_id
6
Added:
add_index :rafts, :crop_nutrient_need_id unless index_exists?(:rafts, :crop_nutrient_need_id)
7
Added:
8
Added:
remove_column :nutrient_profiles, :crop_type
9
Added:
end
10
Added:
end
db/schema.rb
@@ -10,7 +10,7 @@
10
10
#
11
11
# It's strongly recommended that you check this file into your version control system.
12
12
13
Removed:
ActiveRecord::Schema[8.0].define(version: 2025_08_24_163257) do
13
Added:
ActiveRecord::Schema[8.0].define(version: 2025_09_01_112954) do
14
14
create_table "beds", force: :cascade do |t|
15
15
t.integer "location", null: false
16
16
t.datetime "created_at", null: false
@@ -18,29 +18,6 @@
18
18
t.index ["location"], name: "index_beds_on_location", unique: true
19
19
end
20
20
21
Removed:
create_table "crops", force: :cascade do |t|
22
Removed:
t.string "name"
23
Removed:
t.integer "crop_type", default: 1, null: false
24
Removed:
t.float "nno3"
25
Removed:
t.float "p"
26
Removed:
t.float "k"
27
Removed:
t.float "ca"
28
Removed:
t.float "mg"
29
Removed:
t.float "s"
30
Removed:
t.float "na"
31
Removed:
t.float "cl"
32
Removed:
t.float "si"
33
Removed:
t.float "fe"
34
Removed:
t.float "zn"
35
Removed:
t.float "b"
36
Removed:
t.float "mn"
37
Removed:
t.float "cu"
38
Removed:
t.float "mo"
39
Removed:
t.float "nnh4"
40
Removed:
t.datetime "created_at", null: false
41
Removed:
t.datetime "updated_at", null: false
42
Removed:
end
43
Removed:
44
21
create_table "fertilizer_components", force: :cascade do |t|
45
22
t.string "name"
46
23
t.string "formula"
@@ -105,6 +82,28 @@
105
82
t.index ["measured_on"], name: "index_nutrient_measurements_on_measured_on", unique: true
106
83
end
107
84
85
Added:
create_table "nutrient_profiles", force: :cascade do |t|
86
Added:
t.string "name"
87
Added:
t.float "nno3"
88
Added:
t.float "p"
89
Added:
t.float "k"
90
Added:
t.float "ca"
91
Added:
t.float "mg"
92
Added:
t.float "s"
93
Added:
t.float "na"
94
Added:
t.float "cl"
95
Added:
t.float "si"
96
Added:
t.float "fe"
97
Added:
t.float "zn"
98
Added:
t.float "b"
99
Added:
t.float "mn"
100
Added:
t.float "cu"
101
Added:
t.float "mo"
102
Added:
t.float "nnh4"
103
Added:
t.datetime "created_at", null: false
104
Added:
t.datetime "updated_at", null: false
105
Added:
end
106
Added:
108
107
create_table "nutrients", force: :cascade do |t|
109
108
t.string "formula"
110
109
t.string "name"
@@ -115,16 +114,16 @@
115
114
create_table "rafts", force: :cascade do |t|
116
115
t.integer "bed_id", null: false
117
116
t.integer "location", null: false
118
Removed:
t.integer "crop_id"
117
Added:
t.integer "crop_nutrient_need_id"
119
118
t.datetime "created_at", null: false
120
119
t.datetime "updated_at", null: false
121
120
t.index ["bed_id", "location"], name: "index_rafts_on_bed_id_and_location", unique: true
122
121
t.index ["bed_id"], name: "index_rafts_on_bed_id"
123
Removed:
t.index ["crop_id"], name: "index_rafts_on_crop_id"
122
Added:
t.index ["crop_nutrient_need_id"], name: "index_rafts_on_crop_nutrient_need_id"
124
123
end
125
124
126
125
add_foreign_key "fertilizer_compositions", "fertilizer_components"
127
126
add_foreign_key "fertilizer_compositions", "fertilizer_products"
128
127
add_foreign_key "rafts", "beds"
129
Removed:
add_foreign_key "rafts", "crops"
128
Added:
add_foreign_key "rafts", "nutrient_profiles", column: "crop_nutrient_need_id"
130
129
end
db/seeds.rb
@@ -8,6 +8,15 @@
8
8
# MovieGenre.find_or_create_by!(name: genre_name)
9
9
# end
10
10
11
Removed:
Dir[Rails.root.join("db/seeds/*.rb")].sort.each do |seed|
12
Removed:
load seed
11
Added:
MODELS = [ NutrientMeasurement,
12
Added:
NutrientProfile
13
Added:
].freeze
14
Added:
15
Added:
def seed_file_for(model_class)
16
Added:
Rails.root.join("db", "seeds", "#{model_class.name}.rb")
17
Added:
end
18
Added:
19
Added:
MODELS.each do |model|
20
Added:
load seed_file_for(model)
21
Added:
printf "%-22s %3s\n", model.name, model.count
13
22
end
db/seeds/1_nutrients.rb
@@ -1,24 +0,0 @@
1
Removed:
NUTRIENTS = [
2
Removed:
[ "nno3", "nitrate" ],
3
Removed:
[ "p", "phosphore" ],
4
Removed:
[ "k", "potassium" ],
5
Removed:
[ "ca", "calcium" ],
6
Removed:
[ "mg", "magnésium" ],
7
Removed:
[ "s", "soufre" ],
8
Removed:
[ "na", "sodium" ],
9
Removed:
[ "cl", "chlore" ],
10
Removed:
[ "si", "silice" ],
11
Removed:
[ "fe", "fer" ],
12
Removed:
[ "zn", "zinc" ],
13
Removed:
[ "b", "bore" ],
14
Removed:
[ "mn", "manganèse" ],
15
Removed:
[ "cu", "cuivre" ],
16
Removed:
[ "mo", "molybdène" ],
17
Removed:
[ "nnh4", "ammonium" ]
18
Removed:
]
19
Removed:
20
Removed:
NUTRIENTS.each do |formula, name|
21
Removed:
Nutrient.find_or_create_by!(formula:) { |n| n.name = name }
22
Removed:
end
23
Removed:
24
Removed:
puts "Nutrients: #{Nutrient.count}"
db/seeds/2_nutrient_measurements.rb
@@ -1,27 +0,0 @@
1
Removed:
require "csv"
2
Removed:
3
Removed:
csv_path = Rails.root.join("db", "data", "dolibarr_measurements.csv")
4
Removed:
abort "CSV not found: #{csv_path}" unless File.exist?(csv_path)
5
Removed:
6
Removed:
CSV.foreach(csv_path, headers: true) do |row|
7
Removed:
NutrientMeasurement.find_or_create_by!(measured_on: Date.parse(row["date"])) do |m|
8
Removed:
m.nno3 = row["nno3"]
9
Removed:
m.p = row["p"]
10
Removed:
m.k = row["k"]
11
Removed:
m.ca = row["ca"]
12
Removed:
m.mg = row["mg"]
13
Removed:
m.s = row["s"]
14
Removed:
m.na = row["na"]
15
Removed:
m.cl = row["cl"]
16
Removed:
m.si = row["si"]
17
Removed:
m.fe = row["fe"]
18
Removed:
m.zn = row["zn"]
19
Removed:
m.b = row["b"]
20
Removed:
m.mn = row["mn"]
21
Removed:
m.cu = row["cu"]
22
Removed:
m.mo = row["mo"]
23
Removed:
m.nnh4 = row["nnh4"]
24
Removed:
end
25
Removed:
end
26
Removed:
27
Removed:
puts "NutrientMeasurements: #{NutrientMeasurement.count}"
db/seeds/3_crops.rb
@@ -1,125 +0,0 @@
1
Removed:
# crop_type: 0=leafy greens, 1=fruits, 2=herbs
2
Removed:
3
Removed:
LEAFY = {
4
Removed:
nno3: 150,
5
Removed:
p: 31,
6
Removed:
k: 210,
7
Removed:
ca: 90,
8
Removed:
mg: 24,
9
Removed:
s: 32,
10
Removed:
fe: 1.0,
11
Removed:
mn: 0.25,
12
Removed:
zn: 0.13,
13
Removed:
b: 0.16,
14
Removed:
cu: 0.023,
15
Removed:
mo: 0.024,
16
Removed:
nnh4: 5,
17
Removed:
# keep low for leafy
18
Removed:
na: 10,
19
Removed:
cl: 5,
20
Removed:
si: 20
21
Removed:
}
22
Removed:
23
Removed:
HERB = LEAFY # Herbs run well on the Cornell leafy recipe
24
Removed:
25
Removed:
TOMATO = {
26
Removed:
# CEAC Stage 3 "multi-crop" / mature tomato
27
Removed:
nno3: 190,
28
Removed:
p: 47,
29
Removed:
k: 350,
30
Removed:
ca: 200,
31
Removed:
mg: 65,
32
Removed:
s: 102,
33
Removed:
fe: 2.0,
34
Removed:
mn: 0.55,
35
Removed:
zn: 0.33,
36
Removed:
b: 0.28,
37
Removed:
cu: 0.05,
38
Removed:
mo: 0.05,
39
Removed:
nnh4: 0,
40
Removed:
# CEAC recipes are NO3-N; keep NH4 minimal
41
Removed:
na: 20,
42
Removed:
cl: 25,
43
Removed:
si: 20
44
Removed:
}
45
Removed:
46
Removed:
HOT_PEPPER = {
47
Removed:
# Mature greenhouse pepper (HortAmericas summary of UA recipes)
48
Removed:
nno3: 180,
49
Removed:
p: 50,
50
Removed:
k: 280,
51
Removed:
ca: 200,
52
Removed:
mg: 45,
53
Removed:
s: 20,
54
Removed:
fe: 1.0,
55
Removed:
mn: 0.55,
56
Removed:
zn: 0.33,
57
Removed:
b: 0.30,
58
Removed:
cu: 0.05,
59
Removed:
mo: 0.05,
60
Removed:
nnh4: 15,
61
Removed:
na: 20,
62
Removed:
cl: 10,
63
Removed:
si: 20
64
Removed:
}
65
Removed:
66
Removed:
STRAWBERRY = {
67
Removed:
# UA strawberry (Yamazaki) emphasizes lower EC; use conservative macros
68
Removed:
nno3: 120,
69
Removed:
p: 30,
70
Removed:
k: 200,
71
Removed:
ca: 120,
72
Removed:
mg: 35,
73
Removed:
s: 50,
74
Removed:
fe: 1.5,
75
Removed:
mn: 0.50,
76
Removed:
zn: 0.20,
77
Removed:
b: 0.30,
78
Removed:
cu: 0.05,
79
Removed:
mo: 0.05,
80
Removed:
nnh4: 5,
81
Removed:
na: 10,
82
Removed:
cl: 5,
83
Removed:
si: 20
84
Removed:
}
85
Removed:
86
Removed:
RASPBERRY = {
87
Removed:
# Sparse data; align with strawberry but a touch higher vigor
88
Removed:
nno3: 140,
89
Removed:
p: 35,
90
Removed:
k: 230,
91
Removed:
ca: 150,
92
Removed:
mg: 40,
93
Removed:
s: 50,
94
Removed:
fe: 1.5,
95
Removed:
mn: 0.50,
96
Removed:
zn: 0.20,
97
Removed:
b: 0.30,
98
Removed:
cu: 0.05,
99
Removed:
mo: 0.05,
100
Removed:
nnh4: 5,
101
Removed:
na: 15,
102
Removed:
cl: 5,
103
Removed:
si: 20
104
Removed:
}
105
Removed:
106
Removed:
[
107
Removed:
[ "lettuce", 0, LEAFY ],
108
Removed:
[ "kale", 0, LEAFY ],
109
Removed:
[ "cabbage, chinese", 0, LEAFY ],
110
Removed:
[ "tomatoes", 1, TOMATO ],
111
Removed:
[ "raspberries", 1, RASPBERRY ],
112
Removed:
[ "strawberries", 1, STRAWBERRY ],
113
Removed:
[ "hot peppers", 1, HOT_PEPPER ],
114
Removed:
[ "parsley", 2, HERB ],
115
Removed:
[ "chives", 2, HERB ],
116
Removed:
[ "italian basil", 2, HERB ],
117
Removed:
[ "dill", 2, HERB ]
118
Removed:
].each do |name, type, nutrient_requirements|
119
Removed:
Crop.find_or_create_by!(name: name) do |c|
120
Removed:
c.crop_type = type
121
Removed:
c.attributes = nutrient_requirements
122
Removed:
end
123
Removed:
end
124
Removed:
125
Removed:
puts "Crops: #{Crop.count}"
db/seeds/4_beds_and_rafts.rb
@@ -1,24 +0,0 @@
1
Removed:
BEDS = 14
2
Removed:
RAFTS = 10
3
Removed:
4
Removed:
1.upto(BEDS) do |b|
5
Removed:
bed = Bed.find_or_create_by!(location: b)
6
Removed:
7
Removed:
crop_name = case b
8
Removed:
when 1..2 then "tomatoes"
9
Removed:
when 3 then "hot peppers"
10
Removed:
when 4 then "chives"
11
Removed:
when 5 then "italian basil"
12
Removed:
when 6..7 then "cabbage, chinese"
13
Removed:
else "lettuce"
14
Removed:
end
15
Removed:
16
Removed:
1.upto(RAFTS) do |r|
17
Removed:
raft = bed.rafts.find_or_create_by!(location: r) do |raft|
18
Removed:
raft.crop = Crop.find_by!(name: crop_name)
19
Removed:
end
20
Removed:
end
21
Removed:
end
22
Removed:
23
Removed:
puts "Beds: #{Bed.count}"
24
Removed:
puts "Rafts: #{Raft.count}"
db/seeds/5_fertilizer_components.rb.bkp
@@ -1,38 +0,0 @@
1
Removed:
FERTILIZER_COMPONENTS = [
2
Removed:
# Macros / bases
3
Removed:
{ name: "Potassium Nitrate", formula: "KNO3", nno3: 13.50, k: 38.60 },
4
Removed:
{ name: "Calcium Nitrate", formula: "Ca(NO3)2·xH2O", nno3: 15.50, ca: 18.94 },
5
Removed:
{ name: "Ammonium Nitrate", formula: "NH4NO3", nno3: 13.50, nnh4: 13.50 }, # total N ≈ 27
6
Removed:
{ name: "Diammonium Phosphate", formula: "(NH4)2HPO4", p: 21.00 }, # NH4 can be added later if desired
7
Removed:
8
Removed:
# Acids / buffers
9
Removed:
{ name: "Nitric Acid 53%", formula: "HNO3", nno3: 11.70 },
10
Removed:
{ name: "Potassium Bicarbonate", formula: "KHCO3", k: 39.05 },
11
Removed:
{ name: "Calcium Carbonate", formula: "CaCO3", ca: 38.00 },
12
Removed:
13
Removed:
# K–Mg–S complex (Patentkali-type blend)
14
Removed:
{ name: "Potassium Sulfate blend (K–Mg–S + NaCl)", formula: "K2SO4+MgSO4+NaCl",
15
Removed:
p: 0.44, k: 22.66, mg: 6.16, s: 17.77, na: 2.16, cl: 3.34 },
16
Removed:
17
Removed:
# Sulfates (micros / secondary)
18
Removed:
{ name: "Magnesium Sulfate", formula: "MgSO4·7H2O", mg: 9.648, s: 13.016 },
19
Removed:
{ name: "Manganese Sulfate", formula: "MnSO4", s: 7.17, mn: 12.00 },
20
Removed:
{ name: "Zinc Sulfate (Fiza Zinc)", formula: "ZnSO4", zn: 12.00 },
21
Removed:
22
Removed:
# Chelates / traces
23
Removed:
{ name: "Iron DTPA 11.8%", formula: "Fe-DTPA", fe: 11.80 },
24
Removed:
{ name: "HelioCopper (Cu chelate)", formula: "Cu-chelate", cu: 40.00 },
25
Removed:
26
Removed:
# Boron & Mo sources
27
Removed:
{ name: "Boron–Molybdenum (Boronia LS)", formula: "B+Mo", b: 13.50, mo: 0.028 },
28
Removed:
{ name: "Boronia MO12 (10L)", formula: "B+Mn+Cu", b: 8.90, mn: 0.089, cu: 0.89 },
29
Removed:
{ name: "Sodium Molybdate", formula: "Na2MoO4", mo: 39.50 }
30
Removed:
]
31
Removed:
32
Removed:
FERTILIZER_COMPONENTS.each do |attrs|
33
Removed:
FertilizerComponent.find_or_create_by!(name: attrs[:name]) do |c|
34
Removed:
c.attributes = attrs
35
Removed:
end
36
Removed:
end
37
Removed:
38
Removed:
puts "Fertilizer components: #{FertilizerComponent.count}"
db/seeds/6_fertilizer_products.rb
@@ -1,143 +0,0 @@
1
Removed:
FERTILIZER_RECIPES = [
2
Removed:
{
3
Removed:
name: "Multi K Reci",
4
Removed:
purity: 99.0,
5
Removed:
composition: [
6
Removed:
{ component: {
7
Removed:
name: "Potassium nitrate",
8
Removed:
formula: "KNO3",
9
Removed:
nno3: 13.0,
10
Removed:
k: 46.0 },
11
Removed:
percent_w: 100.0 }
12
Removed:
]
13
Removed:
},
14
Removed:
{
15
Removed:
name: "Fixa Mn",
16
Removed:
purity: 98.0,
17
Removed:
composition: [
18
Removed:
{ component: {
19
Removed:
name: "Manganese sulfate",
20
Removed:
formula: "MnSO4·H2O",
21
Removed:
mn: 32.0,
22
Removed:
s: 18.0 },
23
Removed:
percent_w: 100.0 }
24
Removed:
]
25
Removed:
},
26
Removed:
{
27
Removed:
name: "Multi-Cal Haïfa",
28
Removed:
purity: 99.0,
29
Removed:
composition: [
30
Removed:
{ component: {
31
Removed:
name: "Calcium nitrate",
32
Removed:
formula: "Ca(NO3)2·4H2O",
33
Removed:
nno3: 15.5,
34
Removed:
ca: 19.0 },
35
Removed:
percent_w: 100.0 }
36
Removed:
]
37
Removed:
},
38
Removed:
{
39
Removed:
name: "DAP 18/46/00",
40
Removed:
purity: 100.0,
41
Removed:
composition: [
42
Removed:
{ component: {
43
Removed:
name: "Diammonium phosphate",
44
Removed:
formula: "(NH4)2HPO4",
45
Removed:
nnh4: 18.0,
46
Removed:
p: 20.0 },
47
Removed:
percent_w: 100.0 }
48
Removed:
]
49
Removed:
},
50
Removed:
{
51
Removed:
name: "Patenkali",
52
Removed:
purity: 100.0,
53
Removed:
composition: [
54
Removed:
{ component: {
55
Removed:
name: "Potassium sulfate",
56
Removed:
formula: "K2SO4",
57
Removed:
k: 50.0,
58
Removed:
s: 18.0
59
Removed:
}, percent_w: 100.0 }
60
Removed:
]
61
Removed:
},
62
Removed:
{
63
Removed:
name: "Eso Top",
64
Removed:
purity: 100.0,
65
Removed:
composition: [
66
Removed:
{ component: {
67
Removed:
name: "Magnesium sulfate",
68
Removed:
formula: "MgSO4·7H2O",
69
Removed:
mg: 9.8,
70
Removed:
s: 13.0 },
71
Removed:
percent_w: 100.0 }
72
Removed:
]
73
Removed:
},
74
Removed:
{
75
Removed:
name: "Ammonitrate 27",
76
Removed:
purity: 100.0,
77
Removed:
composition: [
78
Removed:
{ component: {
79
Removed:
name: "Ammonium nitrate",
80
Removed:
formula: "NH4NO3",
81
Removed:
nno3: 13.5,
82
Removed:
nnh4: 13.5 },
83
Removed:
percent_w: 100.0 }
84
Removed:
]
85
Removed:
},
86
Removed:
{
87
Removed:
name: "Fer chélaté",
88
Removed:
purity: 100.0,
89
Removed:
composition: [
90
Removed:
{ component: {
91
Removed:
name: "Iron chelate (EDDHA)",
92
Removed:
formula: "Fe-EDDHA",
93
Removed:
fe: 6.0 },
94
Removed:
percent_w: 100.0 }
95
Removed:
]
96
Removed:
},
97
Removed:
{
98
Removed:
name: "Carbonate de calcium",
99
Removed:
purity: 100.0,
100
Removed:
composition: [
101
Removed:
{ component: {
102
Removed:
name: "Calcium carbonate",
103
Removed:
formula: "CaCO3",
104
Removed:
ca: 40.0 },
105
Removed:
percent_w: 100.0 }
106
Removed:
]
107
Removed:
},
108
Removed:
{
109
Removed:
name: "Héliocuivre",
110
Removed:
purity: 100.0,
111
Removed:
composition: [
112
Removed:
{ component: {
113
Removed:
name: "Copper chelate (EDTA)",
114
Removed:
formula: "Cu-EDTA",
115
Removed:
cu: 14.0 },
116
Removed:
percent_w: 100.0 }
117
Removed:
]
118
Removed:
}
119
Removed:
]
120
Removed:
121
Removed:
FERTILIZER_RECIPES.each do |recipe|
122
Removed:
product = FertilizerProduct.find_or_create_by!(name: recipe[:name]) do |fp|
123
Removed:
fp.purity = recipe[:purity]
124
Removed:
end
125
Removed:
126
Removed:
recipe[:composition].each do |c|
127
Removed:
comp_attrs = c[:component]
128
Removed:
component = FertilizerComponent.find_or_create_by!(name: comp_attrs[:name]) do |fc|
129
Removed:
fc.formula = comp_attrs[:formula]
130
Removed:
end
131
Removed:
# update nutrient fields if missing
132
Removed:
component.update!(comp_attrs.except(:name, :formula))
133
Removed:
134
Removed:
FertilizerComposition.find_or_create_by!(
135
Removed:
fertilizer_product: product,
136
Removed:
fertilizer_component: component
137
Removed:
) do |fc|
138
Removed:
fc.percent_w = c[:percent_w]
139
Removed:
end
140
Removed:
end
141
Removed:
end
142
Removed:
143
Removed:
puts "FertilizerProducts: #{FertilizerProduct.count}"
db/seeds/NutrientMeasurement.rb
@@ -0,0 +1,25 @@
1
Added:
require "csv"
2
Added:
3
Added:
csv_path = Rails.root.join("db", "data", "dolibarr_measurements.csv")
4
Added:
abort "CSV not found: #{csv_path}" unless File.exist?(csv_path)
5
Added:
6
Added:
CSV.foreach(csv_path, headers: true) do |row|
7
Added:
NutrientMeasurement.find_or_create_by!(measured_on: Date.parse(row["date"])) do |m|
8
Added:
m.nno3 = row["nno3"]
9
Added:
m.p = row["p"]
10
Added:
m.k = row["k"]
11
Added:
m.ca = row["ca"]
12
Added:
m.mg = row["mg"]
13
Added:
m.s = row["s"]
14
Added:
m.na = row["na"]
15
Added:
m.cl = row["cl"]
16
Added:
m.si = row["si"]
17
Added:
m.fe = row["fe"]
18
Added:
m.zn = row["zn"]
19
Added:
m.b = row["b"]
20
Added:
m.mn = row["mn"]
21
Added:
m.cu = row["cu"]
22
Added:
m.mo = row["mo"]
23
Added:
m.nnh4 = row["nnh4"]
24
Added:
end
25
Added:
end
db/seeds/NutrientProfile.rb
@@ -0,0 +1,109 @@
1
Added:
[ { name: "formule moyenne générale",
2
Added:
nno3: 160.00,
3
Added:
p: 30.00,
4
Added:
k: 230.00,
5
Added:
ca: 100.00,
6
Added:
mg: 30.00,
7
Added:
s: 60.00,
8
Added:
fe: 5.00,
9
Added:
zn: 0.15,
10
Added:
b: 0.30,
11
Added:
mn: 0.50,
12
Added:
cu: 0.15,
13
Added:
mo: 0.05 },
14
Added:
{ name: "salades",
15
Added:
nno3: 130.00,
16
Added:
p: 60.00,
17
Added:
k: 300.00,
18
Added:
ca: 100.00,
19
Added:
mg: 30.00,
20
Added:
s: 60.00,
21
Added:
fe: 2.00,
22
Added:
zn: 0.10,
23
Added:
b: 0.50,
24
Added:
mn: 0.50,
25
Added:
cu: 0.05,
26
Added:
mo: 0.05 },
27
Added:
{ name: "développement floral",
28
Added:
nno3: 190.00,
29
Added:
p: 50.00,
30
Added:
k: 210.00,
31
Added:
ca: 200.00,
32
Added:
mg: 50.00,
33
Added:
s: 66.00,
34
Added:
fe: 5.00,
35
Added:
zn: 0.15,
36
Added:
b: 0.30,
37
Added:
mn: 0.50,
38
Added:
cu: 0.15,
39
Added:
mo: 0.05 },
40
Added:
{ name: "tomates général",
41
Added:
nno3: 140.00,
42
Added:
p: 50.00,
43
Added:
k: 352.00,
44
Added:
ca: 180.00,
45
Added:
mg: 50.00,
46
Added:
s: 168.00,
47
Added:
fe: 5.00,
48
Added:
zn: 0.10,
49
Added:
b: 0.30,
50
Added:
mn: 0.80,
51
Added:
cu: 0.07,
52
Added:
mo: 0.03 },
53
Added:
{ name: "jeunes tomates",
54
Added:
nno3: 100.00,
55
Added:
p: 40.00,
56
Added:
k: 200.00,
57
Added:
ca: 100.00,
58
Added:
mg: 20.00,
59
Added:
s: 53.00,
60
Added:
fe: 3.00,
61
Added:
zn: 0.10,
62
Added:
b: 0.30,
63
Added:
mn: 0.80,
64
Added:
cu: 0.07,
65
Added:
mo: 0.03 },
66
Added:
{ name: "tomate premiers fruits",
67
Added:
nno3: 130.00,
68
Added:
p: 55.00,
69
Added:
k: 300.00,
70
Added:
ca: 150.00,
71
Added:
mg: 33.00,
72
Added:
s: 109.00,
73
Added:
fe: 3.00,
74
Added:
zn: 0.10,
75
Added:
b: 0.30,
76
Added:
mn: 0.80,
77
Added:
cu: 0.07,
78
Added:
mo: 0.03 },
79
Added:
{ name: "tomate mûre",
80
Added:
nno3: 180.00,
81
Added:
p: 65.00,
82
Added:
k: 400.00,
83
Added:
ca: 400.00,
84
Added:
mg: 45.00,
85
Added:
s: 144.00,
86
Added:
fe: 3.00,
87
Added:
zn: 0.10,
88
Added:
b: 0.30,
89
Added:
mn: 0.80,
90
Added:
cu: 0.07,
91
Added:
mo: 0.03 },
92
Added:
{ name: "framboise - tous stades",
93
Added:
nno3: 70.00,
94
Added:
p: 12.00,
95
Added:
k: 88.00,
96
Added:
ca: 90.00,
97
Added:
mg: 24.00,
98
Added:
s: 48.00,
99
Added:
fe: 0.56,
100
Added:
zn: 0.33,
101
Added:
b: 0.11,
102
Added:
mn: 0.11,
103
Added:
cu: 0.03,
104
Added:
mo: 0.01 },
105
Added:
].each do |profile|
106
Added:
NutrientProfile.find_or_create_by!(name: profile[:name]) do |p|
107
Added:
p.attributes = profile
108
Added:
end
109
Added:
end