ข้อกำหนดเปิด · เวอร์ชัน 1.0

OpenCraft Beverage Line Format

BLF v1.0

ภาษากลางสำหรับรายการเครื่องดื่ม — คนอ่านเข้าใจ เครื่องแยกข้อมูลได้ และใช้ได้กับเครื่องดื่มทุกประเภท

หนึ่งบรรทัด หนึ่งสินค้าบังคับเฉพาะชื่อผู้ผลิตใช้ได้กับเครื่องดื่มทุกประเภทมีเวอร์ชัน · v1.0

เผยแพร่โดย OpenCraft · 3 พฤศจิกายน 2026

BLF v1.0 · ตรวจสอบสด

วางหรือพิมพ์ BLF รูปแบบย่อหนึ่งเรกคอร์ดต่อบรรทัด การ์ดจะอัปเดตขณะพิมพ์

รายการที่แยกแล้ว
01

ทำไมต้องมี BLF

รายการเครื่องดื่มเดินทางระหว่างผู้ผลิต ผู้จัดจำหน่าย บาร์ และลูกค้าทุกวัน — ผ่านแชท เมนูพิมพ์ สเปรดชีต และใบสั่งซื้อ แต่ละฝ่ายจัดรูปแบบต่างกัน คนรับจึงต้องพิมพ์ข้อมูลซ้ำใหม่ทุกครั้ง

BLF (Beverage Line Format) คือมาตรฐานขนาดเล็กที่ OpenCraft เสนอ หนึ่งบรรทัดบอกหนึ่งเครื่องดื่ม: ใครผลิต ชื่ออะไร สไตล์ไหน แรงแค่ไหน บรรจุอย่างไร ต้นทุนเท่าไหร่ เหลือกี่ชิ้น และขายเท่าไหร่ คนอ่านเข้าใจได้ทันที และเครื่องแยกข้อมูลได้ด้วยกฎที่ไม่ต้องเดา

คนต้องอ่านได้ก่อน

บรรทัดหนึ่งต้องเข้าใจได้โดยบาร์เทนเดอร์หรือผู้ซื้อ โดยไม่ต้องมีเครื่องมือ ไม่มี XML ไม่มีออบเจ็กต์ซ้อนกัน — แค่ข้อความธรรมดา

เครื่องแยกได้ตามกฎ

ทุกโทเคนมีคำนำ คำต่อท้าย หรือหน่วยที่ชัดเจน เครื่องแยกข้อมูลจึงไม่ต้องเดาขอบเขตของฟิลด์

ทุกฟิลด์ไม่บังคับ

บังคับเฉพาะชื่อผู้ผลิต รายการสามารถเริ่มจากข้อมูลน้อยแล้วค่อยเพิ่มความสมบูรณ์ภายหลังได้โดยไม่พัง

คำศัพท์ขยายได้

ประเภทบรรจุภัณฑ์และสไตล์เป็นรายการควบคุมที่เพิ่มได้ BLF เป็นรูปแบบของเครื่องดื่ม ไม่ใช่ของเบียร์เท่านั้น

02

โครงสร้างของหนึ่งบรรทัด

อ่านจากซ้ายไปขวา ลำดับและชื่อผู้ผลิตคือส่วนหัวของบรรทัด หลังจากนั้นคือพารามิเตอร์ที่ระบุด้วยสัญลักษณ์ หน่วย หรือรหัสสกุลเงินของตัวเอง ไม่ได้อ้างอิงตำแหน่ง

แพตเทิร์น
<order>. <producer>: <beverage>, <style> <abv> <size> <container> <currency> @<cost> [V<vat>] [@<cost-incl>v] x<stock> / <price> [<price-incl>v]
ตัวอย่าง
2. Sample Brewing: Sea Breeze, Pale Ale 5% 440ml Can THB @180 V7.0 @192.6v x8 /320 342.4v
โทเคนฟิลด์ความหมาย
2. ลำดับ (Order Number) ดัชนีในรายการ ไม่ใช่รหัสสินค้า
Sample Brewing ชื่อผู้ผลิต (Producer Name) ฟิลด์เดียวที่บังคับ สิ้นสุดที่เครื่องหมาย :
Sea Breeze ชื่อเครื่องดื่ม (Beverage Name) อยู่หลังเครื่องหมาย : และก่อนจุลภาคตัวสุดท้าย
Pale Ale สไตล์ (Style) อยู่หลังจุลภาคตัวสุดท้ายในส่วนหัว
5% ABV แอลกอฮอล์โดยปริมาตร รองรับทศนิยม
440ml ขนาดบรรจุ (Container Size) หน่วย ml, cl, L หรือ oz
Can ประเภทบรรจุ (Container Type) Can, Bottle, Keg, Cask, Growler และเพิ่มเติมได้ในอนาคต
THB สกุลเงิน (Currency) รหัส ISO 4217 มีผลกับทุกจำนวนในเรกคอร์ด
@180 ต้นทุน (ไม่รวม VAT) ต่อหนึ่งหน่วยบรรจุ ในสกุลเงินที่ระบุหรือตกลงกัน
V7.0 อัตรา VAT เปอร์เซ็นต์ มีผลกับทั้งเรกคอร์ด
@192.6v ต้นทุน (รวม VAT) คำต่อท้าย v หมายถึงจำนวนที่รวม VAT แล้ว
x8 จำนวนคงเหลือ (Stock Amount) จำนวนเต็มของหน่วยบรรจุ
/320 ราคาขายปลีก (ไม่รวม VAT) ต่อหนึ่งหน่วยบรรจุ
342.4v ราคาขายปลีก (รวม VAT) คำต่อท้าย v หมายถึงจำนวนที่รวม VAT แล้ว

