summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCharlotte Koch <charlotte@magentastripe.com>2023-10-24 11:56:17 -0700
committerCharlotte Koch <dressupgeekout@gmail.com>2023-10-24 11:56:37 -0700
commit1c8dc0461d0f54ded68df741775443762b7c6905 (patch)
tree1c8340870181f4180c2b84f959d2255211ecefca
parent77bb20beb12c517a888885fb7a055202db7ccc76 (diff)
First real commit
-rw-r--r--.gitignore7
-rw-r--r--Gemfile4
-rw-r--r--README.md46
-rw-r--r--packingslip.rb285
4 files changed, 342 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..cc60e44
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+.bundle/
+data/
+Gemfile.lock
+*.pdf
+*.swo
+*.swp
+vendor/
diff --git a/Gemfile b/Gemfile
new file mode 100644
index 0000000..7e30993
--- /dev/null
+++ b/Gemfile
@@ -0,0 +1,4 @@
+source "https://rubygems.org"
+gem "matrix"
+gem "prawn"
+gem "prawn-table"
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..407a0dd
--- /dev/null
+++ b/README.md
@@ -0,0 +1,46 @@
+# MSM PackingSlip Generator
+
+This tool takes as input a YAML document ("manifest") which shows what a
+customer ordered, and generates a PDF of the packing slip. The packing slip
+is intended to be printed and shipped to the customer along with the
+merchandise.
+
+The YAML manifest refers only to "catalog numbers," so you must also provide
+a catalog file which maps catalog numbers to all the other metadata about
+the goods you're selling (human-readable description, unit price, etc.)
+
+
+## Example YAML manifest
+
+
+```yaml
+---
+order_no: 1
+order_date: "2023-09-29"
+
+manifest:
+ -
+ catalog_no: 3
+ qty: 2
+ -
+ catalog_no: 5
+ qty: 1
+
+bill_to: |
+ Perseus Floof
+ 57345 Calamity Court
+ Goalla Gumpy, RI 19535
+ United States
+
+ship_to: |
+ Boris M. Q. Felicity III
+ 10 Decimal Way
+ Charming, WY 79345
+ United States
+```
+
+
+## License
+
+This software is released by Magenta Stripe Media under the terms of a
+2-clause BSD-style license. Refer to the LICENSE document.
diff --git a/packingslip.rb b/packingslip.rb
new file mode 100644
index 0000000..6578c9c
--- /dev/null
+++ b/packingslip.rb
@@ -0,0 +1,285 @@
+#
+# MAGENTA STRIPE MEDIA
+# Packing Slip Generator Tool
+#
+
+require 'optparse'
+require 'prawn'
+require 'prawn/table'
+require 'yaml'
+
+########## ########## ##########
+
+# Just a namespace.
+module MagentaStripeMedia
+end
+
+########## ########## ##########
+
+# Custom TSV reader.
+class MagentaStripeMedia::TSVReader
+ include Enumerable
+
+ ONE_OR_MORE_TABS = /\t+/.freeze
+
+ attr_reader :data
+
+ def initialize(filename)
+ @data = []
+
+ fp = File.open(filename, "r")
+ columns = fp.gets.chomp.split(ONE_OR_MORE_TABS)
+
+ until fp.eof?
+ line = fp.gets
+ next if not line
+ record = line.chomp.split(ONE_OR_MORE_TABS)
+ datum = {}
+
+ columns.each_with_index do |column_name, index|
+ datum[column_name] = record[index]
+ end
+
+ @data << datum
+ end
+
+ fp.close
+ end
+
+ def each
+ @data.each { |datum| yield datum }
+ end
+end
+
+########## ########## ##########
+
+# A single line item in the invoice.
+class MagentaStripeMedia::Item
+ attr_accessor :name
+ attr_accessor :catalog_no
+ attr_accessor :qty
+ attr_accessor :unit_price
+
+ def initialize
+ @name = "(untitled)"
+ @catalog_no = "MSM-00000"
+ @qty = 0
+ @unit_price = 0.00
+ end
+
+ def to_prawn_data
+ return [
+ @catalog_no.to_s,
+ @name.to_s,
+ self.unit_price_to_s,
+ @qty.to_s,
+ self.total_price_to_s,
+ ]
+ end
+
+ def unit_price_to_s
+ return sprintf("$%0.2f", @unit_price)
+ end
+
+ def total_price_to_s
+ return sprintf("$%0.2f", @unit_price * @qty)
+ end
+end
+
+########## ########## ##########
+
+# A complete order from a customer.
+class MagentaStripeMedia::Manifest
+ attr_reader :catalog
+ attr_reader :business_info
+ attr_reader :items
+ attr_reader :bill_to
+ attr_reader :ship_to
+ attr_reader :order_no
+ attr_reader :order_date
+
+ # - :catalog_data => String
+ # - :business_info => String
+ # - :manifest_file => String
+ def initialize(**kwargs)
+ @catalog = MagentaStripeMedia::TSVReader.new(kwargs[:catalog_data]).data
+ @business_info = YAML.load(File.read(kwargs[:business_info]))
+ details = YAML.load(File.read(kwargs[:manifest_file]))
+
+ @items = details["manifest"].map do |data|
+ catalog_no = sprintf("MSM-%05d", data["catalog_no"])
+ the_product = @catalog.detect { |product| product["CATALOG-NO"] == catalog_no }
+
+ item = MagentaStripeMedia::Item.new
+ item.catalog_no = the_product["CATALOG-NO"] = catalog_no
+ item.name = the_product["TITLE"]
+ item.qty = data["qty"].to_i
+ item.unit_price = the_product["UNIT-PRICE"].to_f
+ item
+ end
+
+ @bill_to = details["bill_to"]
+ @ship_to = details["ship_to"]
+ @order_no = sprintf("%08d", details["order_no"])
+ @order_date = details["order_date"]
+ end
+end
+
+########## ########## ##########
+
+# The invoice itself.
+class MagentaStripeMedia::InvoiceGenerator
+ include Enumerable
+
+ DPI = 72
+ HELVETICA = "Helvetica".freeze
+
+ attr_accessor :manifest
+ attr_accessor :s_and_h
+
+ # - :manifest => MagentaStripeMedia::Manifest
+ # - :s_and_h => Float
+ def initialize(**kwargs)
+ @manifest = kwargs[:manifest] || raise(ArgumentError, "expected a manifest")
+ @s_and_h = kwargs[:s_and_h] || 0.00
+ end
+
+ def main(outfile)
+ pdf = Prawn::Document.new({
+ :page_size => [inches(8.5), inches(11)],
+ :margin => [inches(0.5), inches(0.5), inches(0.5), inches(0.5)],
+ :page_layout => :portrait,
+ })
+ pdf.font(HELVETICA, :size => 10, :style => :normal)
+
+ pdf.image("./data/logo-head-transparent.png", **{
+ :position => :center,
+ :height => inches(1.25),
+ })
+
+ pdf.font(HELVETICA, :size => 12, :style => :bold) do
+ pdf.text("\n#{@manifest.business_info['name']}\n", :align => :center)
+ end
+
+ pdf.font(HELVETICA, :size => 10, :style => :normal) do
+ @manifest.business_info["address"].split("\n").each do |line|
+ pdf.text(line + "\n", :align => :center)
+ end
+ pdf.text("\n")
+ end
+
+ pdf.font(HELVETICA, :size => 12, :style => :bold) do
+ pdf.text("PACKING SLIP - ORDER # #{@manifest.order_no} - #{@manifest.order_date}\n\n")
+ end
+
+ ##########
+
+ shipping_data = [
+ ["BILL TO:", "SHIP TO:"],
+ [@manifest.bill_to, @manifest.ship_to],
+ ]
+
+ pdf.table(shipping_data) do |table|
+ table.style(table.row(0), :font_style => :bold)
+ end
+
+ ##########
+
+ pdf.text("\n\n")
+
+ data = [
+ ["Catalog no.", "Name", "Unit price", "Qty.", "Amount"]
+ ]
+
+ @manifest.items.each { |item| data << item.to_prawn_data }
+
+ pdf.table(data, :cell_style => {:padding => inches(0.15)}) do |table|
+ table.style(table.row(0), :font_style => :bold)
+ end
+
+ pdf.font(HELVETICA, :size => 12) do
+ pdf.text(sprintf("\n<b>SUBTOTAL:</b> $%0.2f\n", self.subtotal), :inline_format => true)
+ pdf.text(sprintf("<b>SHIPPING & HANDLING:</b> $%0.2f\n", @s_and_h), :inline_format => true)
+ pdf.text(sprintf("<b>TOTAL:</b> $%0.2f\n\n", self.grand_total), :inline_format => true)
+ end
+
+ ##########
+
+ pdf.text(@manifest.business_info["signoff"])
+
+ ##########
+
+ pdf.render_file(outfile)
+ return 0
+ end
+
+ def each
+ @items.each { |item| yield item }
+ end
+
+ def inches(n)
+ return n * DPI
+ end
+
+ def subtotal
+ return @manifest.items.reduce(0.00) { |total, item|
+ total + (item.unit_price * item.qty)
+ }
+ end
+
+ def grand_total
+ return self.subtotal + @s_and_h
+ end
+end
+
+########## ########## ##########
+
+if $0 == __FILE__
+ def die(message)
+ $stderr.puts("FATAL: #{message}")
+ exit 1
+ end
+
+ catalog_data_file = "./data/MSM_CATALOG.tsv"
+ business_info_file = "./data/BUSINESS_INFO.yaml"
+ manifest_file = nil
+ outfile = nil
+
+ parser = OptionParser.new do |opts|
+ opts.on("-m", "--manifest FILE") { |path|
+ manifest_file = File.expand_path(path)
+ }
+ opts.on("-o", "--output FILE") { |path|
+ outfile = File.expand_path(path)
+ }
+ opts.on("-c", "--catalog FILE", "default: #{catalog_data_file}") { |path|
+ catalog_data_file = File.expand_path(path)
+ }
+ opts.on("-b", "--business-info FILE", "default: #{business_info_file}") { |path|
+ business_info_file = File.expand_path(path)
+ }
+ end
+ parser.parse!(ARGV)
+
+ die("expected a manifest file") if not manifest_file
+ die("expected an outfile") if not outfile
+ die("no such file: #{catalog_data_file}") if not File.file?(catalog_data_file)
+ die("no such file: #{business_info_file}") if not File.file?(business_info_file)
+
+ invoicer = MagentaStripeMedia::InvoiceGenerator.new(**{
+ :manifest => MagentaStripeMedia::Manifest.new(**{
+ :catalog_data => catalog_data_file,
+ :business_info => business_info_file,
+ :manifest_file => manifest_file,
+ }),
+ :s_and_h => 1.23,
+ })
+
+ begin
+ rv = invoicer.main(outfile)
+ rescue
+ rv = 1
+ ensure
+ exit rv
+ end
+end