diff --git a/skyetel/Dockerfile b/skyetel/Dockerfile index be11ebd..f74d098 100644 --- a/skyetel/Dockerfile +++ b/skyetel/Dockerfile @@ -28,5 +28,6 @@ FROM public.ecr.aws/lambda/ruby:$RUBY_VERSION COPY --from=build-image ${LAMBDA_TASK_ROOT} ${LAMBDA_TASK_ROOT} ENV RUBY_YJIT_ENABLE=true +ENV APP_ENV=production CMD [ "app.App::Handler.process" ] diff --git a/skyetel/Gemfile b/skyetel/Gemfile index 69c84e9..518d90c 100644 --- a/skyetel/Gemfile +++ b/skyetel/Gemfile @@ -3,6 +3,7 @@ source "https://rubygems.org" gem "aws-sdk-ssm" gem "csv" gem "encrypted_credentials", github: "somleng/encrypted_credentials" +gem "logger" gem "faraday" gem "rack" gem "rate_center" diff --git a/skyetel/Gemfile.lock b/skyetel/Gemfile.lock index 4b0a220..3de2a49 100644 --- a/skyetel/Gemfile.lock +++ b/skyetel/Gemfile.lock @@ -146,6 +146,7 @@ DEPENDENCIES csv encrypted_credentials! faraday + logger pry rack rake diff --git a/skyetel/README.md b/skyetel/README.md index 8babb91..e2cd64c 100644 --- a/skyetel/README.md +++ b/skyetel/README.md @@ -8,6 +8,7 @@ This integration runs on a schedule and automatically orders DIDs from [Skyetel] | Variable | Description | Example | Required | Default | | -------------------------- | --------------------------------------------------------- | ---------------------- | -------- | ---------------------- | +| APP_ENV | Application environment | production | false | production | | SOMLENG_API_KEY | Somleng Carrier API Key SID | change-me | true | none | | SKYETEL_USERNAME | Skyetel API Username Token | change-me | true | none | | SKYETEL_PASSWORD | Skyetel API Password | change-me | true | none | @@ -20,6 +21,14 @@ This integration runs on a schedule and automatically orders DIDs from [Skyetel] See [examples](https://github.com/somleng/somleng-integrations/tree/develop/skyetel/examples). +## CLI + +The CLI can be used to test your integration or in standalone mode. + +```bash +./bin/somleng-skyetel +``` + ## Deployment The [docker image](https://github.com/somleng/somleng-integrations/pkgs/container/somleng-skyetel) is automatically configured for deployment to AWS Lambda. diff --git a/skyetel/app/models/shopping_list.rb b/skyetel/app/models/shopping_list.rb index aaaca50..0c44432 100644 --- a/skyetel/app/models/shopping_list.rb +++ b/skyetel/app/models/shopping_list.rb @@ -1,9 +1,12 @@ class ShoppingList LineItem = Struct.new(:country, :region, :locality, :quantity, :nearby_rate_centers) - attr_reader :line_items + attr_reader :line_items, :cities, :min_stock, :max_stock - def initialize(line_items:) - @line_items = Array(line_items) + def initialize(**options) + @line_items = Array(options.fetch(:line_items)) + @cities = options[:cities] + @min_stock = options[:min_stock] + @max_stock = options[:max_stock] end end diff --git a/skyetel/app/workflows/generate_shopping_list.rb b/skyetel/app/workflows/generate_shopping_list.rb index 3633c3d..fb23ffb 100644 --- a/skyetel/app/workflows/generate_shopping_list.rb +++ b/skyetel/app/workflows/generate_shopping_list.rb @@ -32,6 +32,6 @@ def call ) end - ShoppingList.new(line_items:) + ShoppingList.new(line_items:, cities:, min_stock:, max_stock:) end end diff --git a/skyetel/app/workflows/restock_inventory.rb b/skyetel/app/workflows/restock_inventory.rb index 8747a49..dd06853 100644 --- a/skyetel/app/workflows/restock_inventory.rb +++ b/skyetel/app/workflows/restock_inventory.rb @@ -3,18 +3,93 @@ def self.call(...) new(...).call end - attr_reader :somleng_client, :skyetel_client + attr_reader :somleng_client, :skyetel_client, :dry_run, :logger, :verbose def initialize(**options) @somleng_client = options.fetch(:somleng_client) { Somleng::CarrierAPI::Client.new } @skyetel_client = options.fetch(:skyetel_client) { Skyetel::Client.new } + @dry_run = options[:dry_run] + @verbose = options[:verbose] + @logger = options.fetch(:logger) { Logger.new(STDOUT) } end def call + inventory_report = generate_inventory_report + shopping_list = generate_shopping_list(inventory_report) + purchase_order = generate_purchase_order(shopping_list) + execute_order(purchase_order) + update_inventory(purchase_order) + end + + private + + def generate_inventory_report + logger.info("Generating inventory report...") inventory_report = GenerateInventoryReport.call(client: somleng_client) + logger.info("Done.") + log_inventory_report(inventory_report) if verbose + inventory_report + end + + def generate_shopping_list(inventory_report) + logger.info("Generating shopping list...") shopping_list = GenerateShoppingList.call(inventory_report:) + logger.info("Done.") + log_shopping_list(shopping_list) if verbose + shopping_list + end + + def generate_purchase_order(shopping_list) + logger.info("Generating purchase order...") purchase_order = GeneratePurchaseOrder.call(shopping_list:, client: skyetel_client) + logger.info("Done.") + logger.info("Purchase order contains #{purchase_order.to_order.count} numbers.") if verbose + purchase_order + end + + def execute_order(purchase_order) + if dry_run + logger.info("Dry run. Skipping order execution.") + return + end + + logger.info("Executing order...") ExecuteOrder.call(purchase_order:, client: skyetel_client) + logger.info("Done.") + end + + def update_inventory(purchase_order) + if dry_run + logger.info("Dry run. Skipping inventory update.") + return + end + + logger.info("Updating inventory...") UpdateInventory.call(purchase_order:, client: somleng_client) + logger.info("Done.") + end + + def log_inventory_report(inventory_report) + logger.info("Inventory report contains #{inventory_report.line_items.count} cities.") + report_summary = inventory_report.line_items.each_with_object({}) do |line_item, result| + result["#{line_item.country}/#{line_item.region}/#{line_item.locality}"] = line_item.quantity + end + + logger.info("Inventory report: #{JSON.pretty_generate(report_summary)}") + end + + def log_shopping_list(shopping_list) + logger.info("Shopping list generated with the following options: MIN_STOCK: #{shopping_list.min_stock}, MAX_STOCK: #{shopping_list.max_stock}.") + if shopping_list.line_items.count == 0 + logger.warn("Shopping list contains no items.") + cities = shopping_list.cities.map { |city| "#{city.country}/#{city.region}/#{city.name}" } + logger.info("Shopping list generated for the following cities: #{JSON.pretty_generate(cities)}") + else + logger.info("Shopping list contains #{shopping_list.line_items.count} items.") + report_summary = shopping_list.line_items.each_with_object({}) do |line_item, result| + result["#{line_item.country}/#{line_item.region}/#{line_item.locality}"] = line_item.quantity + end + logger.info("Shopping list: #{JSON.pretty_generate(report_summary)}") + end end end diff --git a/skyetel/bin/somleng-skyetel b/skyetel/bin/somleng-skyetel new file mode 100755 index 0000000..42bb923 --- /dev/null +++ b/skyetel/bin/somleng-skyetel @@ -0,0 +1,58 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "bundler/setup" +require "optparse" +require_relative "../config/application" + +class OptionsParser + class MissingArgumentError < StandardError; end + + Options = Struct.new(:dry_run, :verbose) + + attr_reader :parser, :options + + def initialize(**options) + @parser = options.fetch(:parser) { default_parser } + @options = Options.new + end + + def parse + parser.parse! + check_environment!("APP_ENV", "SOMLENG_API_KEY", "SKYETEL_USERNAME", "SKYETEL_PASSWORD", "MIN_STOCK", "MAX_STOCK") + options + end + + def help + parser.help + end + + private + + def check_environment!(*keys) + Array(keys).each do |key| + raise MissingArgumentError.new("missing env var: #{key}") unless ENV.key?(key) + end + end + + def default_parser + OptionParser.new do |opts| + opts.banner = "Usage: somleng-skyetel [options]" + opts.on("--[no-]dry-run [FLAG]", "Dry run only. No phone numbers will be actually purchased.", TrueClass) { |o| options.dry_run = o.nil? ? true : o } + opts.on("--[no-]verbose [FLAG]", "Run verbosely", TrueClass) { |o| options.verbose = o.nil? ? true : o } + end + end +end + +def parse_options + parser = OptionsParser.new + parser.parse +rescue OptionsParser::MissingArgumentError => e + puts e.message + puts parser.help + exit(1) +end + +options = parse_options + +RestockInventory.call(dry_run: options.dry_run, verbose: options.verbose) diff --git a/skyetel/examples/README.md b/skyetel/examples/README.md index fcf9f57..6b5b56f 100644 --- a/skyetel/examples/README.md +++ b/skyetel/examples/README.md @@ -17,5 +17,5 @@ The image is ready to be deployed to AWS Lambda and can be triggered by a schedu If you're not using Lambda, you can run your image with the following command. ```bash -docker run --platform linux/amd64 --rm -it -e APP_ENV=production -e SOMLENG_API_KEY='somleng-carrier-api-key' SOMLENG_API_KEY='somleng-carrier-api-key' -e SKYETEL_USERNAME='skyetel-username' -e SKYETEL_PASSWORD='skyetel-password' -e MIN_STOCK=2 -e MAX_STOCK=2 --entrypoint ruby somleng-skyetel:example -r ./app.rb -e App::Handler.process +docker run --platform linux/amd64 --rm -it -e APP_ENV=production -e SOMLENG_API_KEY='somleng-carrier-api-key' SOMLENG_API_KEY='somleng-carrier-api-key' -e SKYETEL_USERNAME='skyetel-username' -e SKYETEL_PASSWORD='skyetel-password' -e MIN_STOCK=5 -e MAX_STOCK=10 --entrypoint ./bin/somleng-skyetel somleng-skyetel:example ```