พารามิเตอร์สลับลำดับได้: 5% 440ml Can กับ 440ml 5% Can ให้ความหมายเดียวกัน ลำดับด้านบนคือลำดับมาตรฐานที่แนะนำ ลำดับก็ไม่บังคับเช่นกัน — ถ้าไม่ระบุ บรรทัดจะเริ่มที่ชื่อผู้ผลิต

03

ตารางฟิลด์

ทุกฟิลด์เป็นตัวเลือก ยกเว้นชื่อผู้ผลิต ลำดับด้านล่างคือลำดับมาตรฐานที่แนะนำสำหรับทั้งรูปแบบย่อและรูปแบบขยาย

ฟิลด์บังคับไวยากรณ์หมายเหตุ
ลำดับ (Order Number) ไม่บังคับ 1. ดัชนีของบรรทัด ต้องไม่ซ้ำกันภายในรายการที่เผยแพร่ ใช้เพื่อการแสดงผลเท่านั้น ไม่ใช่รหัสสินค้าถาวร
ชื่อผู้ผลิต (Producer Name) บังคับ Sample Brewing: แบรนด์ โรงเบียร์ หรือผู้ผลิต อาจมีจุลภาคได้ ฟิลด์สิ้นสุดที่เครื่องหมาย :
ชื่อเครื่องดื่ม (Beverage Name) ไม่บังคับ Sea Breeze, ชื่อสินค้า แยกจากสไตล์ที่จุลภาคตัวสุดท้ายในส่วนหัว
สไตล์ (Style) ไม่บังคับ Pale Ale ข้อความอิสระใน v1.0 กำลังวางแผนทะเบียนสไตล์ร่วมกัน
ABV ไม่บังคับ 5% แอลกอฮอล์โดยปริมาตรเป็นเปอร์เซ็นต์ รองรับทศนิยม และ 0% ถือว่าถูกต้อง
ขนาดบรรจุ (Container Size) ไม่บังคับ 440ml ตัวเลขพร้อมหน่วย ml, cl, L หรือ oz โดย oz คือ US fluid ounce (29.5735 ml) แปลงเป็นมิลลิลิตรเมื่อต้องเปรียบเทียบ
ประเภทบรรจุ (Container Type) ไม่บังคับ Can Can, Bottle, Keg, Cask, Growler และเพิ่มเติมได้
สกุลเงิน (Currency) ไม่บังคับ THB รหัส ISO 4217 มีผลกับทุกจำนวนในเรกคอร์ด ถ้าไม่ระบุจะใช้สกุลเงินที่ตกลงกันของรายการ
ต้นทุน (ไม่รวม VAT) ไม่บังคับ @180 ต้นทุนต่อหนึ่งหน่วยบรรจุ ในสกุลเงินที่ระบุหรือตกลงกัน คำว่า Cost เป็นคำย่อของฟิลด์นี้
อัตรา VAT ไม่บังคับ V7.0 เปอร์เซ็นต์ มีผลกับทุกจำนวนในเรกคอร์ด ในประเทศไทยคือ 7.0
ต้นทุน (รวม VAT) ไม่บังคับ @192.6v ระบุตรงหรือคำนวณก็ได้ ต้องมีคำต่อท้าย v
จำนวนคงเหลือ (Stock Amount) ไม่บังคับ x8 จำนวนเต็มของหน่วยบรรจุ — กระป๋อง ขวด หรือถัง ไม่ใช่จำนวนที่เสิร์ฟ
ราคาขายปลีก (ไม่รวม VAT) ไม่บังคับ /320 ราคาต่อหนึ่งหน่วยบรรจุ คำว่า Retail Price เป็นคำย่อของฟิลด์นี้
ราคาขายปลีก (รวม VAT) ไม่บังคับ 342.4v ระบุตรงหรือคำนวณก็ได้ ต้องมีคำต่อท้าย v

