bisac 0.8
Sign up to get free protection for your applications and to get access to all the features.
- data/COPYING +340 -0
- data/LICENSE +12 -0
- data/README +24 -0
- data/Rakefile +90 -0
- data/lib/bisac.rb +17 -0
- data/lib/bisac/message.rb +110 -0
- data/lib/bisac/po.rb +252 -0
- data/lib/bisac/po_line_item.rb +115 -0
- data/lib/bisac/poa.rb +212 -0
- data/lib/bisac/poa_line_item.rb +81 -0
- data/lib/bisac/product.rb +174 -0
- data/specs/data/bisac_multi_po.txt +41 -0
- data/specs/data/bisac_po.txt +112 -0
- data/specs/data/bisac_po_no_footer.txt +111 -0
- data/specs/data/bisac_po_no_header.txt +111 -0
- data/specs/data/single_product.xml +74 -0
- data/specs/data/valid_bisac.txt +213 -0
- data/specs/data/valid_poa.txt +83 -0
- data/specs/new_bisac_spec.rb +67 -0
- data/specs/new_po_line_item_spec.rb +52 -0
- data/specs/new_po_spec.rb +85 -0
- data/specs/poa_line_item_spec.rb +36 -0
- data/specs/poa_spec.rb +70 -0
- metadata +96 -0
data/lib/bisac/poa.rb
ADDED
@@ -0,0 +1,212 @@
|
|
1
|
+
module Bisac
|
2
|
+
|
3
|
+
# Represents a single BISAC purchase order acknowledgement
|
4
|
+
#
|
5
|
+
# = Generating
|
6
|
+
#
|
7
|
+
# It's currently not possible to generate a POA with this class. All it needs
|
8
|
+
# is a to_s method though, much like Bisac::PO. Patches welcome.
|
9
|
+
#
|
10
|
+
# = Reading
|
11
|
+
#
|
12
|
+
# Each POA file can contain multiple POA's, so use pasrse_file() to iterate
|
13
|
+
# over them all.
|
14
|
+
#
|
15
|
+
# Bisac::POA.parse_file("filename.bsc") do |msg|
|
16
|
+
# puts msg.source_san
|
17
|
+
# puts msg.source_name
|
18
|
+
# puts msg.items.size
|
19
|
+
# ...
|
20
|
+
# end
|
21
|
+
class POA
|
22
|
+
|
23
|
+
# file header attributes
|
24
|
+
attr_accessor :source_san, :source_suffix, :source_name
|
25
|
+
attr_accessor :date, :filename, :format_version
|
26
|
+
attr_accessor :destination_san, :destination_suffix
|
27
|
+
attr_accessor :ack_type
|
28
|
+
|
29
|
+
# poa header attributes
|
30
|
+
attr_accessor :supplier_poa_number, :po_number
|
31
|
+
attr_accessor :customer_san, :customer_suffix
|
32
|
+
attr_accessor :supplier_san, :supplier_suffix
|
33
|
+
attr_accessor :poa_date, :currency, :po_date
|
34
|
+
attr_accessor :po_cancel_date, :po_type
|
35
|
+
attr_accessor :items
|
36
|
+
|
37
|
+
# creates a new Bisac::POA object
|
38
|
+
def initialize
|
39
|
+
@items = []
|
40
|
+
|
41
|
+
# default values
|
42
|
+
@cancellation_date = "000000"
|
43
|
+
@do_not_ship_before = "000000"
|
44
|
+
@backorder = true
|
45
|
+
end
|
46
|
+
|
47
|
+
# return all POs from a BISAC file
|
48
|
+
def self.parse_file(input, &block)
|
49
|
+
raise ArgumentError, 'no file provided' if input.nil?
|
50
|
+
raise ArgumentError, 'Invalid file' unless File.file?(input)
|
51
|
+
self.parse_string(File.read(input)) do |msg|
|
52
|
+
yield msg
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
# return all POs from a BISAC string
|
57
|
+
def self.parse_string(input, &block)
|
58
|
+
raise ArgumentError, 'no data provided' if input.nil?
|
59
|
+
data = []
|
60
|
+
input.split("\n").each do |l|
|
61
|
+
data << l
|
62
|
+
|
63
|
+
# yield each message found in the string. A line starting with
|
64
|
+
# 90 is the footer to a PO
|
65
|
+
if data.last[0,2] == "90"
|
66
|
+
msg = Bisac::POA.new
|
67
|
+
msg.build_message(data)
|
68
|
+
yield msg
|
69
|
+
data = []
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
# if we've got to the end of the file, and haven't hit a footer line yet, the file
|
74
|
+
# is probably malformed. Call build_message anyway, and let it detect any errors
|
75
|
+
if data.size > 0
|
76
|
+
msg = Bisac::POA.new
|
77
|
+
msg.build_message(data)
|
78
|
+
yield msg
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
def total_qty
|
83
|
+
@items.collect { |i| i.qty }.inject { |sum, x| sum ? sum+x : x}
|
84
|
+
end
|
85
|
+
|
86
|
+
def to_s
|
87
|
+
lines = []
|
88
|
+
|
89
|
+
# file header
|
90
|
+
line = " " * 80
|
91
|
+
line[0,2] = "00" # line type
|
92
|
+
line[2,5] = "00001" # line counter
|
93
|
+
line[7,7] = pad_trunc(@source_san, 7)
|
94
|
+
line[14,5] = pad_trunc(@source_suffix, 5)
|
95
|
+
line[19,13] = pad_trunc(@source_name, 13)
|
96
|
+
line[32,6] = pad_trunc(@date, 6)
|
97
|
+
line[38,22] = pad_trunc(@filename, 22)
|
98
|
+
line[60,3] = pad_trunc(@format_version, 3)
|
99
|
+
line[63,7] = pad_trunc(@destination_san, 7)
|
100
|
+
line[70,5] = pad_trunc(@destination_suffix, 5)
|
101
|
+
lines << line
|
102
|
+
|
103
|
+
# po header
|
104
|
+
lines << ""
|
105
|
+
lines.last << "10"
|
106
|
+
lines.last << "00002" # line counter
|
107
|
+
lines.last << " "
|
108
|
+
lines.last << @po_number.to_s.ljust(11, " ")
|
109
|
+
lines.last << " " # TODO
|
110
|
+
lines.last << pad_trunc(@source_san, 7)
|
111
|
+
lines.last << pad_trunc("",5) # TODO
|
112
|
+
lines.last << pad_trunc(@destination_san, 7)
|
113
|
+
lines.last << pad_trunc("",5) # TODO
|
114
|
+
lines.last << pad_trunc(@date, 6)
|
115
|
+
lines.last << pad_trunc(@cancellation_date,6)
|
116
|
+
lines.last << yes_no(@backorder)
|
117
|
+
lines.last << pad_trunc(@do_not_exceed_action,1)
|
118
|
+
lines.last << pad_trunc(@do_not_exceed_amount,7)
|
119
|
+
lines.last << pad_trunc(@invoice_copies,2)
|
120
|
+
lines.last << yes_no(@special_instructions)
|
121
|
+
lines.last << pad_trunc("",5) # TODO
|
122
|
+
lines.last << pad_trunc(@do_not_ship_before,6)
|
123
|
+
|
124
|
+
sequence = 3
|
125
|
+
@items.each_with_index do |item, idx|
|
126
|
+
item.line_item_number = idx + 1
|
127
|
+
item.sequence_number = sequence
|
128
|
+
lines += item.to_s.split("\n")
|
129
|
+
sequence += 3
|
130
|
+
end
|
131
|
+
|
132
|
+
# PO control
|
133
|
+
line = " " * 80
|
134
|
+
line[0,2] = "50"
|
135
|
+
line[2,5] = (lines.size + 1).to_s.rjust(5,"0") # line counter
|
136
|
+
line[8,12] = @po_number.to_s.ljust(13, " ")
|
137
|
+
line[20,5] = "00001" # number of POs in file
|
138
|
+
line[25,10] = @items.size.to_s.rjust(10,"0")
|
139
|
+
line[35,10] = total_qty.to_s.rjust(10,"0")
|
140
|
+
lines << line
|
141
|
+
|
142
|
+
# file trailer
|
143
|
+
line = " " * 80
|
144
|
+
line[0,2] = "90"
|
145
|
+
line[2,5] = (lines.size+1).to_s.rjust(5,"0") # line counter
|
146
|
+
line[7,20] = @items.size.to_s.rjust(13,"0")
|
147
|
+
line[20,5] = "00001" # total '10' (PO) records
|
148
|
+
line[25,10] = total_qty.to_s.rjust(10,"0")
|
149
|
+
line[35,5] = "00001" # number of '00'-'09' records
|
150
|
+
line[40,5] = "00001" # number of '10'-'19' records
|
151
|
+
line[55,5] = (@items.size * 3).to_s.rjust(5,"0") # number of '40'-'49' records
|
152
|
+
line[60,5] = "00000" # number of '50'-'59' records
|
153
|
+
line[45,5] = "00000" # number of '60'-'69' records
|
154
|
+
lines << line
|
155
|
+
|
156
|
+
lines.join("\n")
|
157
|
+
end
|
158
|
+
|
159
|
+
# Populate the current object with the message contained in data
|
160
|
+
#
|
161
|
+
# An array of lines making a complete, single POA message
|
162
|
+
#
|
163
|
+
def build_message(data)
|
164
|
+
raise Bisac::InvalidFileError, 'File appears to be too short' unless data.size >= 3
|
165
|
+
raise Bisac::InvalidFileError, 'Missing header information' unless data[0][0,2].eql?("02")
|
166
|
+
raise Bisac::InvalidFileError, 'Missing footer information' unless data[-1][0,2].eql?("91")
|
167
|
+
|
168
|
+
data.each do |line|
|
169
|
+
# ensure each line is at least 80 chars long
|
170
|
+
line = line.ljust(80)
|
171
|
+
|
172
|
+
case line[0,2]
|
173
|
+
when "02" # file header
|
174
|
+
self.source_san = line[7,7].strip
|
175
|
+
self.source_suffix = line[14,5].strip
|
176
|
+
self.source_name = line[19,13].strip
|
177
|
+
self.date = line[32,6].strip
|
178
|
+
self.filename = line[38,22].strip
|
179
|
+
self.format_version = line[60,3].strip
|
180
|
+
self.destination_san = line[63,7].strip
|
181
|
+
self.destination_suffix = line[70,5].strip
|
182
|
+
self.ack_type = line[75,1].strip
|
183
|
+
when "11" # poa header
|
184
|
+
self.supplier_poa_number = line[7,13].strip
|
185
|
+
self.po_number = line[20,13].strip
|
186
|
+
self.customer_san = line[33,7].strip
|
187
|
+
self.customer_suffix = line[40,5].strip
|
188
|
+
self.supplier_san = line[45,7].strip
|
189
|
+
self.supplier_suffix = line[52,5].strip
|
190
|
+
self.poa_date = line[57,6].strip
|
191
|
+
self.currency = line[63,3].strip
|
192
|
+
self.po_date = line[66,6].strip
|
193
|
+
self.po_cancel_date = line[72,6].strip
|
194
|
+
self.po_type = line[78,2].strip
|
195
|
+
when "40" # line item
|
196
|
+
item = Bisac::POALineItem.load_from_string(line)
|
197
|
+
self.items << item
|
198
|
+
when "41"
|
199
|
+
when "42"
|
200
|
+
when "59" # poa footer
|
201
|
+
# check the built objects match the file
|
202
|
+
when "91" # file footer
|
203
|
+
# check the built objects match the file
|
204
|
+
end
|
205
|
+
|
206
|
+
end
|
207
|
+
|
208
|
+
self
|
209
|
+
end
|
210
|
+
|
211
|
+
end
|
212
|
+
end
|
@@ -0,0 +1,81 @@
|
|
1
|
+
require 'bigdecimal'
|
2
|
+
|
3
|
+
module Bisac
|
4
|
+
|
5
|
+
# represents a single line on the purchase order ack
|
6
|
+
class POALineItem
|
7
|
+
|
8
|
+
attr_accessor :sequence_number, :supplier_poa_number
|
9
|
+
attr_accessor :line_item_number, :order_qty, :unit_price, :nett_price
|
10
|
+
attr_accessor :list_nett_indicator, :special_price, :discount
|
11
|
+
attr_accessor :shippable_qty, :status, :warehouse_status
|
12
|
+
attr_reader :isbn
|
13
|
+
|
14
|
+
# returns a new Bisac::POLineItem object using the data passed in as a string
|
15
|
+
# refer to the bisac spec for the expected format of the string
|
16
|
+
def self.load_from_string(data)
|
17
|
+
data = data.to_s.ljust(93)
|
18
|
+
raise ArgumentError, "POA Line Items must start with '40'" unless data[0,2] == "40"
|
19
|
+
|
20
|
+
item = POALineItem.new
|
21
|
+
|
22
|
+
item.sequence_number = data[2,5].to_i
|
23
|
+
item.supplier_poa_number = data[7,13].strip
|
24
|
+
item.line_item_number = data[20,10].strip
|
25
|
+
|
26
|
+
# prefer the 13 digit ISBN if it exists (it's a non-standard, Pacstream extension)
|
27
|
+
# fallback to the ISBN10
|
28
|
+
item.isbn = data[80,13].strip
|
29
|
+
item.isbn = data[30,10].strip if item.isbn.nil?
|
30
|
+
item.order_qty = data[40,5].to_i
|
31
|
+
item.unit_price = BigDecimal.new(data[45,8]) / 100
|
32
|
+
item.nett_price = BigDecimal.new(data[53,9]) / 100
|
33
|
+
item.list_nett_indicator = data[62,1]
|
34
|
+
item.special_price = data[63,1]
|
35
|
+
item.discount = BigDecimal.new(data[63,1]) / 100
|
36
|
+
item.shippable_qty = data[69,5].to_i
|
37
|
+
item.status = data[74,2].to_i
|
38
|
+
item.warehouse_status = data[76,2].to_i
|
39
|
+
|
40
|
+
return item
|
41
|
+
end
|
42
|
+
|
43
|
+
def isbn=(val)
|
44
|
+
if val == ""
|
45
|
+
@isbn = nil
|
46
|
+
elsif RBook::ISBN.valid_isbn10?(val)
|
47
|
+
@isbn = RBook::ISBN.convert_to_isbn13(val)
|
48
|
+
else
|
49
|
+
@isbn = val
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
# is the isbn for this product valid?
|
54
|
+
def isbn?
|
55
|
+
RBook::ISBN.valid_isbn13?(@isbn || "")
|
56
|
+
end
|
57
|
+
|
58
|
+
def isbn10
|
59
|
+
if isbn?
|
60
|
+
RBook::ISBN.convert_to_isbn10(@isbn)
|
61
|
+
else
|
62
|
+
@isbn
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
def status_text
|
67
|
+
case self.status
|
68
|
+
when 1 then "Title Shipped As Ordered"
|
69
|
+
when 3 then "Cancelled: Future Publication"
|
70
|
+
when 6 then "Cancelled: Out of Stock"
|
71
|
+
when 7 then "Backordered: Out of Stock"
|
72
|
+
when 9 then "Partial Ship: Cancel Rest"
|
73
|
+
when 10 then "Partial Ship: Backorder Rest"
|
74
|
+
when 20 then "Cancelled: Not Carried"
|
75
|
+
when 28 then "Cancelled: Out of Print"
|
76
|
+
else
|
77
|
+
"UNKNOWN"
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
@@ -0,0 +1,174 @@
|
|
1
|
+
module Bisac
|
2
|
+
|
3
|
+
# Class to represent a single product line in a Bisac File. See
|
4
|
+
# Bisac::Message for basic usage instructions.
|
5
|
+
class Product
|
6
|
+
|
7
|
+
attr_reader :isbn, :title, :author, :price, :pubdate, :publisher
|
8
|
+
attr_reader :imprint, :volumes, :edition, :binding, :volume, :status
|
9
|
+
|
10
|
+
# Creates a new product object with the requested ISBN. Must be a 10 digit ISBN.
|
11
|
+
def initialize(isbn)
|
12
|
+
raise ArgumentError, 'isbn must 10 chars or less' if isbn.to_s.length > 10
|
13
|
+
|
14
|
+
@isbn = isbn.to_s
|
15
|
+
@title = ""
|
16
|
+
@author = ""
|
17
|
+
@price = ""
|
18
|
+
@pubdate = ""
|
19
|
+
@publisher = ""
|
20
|
+
@imprint = ""
|
21
|
+
@volumes = ""
|
22
|
+
@edition = ""
|
23
|
+
@binding = ""
|
24
|
+
@volume = ""
|
25
|
+
@status = ""
|
26
|
+
end
|
27
|
+
|
28
|
+
# sets the products author. Maximum of 30 chars.
|
29
|
+
def author=(author)
|
30
|
+
@author = author.to_s
|
31
|
+
end
|
32
|
+
|
33
|
+
# sets the products binding. Maximum of 2 chars.
|
34
|
+
def binding=(binding)
|
35
|
+
@binding = binding.to_s
|
36
|
+
end
|
37
|
+
|
38
|
+
# sets the products edition number. Maximum of 3 chars.
|
39
|
+
def edition=(edition)
|
40
|
+
@edition = edition.to_s
|
41
|
+
end
|
42
|
+
|
43
|
+
# takes a single line from a BISAC file and attempts to convert
|
44
|
+
# it to a Product object
|
45
|
+
def self.from_string(s)
|
46
|
+
s = s.to_s
|
47
|
+
return nil if s.length < 259
|
48
|
+
product = self.new(s[0,10])
|
49
|
+
product.title = s[15,30].strip
|
50
|
+
product.author = s[46,30].strip
|
51
|
+
product.price = s[79,7].strip if s[79,7].strip.match(/\A\d{0,7}\Z/)
|
52
|
+
product.pubdate = s[87,6].strip if s[87,6].strip.match(/\A\d{6}\Z/)
|
53
|
+
product.publisher = s[94,10].strip
|
54
|
+
product.imprint = s[105,14].strip
|
55
|
+
product.volumes = s[120,3].strip
|
56
|
+
product.edition = s[124,2].strip
|
57
|
+
product.binding = s[127,2].strip
|
58
|
+
product.volume = s[130,3].strip
|
59
|
+
product.status = s[153,3].strip
|
60
|
+
return product
|
61
|
+
end
|
62
|
+
|
63
|
+
# Sets the products imprint. Maximum of 14 chars.
|
64
|
+
def imprint=(imprint)
|
65
|
+
@imprint = imprint.to_s
|
66
|
+
end
|
67
|
+
|
68
|
+
# Sets the price imprint. Maximum of 7 chars. Must by a whole
|
69
|
+
# number (represent price in cents).
|
70
|
+
def price=(price)
|
71
|
+
unless price.to_s.match(/\A\d{0,7}\Z/)
|
72
|
+
raise ArgumentError, 'price should be a whole number with no more than 7 digits. (price in cents)'
|
73
|
+
end
|
74
|
+
@price = price.to_s
|
75
|
+
end
|
76
|
+
|
77
|
+
# Sets the products pubdate. Must be in the form YYMMDD
|
78
|
+
def pubdate=(pubdate)
|
79
|
+
unless pubdate.to_s.match(/\A\d{6}\Z/)
|
80
|
+
raise ArgumentError, 'pubdate should be a date in the form YYMMDD.'
|
81
|
+
end
|
82
|
+
@pubdate = pubdate.to_s
|
83
|
+
end
|
84
|
+
|
85
|
+
# sets the products publisher. Maximum of 10 chars.
|
86
|
+
def publisher=(publisher)
|
87
|
+
@publisher = publisher.to_s
|
88
|
+
end
|
89
|
+
|
90
|
+
# sets the products status code. Maximum of 3 chars.
|
91
|
+
def status=(status)
|
92
|
+
@status = status.to_s
|
93
|
+
end
|
94
|
+
|
95
|
+
# sets the products title. Maximum of 30 chars.
|
96
|
+
def title=(title)
|
97
|
+
@title = title.to_s
|
98
|
+
end
|
99
|
+
|
100
|
+
# Returns the product as a single line ready for inserting into a BISAC file.
|
101
|
+
# Doesn't have a \n on the end
|
102
|
+
def to_s
|
103
|
+
content = ""
|
104
|
+
content << @isbn[0,10].ljust(10) # 10 digit isbn
|
105
|
+
content << "1"
|
106
|
+
content << "N"
|
107
|
+
content << "N"
|
108
|
+
content << "B"
|
109
|
+
content << "N"
|
110
|
+
content << @title[0,30].ljust(30)
|
111
|
+
content << "N"
|
112
|
+
content << @author[0,30].ljust(30)
|
113
|
+
content << "N"
|
114
|
+
content << "A" # author role
|
115
|
+
content << "N"
|
116
|
+
content << @price[0,7].rjust(7,"0") # current price
|
117
|
+
content << "N"
|
118
|
+
content << @pubdate[0,6].ljust(6) # published date
|
119
|
+
content << "N"
|
120
|
+
content << @publisher[0,10].ljust(10) # publisher
|
121
|
+
content << "N"
|
122
|
+
content << @imprint[0,14].ljust(14) #imprint
|
123
|
+
content << "N"
|
124
|
+
content << @volumes[0,3].rjust(3,"0") # volumes included in this isbn
|
125
|
+
content << "N"
|
126
|
+
content << @edition[0,2].rjust(2,"0") # edition
|
127
|
+
content << "N"
|
128
|
+
content << @binding[0,2].rjust(2,"0") # binding
|
129
|
+
content << "N"
|
130
|
+
content << @volume[0,3].rjust(3,"0") # volume number
|
131
|
+
content << "N"
|
132
|
+
content << "0000000" # new price
|
133
|
+
content << "N"
|
134
|
+
content << "000000" # new price effective date
|
135
|
+
content << "N"
|
136
|
+
content << " " # audience type
|
137
|
+
content << "N"
|
138
|
+
content << @status[0,3].rjust(3) # status
|
139
|
+
content << "N"
|
140
|
+
content << " " # available date. only use for status' like NYP
|
141
|
+
content << "N"
|
142
|
+
content << " " # alternate isbn
|
143
|
+
content << "N"
|
144
|
+
content << "999999" # out of print date. only use for status == OP
|
145
|
+
content << "N"
|
146
|
+
content << " " # geographic restrictions
|
147
|
+
content << "N"
|
148
|
+
content << " " # library of congress catalogue number
|
149
|
+
content << "N"
|
150
|
+
content << "".ljust(40) # series title
|
151
|
+
content << "N"
|
152
|
+
content << "0" # price code for current price
|
153
|
+
content << "N"
|
154
|
+
content << "0" # price code for new price
|
155
|
+
content << "N"
|
156
|
+
content << "0000000" # freight pass through price
|
157
|
+
content << "N"
|
158
|
+
content << "000000" # new freight pass through price
|
159
|
+
content << "00000" # last changed date
|
160
|
+
|
161
|
+
return content
|
162
|
+
end
|
163
|
+
|
164
|
+
# sets the products volume number. Maximum of 3 chars.
|
165
|
+
def volume=(volume)
|
166
|
+
@volume = volume.to_s
|
167
|
+
end
|
168
|
+
|
169
|
+
# sets the number of volumes in the set this product belongs to. Maximum of 3 chars.
|
170
|
+
def volumes=(volumes)
|
171
|
+
@volumes = volumes.to_s
|
172
|
+
end
|
173
|
+
end
|
174
|
+
end
|