จำนวนที่ไม่มีคำต่อท้าย v ถือว่าไม่รวม VAT ถ้าไม่ระบุสกุลเงิน จำนวนจะใช้สกุลเงินที่ตกลงกันของรายการ หนึ่งเรกคอร์ดไม่ควรมีหลายสกุลเงิน

04

สองรูปแบบที่เทียบเท่ากัน

รูปแบบย่อเหมาะกับข้อความแชทและป้ายชั้นวาง รูปแบบขยายเหมาะกับการตรวจทาน แก้ไข และควบคุมเวอร์ชัน ทั้งสองรูปแบบบรรจุข้อมูลชุดเดียวกันและแปลงกลับไปมาได้โดยไม่สูญเสียข้อมูล

รูปแบบย่อ — หนึ่งบรรทัดต่อสินค้า

ลำดับและชื่อผู้ผลิตนำหน้าบรรทัด ตามด้วยพารามิเตอร์ที่ระบุด้วยสัญลักษณ์ หน่วย หรือรหัสสกุลเงิน ISO ตำแหน่งไม่มีความหมาย ลำดับมาตรฐานจึงเป็นเพียงธรรมเนียมเพื่อให้อ่านง่าย ไม่ใช่ข้อบังคับในการแยกข้อมูล

1. Mock Brewery: Night Owl, Chocolate Milk Stout 5.6% 12oz Bottle THB @120 x5 / 260
2. Sample Brewing: Sea Breeze, Pale Ale 5% 440ml Can THB @180 V7.0 @192.6v x8 / 320 342.4v
3. Demo Lagerhaus, Munich Helles 5.5%
  • พารามิเตอร์ถูกตรวจจับด้วย @, V, x, / และหน่วยอย่าง %, ml และ L
  • จำนวนเงินใช้จุดทศนิยม ห้ามใช้จุลภาคคั่นหลักพันภายในจำนวน
  • หนึ่งเรกคอร์ดต่อหนึ่งบรรทัด บรรทัดว่างคั่นระหว่างเรกคอร์ดได้
  • คำสำคัญและหน่วยไม่แยกตัวพิมพ์ใหญ่เล็ก การเขียนตัวพิมพ์ตามตัวอย่างคือรูปแบบมาตรฐาน
  • สกุลเงินใช้รหัส ISO 4217 เช่น THB วางไว้ก่อนจำนวนแรก
  • เว้นวรรคหลัง @ หรือ / ได้ จึงเขียน @180 หรือ @ 180 ก็ให้ผลเดียวกัน
  • หนึ่งบรรทัดต่อหนึ่งรูปแบบบรรจุที่ขายจริง — เบียร์เดียวกันขนาด 330ml กับ 500ml คือสองบรรทัด
  • ถ้าชื่อมีข้อความที่หน้าตาเหมือนโทเคน หรือสไตล์มีจุลภาค ให้ใช้รูปแบบขยายแทน

รูปแบบขยาย — หนึ่งฟิลด์ต่อบรรทัด

เขียน ชื่อฟิลด์: ค่า แล้วคั่นเรกคอร์ดด้วยบรรทัดว่าง รูปแบบนี้ตรวจทาน diff และกรอกด้วยมือได้ง่ายกว่า และปลอดภัยที่สุดเมื่อชื่อมีจุลภาค ตัวเลข หรือหน่วย

Order Number: 2
Producer Name: Sample Brewing
Beverage Name: Sea Breeze
Style: Pale Ale
ABV: 5%
Container Size: 440ml
Container Type: Can
Currency: THB
Cost (VAT Excluded): 180
VAT: 7.0%
Cost (VAT Included): 192.6
Stock Amount: 8
Retail Price (VAT Excluded): 320
Retail Price (VAT Included): 342.4
  • ชื่อฟิลด์ไม่แยกตัวพิมพ์ใหญ่เล็ก แต่แนะนำให้ใช้รูปแบบมาตรฐานเมื่อเผยแพร่
  • Cost และ Retail Price เป็นคำยอมรับได้ของฟิลด์ไม่รวม VAT
  • เครื่องอ่านควรข้ามฟิลด์ที่ไม่รู้จัก ไม่ใช่ปฏิเสธทั้งเรกคอร์ด
  • บรรทัดว่างเป็นตัวจบเรกคอร์ด ฟิลด์ที่ไม่ระบุถือว่าไม่มีข้อมูล
05

กฎ VAT

จำนวนเงินเป็นแบบไม่รวม VAT โดยค่าเริ่มต้น เพราะเป็นวิธีที่รายการซื้อขายส่วนใหญ่ใช้กัน คำต่อท้าย v หมายถึงจำนวนนั้นรวม VAT แล้ว ทั้งสองแบบอยู่ร่วมกันในเรกคอร์ดเดียวได้

ค่าเริ่มต้นคือไม่รวม VAT

จำนวนใดที่ไม่มีคำต่อท้าย v ถือว่าไม่รวม VAT ใช้กับทั้งต้นทุนและราคาขายปลีก

v หมายถึงรวม VAT

ต่อท้ายจำนวนโดยตรง: @192.6v หรือ /342.4v และยอมรับจำนวนเปล่าที่ลงท้ายด้วย v ต่อจากจำนวนไม่รวม VAT ที่เกี่ยวข้องด้วย แต่รูปแบบนั้นอ้างอิงตำแหน่ง สำหรับรายการใหม่แนะนำให้ต่อ v กับสัญลักษณ์ @ หรือ / โดยตรง

V<rate> ประกาศอัตรา

อัตรามีผลกับทุกจำนวนในเรกคอร์ด ในประเทศไทยอัตรามาตรฐานคือ V7.0

คำนวณจำนวนรวม VAT

เมื่อรู้จำนวนไม่รวม VAT และอัตรา ให้คำนวณ จำนวน × (1 + อัตรา / 100) แล้วปัดครึ่งขึ้นที่ทศนิยมสองตำแหน่ง

ตรวจความสอดคล้อง

ถ้าเรกคอร์ดระบุทั้งจำนวนไม่รวมและรวม VAT ค่าทั้งคู่ควรต่างกันไม่เกิน 0.01 หากต่างมากกว่านั้นถือเป็นข้อมูลผิด

ไม่จำเป็นต้องมีอัตราเสมอ

จำนวนที่ลงท้ายด้วย v โดยไม่ระบุอัตราได้ หมายความว่าจำนวนนั้นรวม VAT แต่ไม่ทราบอัตรา — ห้ามเดา

ตัวอย่างการคำนวณ: เรกคอร์ดที่ 2

Currency: THB
Cost (VAT Excluded): 180
VAT: 7.0%
Cost (VAT Included): 192.6
Retail Price (VAT Excluded): 320
Retail Price (VAT Included): 342.4

180 × 1.07 = 192.60 และ 320 × 1.07 = 342.40 ทั้งคู่ลงตัวที่ทศนิยมสองตำแหน่ง

06

ตัวอย่างสมมติทั้งหก

เรกคอร์ดทั้งหกด้านล่างเป็นข้อมูลสมมติ ใช้ทดสอบทุกส่วนของรูปแบบ แต่ละการ์ดแสดงรูปแบบย่อและฟิลด์ในรูปแบบขยาย

Mock Brewery — Night Owl

1. Mock Brewery: Night Owl, Chocolate Milk Stout 5.6% 12oz Bottle THB @120 x5 / 260
ฟิลด์แบบขยาย
ลำดับ
1
ชื่อผู้ผลิต
Mock Brewery
ชื่อเครื่องดื่ม
Night Owl
สไตล์
Chocolate Milk Stout
ABV
5.6%
ขนาดบรรจุ
12oz
ประเภทบรรจุ
Bottle
สกุลเงิน
THB
ต้นทุน (ไม่รวม VAT)
120
จำนวนคงเหลือ
5
ราคาขายปลีก (ไม่รวม VAT)
260

ไม่มีการระบุ VAT จำนวนเงินทั้งสองจึงเป็นแบบไม่รวม VAT ตามค่าเริ่มต้น ขนาด 12oz แสดงการรองรับหน่วย oz

Sample Brewing — Sea Breeze

2. Sample Brewing: Sea Breeze, Pale Ale 5% 440ml Can THB @180 V7.0 @192.6v x8 / 320 342.4v
ฟิลด์แบบขยาย
ลำดับ
2
ชื่อผู้ผลิต
Sample Brewing
ชื่อเครื่องดื่ม
Sea Breeze
สไตล์
Pale Ale
ABV
5%
ขนาดบรรจุ
440ml
ประเภทบรรจุ
Can
สกุลเงิน
THB
ต้นทุน (ไม่รวม VAT)
180
VAT
7.0%
ต้นทุน (รวม VAT)
192.6
จำนวนคงเหลือ
8
ราคาขายปลีก (ไม่รวม VAT)
320
ราคาขายปลีก (รวม VAT)
342.4

เรกคอร์ดสมมติที่สมบูรณ์ที่สุด: มีทั้งอัตรา จำนวนคงเหลือ และต้นทุนกับราคาทั้งแบบไม่รวมและรวม VAT

Sample Brewing — Sunbeam

3. Sample Brewing: Sunbeam, Fruity Pale Ale 4.8% 440ml Can THB @150v x10 / 280v
ฟิลด์แบบขยาย
ลำดับ
3
ชื่อผู้ผลิต
Sample Brewing
ชื่อเครื่องดื่ม
Sunbeam
สไตล์
Fruity Pale Ale
ABV
4.8%
ขนาดบรรจุ
440ml
ประเภทบรรจุ
Can
สกุลเงิน
THB
ต้นทุน (รวม VAT)
150
จำนวนคงเหลือ
10
ราคาขายปลีก (รวม VAT)
280

จำนวนแบบรวม VAT โดยไม่ระบุอัตราถือว่าถูกต้อง อัตราจะคงเป็นไม่ทราบ ไม่ใช่เดา

Demo Lagerhaus — Munich Helles

4. Demo Lagerhaus, Munich Helles 5.5%
ฟิลด์แบบขยาย
ลำดับ
4
ชื่อผู้ผลิต
Demo Lagerhaus
สไตล์
Munich Helles
ABV
5.5%

ไม่มีเครื่องหมาย : — จุลภาคแยกผู้ผลิตออกจากสไตล์ และไม่มีชื่อเครื่องดื่ม

Mock Cocktail Co. — Citrus Club

5. Mock Cocktail Co.: Citrus Club, Pre-made Cocktail 330ml 5%
ฟิลด์แบบขยาย
ลำดับ
5
ชื่อผู้ผลิต
Mock Cocktail Co.
ชื่อเครื่องดื่ม
Citrus Club
สไตล์
Pre-made Cocktail
ขนาดบรรจุ
330ml
ABV
5%

ขนาดมาก่อน ABV — พารามิเตอร์สลับลำดับได้ และนี่คือเครื่องดื่มที่ไม่ใช่เบียร์ในรูปแบบเดียวกัน

Demo Brewing — Love, Laugh, and Sorrow

6. Demo Brewing: Love, Laugh, and Sorrow, QDH Hazy IPA 7.5% 20L Keg
ฟิลด์แบบขยาย
ลำดับ
6
ชื่อผู้ผลิต
Demo Brewing
ชื่อเครื่องดื่ม
Love, Laugh, and Sorrow
สไตล์
QDH Hazy IPA
ABV
7.5%
ขนาดบรรจุ
20L
ประเภทบรรจุ
Keg

จุลภาคตัวสุดท้ายแยกชื่อเครื่องดื่มออกจากสไตล์ จุลภาคภายในชื่อเครื่องดื่มจึงปลอดภัย

07

สำหรับนักพัฒนา

BLF ถูกออกแบบให้แยกโทเคนด้วย regular expression ไม่กี่ตัวและกฎสั้น ๆ โดยไม่ต้องมีไลบรารีเฉพาะ

โทเคนแพตเทิร์น
ลำดับ ^\d+\.
ABV \d+(?:\.\d+)?%
ขนาดบรรจุ \d+(?:\.\d+)?(?:ml|cl|L|oz)\b
ประเภทบรรจุ \b(?:Bottle|Can|Keg|Cask|Growler)\b
สกุลเงิน (ISO 4217) \b[A-Z]{3}\b
ต้นทุน (ไม่รวม VAT) @\d+(?:\.\d+)?
อัตรา VAT V\d+(?:\.\d+)?
ต้นทุน (รวม VAT) @?\d+(?:\.\d+)?v
จำนวนคงเหลือ x\d+
ราคาขายปลีก (ไม่รวม VAT) /\d+(?:\.\d+)?
ราคาขายปลีก (รวม VAT) /?\d+(?:\.\d+)?v

การแยกรูปแบบย่อ

  1. ตัดช่องว่างหัวท้ายและข้ามบรรทัดว่าง หนึ่งบรรทัดที่ไม่ว่างคือหนึ่งเรกคอร์ด
  2. อ่านลำดับที่ต้นบรรทัดถ้ามี ด้วยแพตเทิร์น ^\d+\.
  3. ถ้ามีรหัส ISO 4217 เดี่ยว ๆ (เช่น THB) ในส่วนพารามิเตอร์ ให้บันทึกเป็นสกุลเงินของทุกจำนวนในเรกคอร์ด
  4. แยกส่วนหัวที่เครื่องหมาย : ตัวแรก ถ้าไม่มี : ให้แยกที่จุลภาคตัวแรก: ก่อนหน้าคือผู้ผลิต หลังคือสไตล์
  5. ในส่วนหัวหลัง : ให้แยกที่จุลภาคตัวสุดท้าย: ก่อนหน้าคือชื่อเครื่องดื่ม หลังคือสไตล์ ถ้าไม่มีจุลภาค ทั้งส่วนคือชื่อเครื่องดื่ม
  6. สแกนข้อความที่เหลือเพื่อหาพารามิเตอร์ด้วยแพตเทิร์นของแต่ละโทเคน ลำดับของพารามิเตอร์ไม่สำคัญ
  7. แปลงขนาดบรรจุเป็นมิลลิลิตรเมื่อต้องเปรียบเทียบ (1 oz = 29.5735 ml) แต่เก็บหน่วยเดิมไว้แสดงผล
  8. ใช้กฎ VAT และปฏิเสธเรกคอร์ดที่จำนวนไม่รวมและรวม VAT ต่างกันเกิน 0.01

Reference parser (JavaScript)

parser ตัวเดียวกับที่ขับเคลื่อนตัวตรวจสอบสดด้านบน ไม่มี dependency และคัดลอกไปใช้ต่อในโปรเจกต์ได้

blf-parser.js
/**
 * BLF v1.0 reference parser — compact form.
 *
 * Usage:
 *   import { parseBlf } from './blf-parser.js';
 *   const { results, itemCount, warningCount, errorCount } = parseBlf(text);
 *
 * Each non-empty line is one record. The order number is optional; the
 * Producer Name is the only required field. Parameters are recognised by
 * their sigil or unit, so they may appear in any order. An optional ISO 4217
 * currency code (for example THB) applies to every amount in the record.
 */

const CONTAINER_TYPES = ['bottle', 'can', 'keg', 'cask', 'growler'];

// Use the runtime's ISO 4217 list when available; otherwise accept any
// uppercase three-letter token and let the caller validate it.
const CURRENCY_CODES =
  typeof Intl !== 'undefined' && typeof Intl.supportedValuesOf === 'function'
    ? new Set(Intl.supportedValuesOf('currency'))
    : null;

const isCurrencyCode = (token) =>
  /^[A-Z]{3}$/.test(token) && (CURRENCY_CODES === null || CURRENCY_CODES.has(token));

const round2 = (value) => Math.round((value + Number.EPSILON) * 100) / 100;

const normalizeContainer = (token) => {
  const match = CONTAINER_TYPES.find((type) => type === token.toLowerCase());
  return match ? match.charAt(0).toUpperCase() + match.slice(1) : null;
};

/**
 * Classify a whitespace-separated parameter token, or return null when the
 * token is not a parameter.
 */
const classifyToken = (token) => {
  if (/^@\d+(?:\.\d+)?v?$/i.test(token)) return 'cost';
  if (/^\/\d+(?:\.\d+)?v?$/i.test(token)) return 'retail';
  if (/^V\d+(?:\.\d+)?%?$/i.test(token)) return 'vat';
  if (/^x\d+$/i.test(token)) return 'stock';
  if (/^\d+(?:\.\d+)?%$/.test(token)) return 'abv';
  if (/^\d+(?:\.\d+)?(?:ml|cl|L|oz)$/i.test(token)) return 'size';
  if (normalizeContainer(token)) return 'container';
  if (isCurrencyCode(token)) return 'currency';
  if (/^\d+(?:\.\d+)?v$/i.test(token)) return 'inc';
  return null;
};

/**
 * Split the text after the order number into a name head and parameter
 * tokens. A token only starts the parameter section if it is a strong
 * parameter (not a bare container word), so names containing "Can" or
 * "Cask" survive better.
 */
const splitHeadAndParams = (text) => {
  const tokens = text.split(/\s+/).filter(Boolean);
  let firstParam = tokens.length;

  for (let i = 0; i < tokens.length; i += 1) {
    const kind = classifyToken(tokens[i]);
    if (kind && kind !== 'container') {
      firstParam = i;
      break;
    }
  }

  return {
    head: tokens.slice(0, firstParam).join(' '),
    params: tokens.slice(firstParam),
  };
};

/**
 * Parse the head into producer, beverage, and style.
 * With a colon:   producer : beverage , style
 * Without colon:  producer , style
 */
const parseHead = (head) => {
  const colon = head.indexOf(':');

  if (colon !== -1) {
    const producer = head.slice(0, colon).trim();
    const tail = head.slice(colon + 1).trim();
    const lastComma = tail.lastIndexOf(',');

    return {
      producer,
      beverage: lastComma === -1 ? tail || null : tail.slice(0, lastComma).trim() || null,
      style: lastComma === -1 ? null : tail.slice(lastComma + 1).trim() || null,
    };
  }

  const firstComma = head.indexOf(',');
  if (firstComma !== -1) {
    return {
      producer: head.slice(0, firstComma).trim(),
      beverage: null,
      style: head.slice(firstComma + 1).trim() || null,
    };
  }

  return { producer: head.trim(), beverage: null, style: null };
};

/**
 * Parse one compact BLF line.
 * Returns { lineNumber, raw, ok, item, warnings, error }.
 */
export const parseBlfLine = (raw, lineNumber = 1) => {
  const line = String(raw ?? '').trim();
  const result = {
    lineNumber,
    raw: line,
    ok: false,
    item: null,
    warnings: [],
    error: null,
  };

  if (!line) return result;

  // 1. Optional order number.
  let rest = line;
  let orderNumber = null;
  const orderMatch = rest.match(/^(\d+)\s*\.\s*/);
  if (orderMatch) {
    orderNumber = Number(orderMatch[1]);
    rest = rest.slice(orderMatch[0].length).trim();
  }

  // The examples write prices as "/ 320" with a space. Treat the space after
  // a price or cost sigil as optional so both "/320" and "/ 320" work.
  rest = rest.replace(/\/\s+(?=\d)/g, '/').replace(/@\s+(?=\d)/g, '@');

  // 2. Locate the first parameter token.
  const { head, params } = splitHeadAndParams(rest);
  const parsedHead = parseHead(head);

  if (!parsedHead.producer) {
    result.error = { code: 'missing-producer' };
    return result;
  }

  const item = {
    orderNumber,
    producerName: parsedHead.producer,
    beverageName: parsedHead.beverage,
    style: parsedHead.style,
    abv: null,
    containerSize: null,
    containerType: null,
    currency: null,
    costExVat: null,
    vatRate: null,
    costIncVat: null,
    stockAmount: null,
    retailPriceExVat: null,
    retailPriceIncVat: null,
  };

  // 3. Parse parameter tokens in order.
  let previousKind = null;

  for (const token of params) {
    const kind = classifyToken(token);

    if (kind === 'cost') {
      const match = token.match(/^@(\d+(?:\.\d+)?)(v)?$/i);
      if (match) {
        if (match[2]) item.costIncVat = Number(match[1]);
        else item.costExVat = Number(match[1]);
      }
    } else if (kind === 'retail') {
      const match = token.match(/^\/(\d+(?:\.\d+)?)(v)?$/i);
      if (match) {
        if (match[2]) item.retailPriceIncVat = Number(match[1]);
        else item.retailPriceExVat = Number(match[1]);
      }
    } else if (kind === 'vat') {
      item.vatRate = Number(token.slice(1).replace('%', ''));
    } else if (kind === 'stock') {
      item.stockAmount = Number(token.slice(1));
    } else if (kind === 'abv') {
      item.abv = Number(token.slice(0, -1));
    } else if (kind === 'size') {
      // oz is a US fluid ounce (29.5735 ml); the original unit is preserved.
      const match = token.match(/^(\d+(?:\.\d+)?)(ml|cl|L|oz)$/i);
      if (match) {
        item.containerSize = {
          value: Number(match[1]),
          unit: match[2].toLowerCase() === 'l' ? 'L' : match[2].toLowerCase(),
        };
      }
    } else if (kind === 'container') {
      item.containerType = normalizeContainer(token);
    } else if (kind === 'currency') {
      item.currency = token.toUpperCase();
    } else if (kind === 'inc') {
      const value = Number(token.slice(0, -1));

      if (previousKind === 'retail') item.retailPriceIncVat = value;
      else if (previousKind === 'cost' || previousKind === 'vat') item.costIncVat = value;
      else if (item.retailPriceExVat !== null && item.retailPriceIncVat === null) {
        item.retailPriceIncVat = value;
      } else if (item.costExVat !== null && item.costIncVat === null) {
        item.costIncVat = value;
      } else {
        result.warnings.push({ code: 'unknown-token', token });
      }
    } else {
      result.warnings.push({ code: 'unknown-token', token });
    }

    if (kind) previousKind = kind;
  }

  // 4. Validate stated VAT pairs when a rate is present.
  if (item.vatRate !== null) {
    const factor = 1 + item.vatRate / 100;

    const checks = [
      ['cost', item.costExVat, item.costIncVat],
      ['retail', item.retailPriceExVat, item.retailPriceIncVat],
    ];

    for (const [field, exclusive, inclusive] of checks) {
      if (exclusive === null || inclusive === null) continue;

      const expected = round2(exclusive * factor);
      if (Math.abs(expected - inclusive) > 0.01) {
        result.warnings.push({
          code: 'vat-mismatch',
          field,
          expected,
          actual: inclusive,
        });
      }
    }
  }

  result.item = item;
  result.ok = true;
  return result;
};

/**
 * Parse a multi-line BLF document.
 * Blank lines are skipped; every other line becomes a result in input order.
 */
export const parseBlf = (text) => {
  const results = [];
  const lines = String(text ?? '').split(/\r?\n/);

  lines.forEach((line, index) => {
    if (line.trim() === '') return;
    results.push(parseBlfLine(line, index + 1));
  });

  return {
    results,
    itemCount: results.filter((entry) => entry.ok).length,
    warningCount: results.reduce((total, entry) => total + entry.warnings.length, 0),
    errorCount: results.filter((entry) => !entry.ok).length,
  };
};

การแมปเป็น JSON มาตรฐาน

{
  "orderNumber": 2,
  "producerName": "Sample Brewing",
  "beverageName": "Sea Breeze",
  "style": "Pale Ale",
  "abv": 5,
  "containerSize": { "value": 440, "unit": "ml" },
  "containerType": "Can",
  "currency": "THB",
  "costExVat": 180,
  "vatRate": 7.0,
  "costIncVat": 192.6,
  "stockAmount": 8,
  "retailPriceExVat": 320,
  "retailPriceIncVat": 342.4
}

ฟิลด์ที่ไม่มีให้ละไว้ ไม่ต้องใส่ null จำนวนเงินเป็น JSON number สกุลเงินใช้รหัส ISO 4217 และเก็บหน่วยบรรจุเดิมไว้ ถ้าไม่ระบุสกุลเงิน จะใช้สกุลเงินที่ตกลงกันของรายการ

08

ไม่จำกัดแค่เบียร์

BLF ย่อมาจาก Beverage Line Format และทุกฟิลด์ใช้ได้กับเครื่องดื่มบรรจุทุกประเภท เบียร์เป็นเพียงหนึ่งในหลายหมวดหมู่

ค็อกเทลพร้อมดื่ม

ฟิลด์เดียวกันอธิบายค็อกเทลกระป๋องได้ สไตล์บอกหมวดสินค้า และ ABV ยังคงใช้ได้

5. Mock Cocktail Co.: Citrus Club, Pre-made Cocktail 330ml 5%

มี้ด ไวน์ และสาเก

ผู้ผลิต ชื่อ สไตล์ ABV บรรจุภัณฑ์ และราคา ไม่มีอะไรผูกกับเบียร์ ใช้คำศัพท์สไตล์ที่เหมาะสม

7. Mock Meadery: Wildflower, Semi-Sweet Mead 12% 375ml Bottle THB @320 x12 / 650

ไม่มีแอลกอฮอล์

0% เป็น ABV ที่ถูกต้อง ใช้สไตล์เช่น Non-Alcoholic, Hop Water หรือ Soft Drink

8. Mock Brewery: Hop Water, Non-Alcoholic Sparkling 0% 330ml Can THB @28 x24 / 65
09

ข้อจำกัดและเส้นทางสู่ v1.1

มาตรฐานที่ดีต้องบอกข้อจำกัดของตัวเอง นี่คือช่องว่างของ v1.0 ที่ OpenCraft กำลังพิจารณาสำหรับเวอร์ชันถัดไป

v1.1

รหัสสินค้าถาวร

ลำดับเป็นเพียงดัชนีแสดงผล โทเคน SKU ที่ไม่บังคับจะช่วยให้ระบบจับคู่สินค้าเดียวกันข้ามการอัปเดตได้

v1.1

ส่วนหัวของรายการและข้อมูลเมตา

ส่วนหัวระดับรายการสามารถประกาศเวอร์ชัน BLF สกุลเงินเริ่มต้น อัตรา VAT เริ่มต้น และวันที่มีผลครั้งเดียว แทนการเขียนซ้ำในทุกเรกคอร์ด

v1.1

ทะเบียนสไตล์

สไตล์เป็นข้อความอิสระใน v1.0 ทะเบียนชื่อมาตรฐานร่วมกันจะทำให้ค้นหาและกรองได้แม่นยำ โดยยังอนุญาตข้อความอิสระ

v1.1

การ escape และ quoting

ชื่อที่มีข้อความคล้ายโทเคน เช่นเครื่องดื่มที่ชื่อ “440ml” ต้องมีทางหนี รูปแบบขยายคือทางออกชั่วคราว

v1.2

ชุดทดสอบอ้างอิง

ชุดทดสอบสาธารณะจะทำให้ parser สองตัวตีความตรงกันทุกกรณี

ลองใช้ แล้วบอกเราว่าพังตรงไหน

BLF เป็นข้อเสนอเปิด ยังไม่ใช่มาตรฐานอุตสาหกรรมที่เสร็จสมบูรณ์ ลองนำไปใช้กับรายการจริงของคุณ ทดสอบ และส่งข้อแก้ไขหรือกรณีขอบมาให้เรา ตัวอย่างจากโลกจริงคือทางที่เร็วที่สุดที่จะทำให้ v1.1 ดีขึ้น

📧 ส่งความคิดเห็น

BLF v1.0 · เสนอโดย OpenCraft · 3 พฤศจิกายน 2026