feat: cap nhat bang gia in nhanh kem giay INTC va in gia cong Konica C12000
Browse files- .gitignore +3 -0
- data/digital_pricing_giacong.json +67 -0
- data/digital_pricing_kem_giay.json +162 -0
- scripts/extract_pdf_images.py +26 -0
- scripts/inspect_pdf_details.py +20 -0
- scripts/inspect_pdfs.py +37 -0
- scripts/setup_digital_pricing.py +110 -0
- scripts/update_cost_calculator.py +690 -0
- src/app/api/calculate/route.ts +47 -2
- src/app/api/machines/route.ts +63 -1
- src/app/page.tsx +78 -0
- src/components/CostBreakdownCard.tsx +37 -2
- src/components/MachineManager.tsx +569 -246
- src/lib/db.ts +77 -1
- src/lib/engine/costCalculator.ts +230 -55
- src/lib/engine/imposition.ts +24 -8
- src/types/index.ts +42 -0
.gitignore
CHANGED
|
@@ -45,3 +45,6 @@ data/*.db-wal
|
|
| 45 |
data/*.db-shm
|
| 46 |
data/*.db-journal
|
| 47 |
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
data/*.db-shm
|
| 46 |
data/*.db-journal
|
| 47 |
|
| 48 |
+
# extracted images
|
| 49 |
+
scripts/extracted_images/
|
| 50 |
+
|
data/digital_pricing_giacong.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"machine_name": "KONICA C12000 / C12010S 5 MÀU",
|
| 4 |
+
"sheet_size": "A4",
|
| 5 |
+
"width_mm": 210,
|
| 6 |
+
"height_mm": 297,
|
| 7 |
+
"paper_lt_249": 600,
|
| 8 |
+
"paper_250_349": 800,
|
| 9 |
+
"paper_350_450": 1000,
|
| 10 |
+
"decal_paper_plastic": 1200,
|
| 11 |
+
"decal_clear": 1500,
|
| 12 |
+
"synthetic_paper": 1000,
|
| 13 |
+
"pvc_plastic": 1500
|
| 14 |
+
},
|
| 15 |
+
{
|
| 16 |
+
"machine_name": "KONICA C12000 / C12010S 5 MÀU",
|
| 17 |
+
"sheet_size": "330x355",
|
| 18 |
+
"width_mm": 330,
|
| 19 |
+
"height_mm": 355,
|
| 20 |
+
"paper_lt_249": 800,
|
| 21 |
+
"paper_250_349": 1000,
|
| 22 |
+
"paper_350_450": 1800,
|
| 23 |
+
"decal_paper_plastic": 1500,
|
| 24 |
+
"decal_clear": 1700,
|
| 25 |
+
"synthetic_paper": 1800,
|
| 26 |
+
"pvc_plastic": 2000
|
| 27 |
+
},
|
| 28 |
+
{
|
| 29 |
+
"machine_name": "KONICA C12000 / C12010S 5 MÀU",
|
| 30 |
+
"sheet_size": "A3",
|
| 31 |
+
"width_mm": 297,
|
| 32 |
+
"height_mm": 420,
|
| 33 |
+
"paper_lt_249": 1200,
|
| 34 |
+
"paper_250_349": 1600,
|
| 35 |
+
"paper_350_450": 2100,
|
| 36 |
+
"decal_paper_plastic": 2100,
|
| 37 |
+
"decal_clear": 2300,
|
| 38 |
+
"synthetic_paper": 2100,
|
| 39 |
+
"pvc_plastic": 2500
|
| 40 |
+
},
|
| 41 |
+
{
|
| 42 |
+
"machine_name": "KONICA C12000 / C12010S 5 MÀU",
|
| 43 |
+
"sheet_size": "330x483",
|
| 44 |
+
"width_mm": 330,
|
| 45 |
+
"height_mm": 483,
|
| 46 |
+
"paper_lt_249": 2000,
|
| 47 |
+
"paper_250_349": 2500,
|
| 48 |
+
"paper_350_450": 2700,
|
| 49 |
+
"decal_paper_plastic": 2500,
|
| 50 |
+
"decal_clear": 2800,
|
| 51 |
+
"synthetic_paper": 2700,
|
| 52 |
+
"pvc_plastic": 3000
|
| 53 |
+
},
|
| 54 |
+
{
|
| 55 |
+
"machine_name": "KONICA C12000 / C12010S 5 MÀU",
|
| 56 |
+
"sheet_size": "330x1200",
|
| 57 |
+
"width_mm": 330,
|
| 58 |
+
"height_mm": 1200,
|
| 59 |
+
"paper_lt_249": 4000,
|
| 60 |
+
"paper_250_349": 7000,
|
| 61 |
+
"paper_350_450": 10000,
|
| 62 |
+
"decal_paper_plastic": 6000,
|
| 63 |
+
"decal_clear": 7500,
|
| 64 |
+
"synthetic_paper": 15000,
|
| 65 |
+
"pvc_plastic": 0
|
| 66 |
+
}
|
| 67 |
+
]
|
data/digital_pricing_kem_giay.json
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"paper_code": "C100",
|
| 4 |
+
"paper_name": "Couche 100gsm",
|
| 5 |
+
"gsm": 100,
|
| 6 |
+
"sheet_size": "325x430",
|
| 7 |
+
"width_mm": 325,
|
| 8 |
+
"height_mm": 430,
|
| 9 |
+
"price_1side": 1600,
|
| 10 |
+
"price_2side": 2800
|
| 11 |
+
},
|
| 12 |
+
{
|
| 13 |
+
"paper_code": "C120",
|
| 14 |
+
"paper_name": "Couche 120gsm",
|
| 15 |
+
"gsm": 120,
|
| 16 |
+
"sheet_size": "325x430",
|
| 17 |
+
"width_mm": 325,
|
| 18 |
+
"height_mm": 430,
|
| 19 |
+
"price_1side": 1700,
|
| 20 |
+
"price_2side": 2900
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
"paper_code": "C150",
|
| 24 |
+
"paper_name": "Couche 150gsm",
|
| 25 |
+
"gsm": 150,
|
| 26 |
+
"sheet_size": "325x430",
|
| 27 |
+
"width_mm": 325,
|
| 28 |
+
"height_mm": 430,
|
| 29 |
+
"price_1side": 1800,
|
| 30 |
+
"price_2side": 3000
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
"paper_code": "C200",
|
| 34 |
+
"paper_name": "Couche 200gsm",
|
| 35 |
+
"gsm": 200,
|
| 36 |
+
"sheet_size": "325x430",
|
| 37 |
+
"width_mm": 325,
|
| 38 |
+
"height_mm": 430,
|
| 39 |
+
"price_1side": 1900,
|
| 40 |
+
"price_2side": 3100
|
| 41 |
+
},
|
| 42 |
+
{
|
| 43 |
+
"paper_code": "C250",
|
| 44 |
+
"paper_name": "Couche 250gsm",
|
| 45 |
+
"gsm": 250,
|
| 46 |
+
"sheet_size": "325x430",
|
| 47 |
+
"width_mm": 325,
|
| 48 |
+
"height_mm": 430,
|
| 49 |
+
"price_1side": 2100,
|
| 50 |
+
"price_2side": 3300
|
| 51 |
+
},
|
| 52 |
+
{
|
| 53 |
+
"paper_code": "C300",
|
| 54 |
+
"paper_name": "Couche 300gsm",
|
| 55 |
+
"gsm": 300,
|
| 56 |
+
"sheet_size": "325x430",
|
| 57 |
+
"width_mm": 325,
|
| 58 |
+
"height_mm": 430,
|
| 59 |
+
"price_1side": 2700,
|
| 60 |
+
"price_2side": 4300
|
| 61 |
+
},
|
| 62 |
+
{
|
| 63 |
+
"paper_code": "I250",
|
| 64 |
+
"paper_name": "Ivory 250gsm",
|
| 65 |
+
"gsm": 250,
|
| 66 |
+
"sheet_size": "325x430",
|
| 67 |
+
"width_mm": 325,
|
| 68 |
+
"height_mm": 430,
|
| 69 |
+
"price_1side": 2100,
|
| 70 |
+
"price_2side": 3300
|
| 71 |
+
},
|
| 72 |
+
{
|
| 73 |
+
"paper_code": "I300",
|
| 74 |
+
"paper_name": "Ivory 300gsm",
|
| 75 |
+
"gsm": 300,
|
| 76 |
+
"sheet_size": "325x430",
|
| 77 |
+
"width_mm": 325,
|
| 78 |
+
"height_mm": 430,
|
| 79 |
+
"price_1side": 2600,
|
| 80 |
+
"price_2side": 4200
|
| 81 |
+
},
|
| 82 |
+
{
|
| 83 |
+
"paper_code": "I350",
|
| 84 |
+
"paper_name": "Ivory 350gsm",
|
| 85 |
+
"gsm": 350,
|
| 86 |
+
"sheet_size": "325x430",
|
| 87 |
+
"width_mm": 325,
|
| 88 |
+
"height_mm": 430,
|
| 89 |
+
"price_1side": 3300,
|
| 90 |
+
"price_2side": 5300
|
| 91 |
+
},
|
| 92 |
+
{
|
| 93 |
+
"paper_code": "F100",
|
| 94 |
+
"paper_name": "Fort 100gsm",
|
| 95 |
+
"gsm": 100,
|
| 96 |
+
"sheet_size": "325x430",
|
| 97 |
+
"width_mm": 325,
|
| 98 |
+
"height_mm": 430,
|
| 99 |
+
"price_1side": 1600,
|
| 100 |
+
"price_2side": 2800
|
| 101 |
+
},
|
| 102 |
+
{
|
| 103 |
+
"paper_code": "F120",
|
| 104 |
+
"paper_name": "Fort 120gsm",
|
| 105 |
+
"gsm": 120,
|
| 106 |
+
"sheet_size": "325x430",
|
| 107 |
+
"width_mm": 325,
|
| 108 |
+
"height_mm": 430,
|
| 109 |
+
"price_1side": 1700,
|
| 110 |
+
"price_2side": 2900
|
| 111 |
+
},
|
| 112 |
+
{
|
| 113 |
+
"paper_code": "F180",
|
| 114 |
+
"paper_name": "Fort 180gsm",
|
| 115 |
+
"gsm": 180,
|
| 116 |
+
"sheet_size": "325x430",
|
| 117 |
+
"width_mm": 325,
|
| 118 |
+
"height_mm": 430,
|
| 119 |
+
"price_1side": 1900,
|
| 120 |
+
"price_2side": 3100
|
| 121 |
+
},
|
| 122 |
+
{
|
| 123 |
+
"paper_code": "F230",
|
| 124 |
+
"paper_name": "Fort 230gsm",
|
| 125 |
+
"gsm": 230,
|
| 126 |
+
"sheet_size": "325x430",
|
| 127 |
+
"width_mm": 325,
|
| 128 |
+
"height_mm": 430,
|
| 129 |
+
"price_1side": 2100,
|
| 130 |
+
"price_2side": 3300
|
| 131 |
+
},
|
| 132 |
+
{
|
| 133 |
+
"paper_code": "F250",
|
| 134 |
+
"paper_name": "Fort 250gsm",
|
| 135 |
+
"gsm": 250,
|
| 136 |
+
"sheet_size": "325x430",
|
| 137 |
+
"width_mm": 325,
|
| 138 |
+
"height_mm": 430,
|
| 139 |
+
"price_1side": 2300,
|
| 140 |
+
"price_2side": 3500
|
| 141 |
+
},
|
| 142 |
+
{
|
| 143 |
+
"paper_code": "F180_355",
|
| 144 |
+
"paper_name": "Fort 180gsm (325x355)",
|
| 145 |
+
"gsm": 180,
|
| 146 |
+
"sheet_size": "325x355",
|
| 147 |
+
"width_mm": 325,
|
| 148 |
+
"height_mm": 355,
|
| 149 |
+
"price_1side": 1400,
|
| 150 |
+
"price_2side": 2200
|
| 151 |
+
},
|
| 152 |
+
{
|
| 153 |
+
"paper_code": "F230_355",
|
| 154 |
+
"paper_name": "Fort 230gsm (325x355)",
|
| 155 |
+
"gsm": 230,
|
| 156 |
+
"sheet_size": "325x355",
|
| 157 |
+
"width_mm": 325,
|
| 158 |
+
"height_mm": 355,
|
| 159 |
+
"price_1side": 1500,
|
| 160 |
+
"price_2side": 2300
|
| 161 |
+
}
|
| 162 |
+
]
|
scripts/extract_pdf_images.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fitz
|
| 2 |
+
import os
|
| 3 |
+
|
| 4 |
+
pdf1_path = r"C:\Users\admin\Downloads\BẢNG GIÁ IN NHANH 14.03.2026.pdf"
|
| 5 |
+
pdf2_path = r"C:\Users\admin\Downloads\BẢNG GIÁ IN GIA CÔNG 26.02.2026.pdf"
|
| 6 |
+
|
| 7 |
+
os.makedirs("scripts/extracted_images", exist_ok=True)
|
| 8 |
+
|
| 9 |
+
# Extract PDF 1 Page 1
|
| 10 |
+
doc1 = fitz.open(pdf1_path)
|
| 11 |
+
page1 = doc1[0]
|
| 12 |
+
pix1 = page1.get_pixmap(dpi=150)
|
| 13 |
+
pix1.save("scripts/extracted_images/in_nhanh_page1.png")
|
| 14 |
+
print("Saved in_nhanh_page1.png")
|
| 15 |
+
|
| 16 |
+
# Extract PDF 2 Page 2
|
| 17 |
+
doc2 = fitz.open(pdf2_path)
|
| 18 |
+
page2 = doc2[1] # 0-indexed page 2
|
| 19 |
+
pix2 = page2.get_pixmap(dpi=150)
|
| 20 |
+
pix2.save("scripts/extracted_images/in_gia_cong_page2.png")
|
| 21 |
+
print("Saved in_gia_cong_page2.png")
|
| 22 |
+
|
| 23 |
+
# Also let's extract page 1 and page 3 of PDF 2 just in case
|
| 24 |
+
doc2[0].get_pixmap(dpi=100).save("scripts/extracted_images/in_gia_cong_page1.png")
|
| 25 |
+
doc2[2].get_pixmap(dpi=100).save("scripts/extracted_images/in_gia_cong_page3.png")
|
| 26 |
+
print("Saved in_gia_cong_page1.png and page3.png")
|
scripts/inspect_pdf_details.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fitz
|
| 2 |
+
import sys
|
| 3 |
+
|
| 4 |
+
sys.stdout.reconfigure(encoding='utf-8')
|
| 5 |
+
|
| 6 |
+
for path, name in [
|
| 7 |
+
(r"C:\Users\admin\Downloads\BẢNG GIÁ IN NHANH 14.03.2026.pdf", "PDF 1"),
|
| 8 |
+
(r"C:\Users\admin\Downloads\BẢNG GIÁ IN GIA CÔNG 26.02.2026.pdf", "PDF 2")
|
| 9 |
+
]:
|
| 10 |
+
print(f"\n*** Checking {name}: {path} ***")
|
| 11 |
+
doc = fitz.open(path)
|
| 12 |
+
for p_idx, page in enumerate(doc):
|
| 13 |
+
imgs = page.get_images()
|
| 14 |
+
text = page.get_text()
|
| 15 |
+
drawings = page.get_drawings()
|
| 16 |
+
print(f"Page {p_idx+1}: text_len={len(text)}, images_count={len(imgs)}, drawings_count={len(drawings)}")
|
| 17 |
+
for img_idx, img in enumerate(imgs):
|
| 18 |
+
xref = img[0]
|
| 19 |
+
base_img = doc.extract_image(xref)
|
| 20 |
+
print(f" Image {img_idx+1}: xref={xref}, ext={base_img['ext']}, width={base_img['width']}, height={base_img['height']}")
|
scripts/inspect_pdfs.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fitz
|
| 2 |
+
import sys
|
| 3 |
+
|
| 4 |
+
sys.stdout.reconfigure(encoding='utf-8')
|
| 5 |
+
|
| 6 |
+
pdf1_path = r"C:\Users\admin\Downloads\BẢNG GIÁ IN NHANH 14.03.2026.pdf"
|
| 7 |
+
pdf2_path = r"C:\Users\admin\Downloads\BẢNG GIÁ IN GIA CÔNG 26.02.2026.pdf"
|
| 8 |
+
|
| 9 |
+
print("==================================================")
|
| 10 |
+
print("=== PDF 1: BẢNG GIÁ IN NHANH 14.03.2026.pdf ===")
|
| 11 |
+
print("==================================================")
|
| 12 |
+
try:
|
| 13 |
+
doc1 = fitz.open(pdf1_path)
|
| 14 |
+
print("Number of pages in PDF 1:", len(doc1))
|
| 15 |
+
for i, page in enumerate(doc1):
|
| 16 |
+
print(f"\n--- PDF 1 - Page {i+1} ---")
|
| 17 |
+
text = page.get_text()
|
| 18 |
+
print(text[:2000])
|
| 19 |
+
if len(text) > 2000:
|
| 20 |
+
print(f"... [Total length: {len(text)} chars]")
|
| 21 |
+
except Exception as e:
|
| 22 |
+
print("Error reading PDF 1:", e)
|
| 23 |
+
|
| 24 |
+
print("\n==================================================")
|
| 25 |
+
print("=== PDF 2: BẢNG GIÁ IN GIA CÔNG 26.02.2026.pdf ===")
|
| 26 |
+
print("==================================================")
|
| 27 |
+
try:
|
| 28 |
+
doc2 = fitz.open(pdf2_path)
|
| 29 |
+
print("Number of pages in PDF 2:", len(doc2))
|
| 30 |
+
for i, page in enumerate(doc2):
|
| 31 |
+
print(f"\n--- PDF 2 - Page {i+1} ---")
|
| 32 |
+
text = page.get_text()
|
| 33 |
+
print(text[:2000])
|
| 34 |
+
if len(text) > 2000:
|
| 35 |
+
print(f"... [Total length: {len(text)} chars]")
|
| 36 |
+
except Exception as e:
|
| 37 |
+
print("Error reading PDF 2:", e)
|
scripts/setup_digital_pricing.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sqlite3
|
| 2 |
+
import json
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
db_path = "data/printcal.db"
|
| 6 |
+
conn = sqlite3.connect(db_path)
|
| 7 |
+
cur = conn.cursor()
|
| 8 |
+
|
| 9 |
+
# 1. Create digital_pricing_giacong
|
| 10 |
+
cur.execute("""
|
| 11 |
+
CREATE TABLE IF NOT EXISTS digital_pricing_giacong (
|
| 12 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 13 |
+
machine_name TEXT NOT NULL,
|
| 14 |
+
sheet_size TEXT NOT NULL UNIQUE,
|
| 15 |
+
width_mm REAL NOT NULL,
|
| 16 |
+
height_mm REAL NOT NULL,
|
| 17 |
+
paper_lt_249 REAL NOT NULL,
|
| 18 |
+
paper_250_349 REAL NOT NULL,
|
| 19 |
+
paper_350_450 REAL NOT NULL,
|
| 20 |
+
decal_paper_plastic REAL NOT NULL,
|
| 21 |
+
decal_clear REAL NOT NULL,
|
| 22 |
+
synthetic_paper REAL NOT NULL,
|
| 23 |
+
pvc_plastic REAL NOT NULL,
|
| 24 |
+
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
| 25 |
+
);
|
| 26 |
+
""")
|
| 27 |
+
|
| 28 |
+
# 2. Create digital_pricing_kem_giay
|
| 29 |
+
cur.execute("""
|
| 30 |
+
CREATE TABLE IF NOT EXISTS digital_pricing_kem_giay (
|
| 31 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 32 |
+
paper_code TEXT NOT NULL UNIQUE,
|
| 33 |
+
paper_name TEXT NOT NULL,
|
| 34 |
+
gsm INTEGER NOT NULL,
|
| 35 |
+
sheet_size TEXT NOT NULL,
|
| 36 |
+
width_mm REAL NOT NULL,
|
| 37 |
+
height_mm REAL NOT NULL,
|
| 38 |
+
price_1side REAL NOT NULL,
|
| 39 |
+
price_2side REAL NOT NULL,
|
| 40 |
+
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
| 41 |
+
);
|
| 42 |
+
""")
|
| 43 |
+
|
| 44 |
+
# 3. Data for In Gia Cong (Konica C12000 / C12010S 5 Mau - Trang 2 PDF)
|
| 45 |
+
giacong_data = [
|
| 46 |
+
# machine_name, sheet_size, w, h, lt249, 250-349, 350-450, decal, decal_trong, giay_nhua, pvc
|
| 47 |
+
("KONICA C12000 / C12010S 5 MÀU", "A4", 210, 297, 600, 800, 1000, 1200, 1500, 1000, 1500),
|
| 48 |
+
("KONICA C12000 / C12010S 5 MÀU", "330x355", 330, 355, 800, 1000, 1800, 1500, 1700, 1800, 2000),
|
| 49 |
+
("KONICA C12000 / C12010S 5 MÀU", "A3", 297, 420, 1200, 1600, 2100, 2100, 2300, 2100, 2500),
|
| 50 |
+
("KONICA C12000 / C12010S 5 MÀU", "330x483", 330, 483, 2000, 2500, 2700, 2500, 2800, 2700, 3000),
|
| 51 |
+
("KONICA C12000 / C12010S 5 MÀU", "330x1200", 330, 1200, 4000, 7000, 10000, 6000, 7500, 15000, 0),
|
| 52 |
+
]
|
| 53 |
+
|
| 54 |
+
cur.execute("DELETE FROM digital_pricing_giacong;")
|
| 55 |
+
cur.executemany("""
|
| 56 |
+
INSERT INTO digital_pricing_giacong (machine_name, sheet_size, width_mm, height_mm, paper_lt_249, paper_250_349, paper_350_450, decal_paper_plastic, decal_clear, synthetic_paper, pvc_plastic)
|
| 57 |
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
| 58 |
+
""", giacong_data)
|
| 59 |
+
|
| 60 |
+
# 4. Data for In Nhanh Kem Giay (INTC - PDF In Nhanh 14.03.2026)
|
| 61 |
+
kem_giay_data = [
|
| 62 |
+
# code, name, gsm, sheet_size, w, h, p1, p2
|
| 63 |
+
("C100", "Couche 100gsm", 100, "325x430", 325, 430, 1600, 2800),
|
| 64 |
+
("C120", "Couche 120gsm", 120, "325x430", 325, 430, 1700, 2900),
|
| 65 |
+
("C150", "Couche 150gsm", 150, "325x430", 325, 430, 1800, 3000),
|
| 66 |
+
("C200", "Couche 200gsm", 200, "325x430", 325, 430, 1900, 3100),
|
| 67 |
+
("C250", "Couche 250gsm", 250, "325x430", 325, 430, 2100, 3300),
|
| 68 |
+
("C300", "Couche 300gsm", 300, "325x430", 325, 430, 2700, 4300),
|
| 69 |
+
("I250", "Ivory 250gsm", 250, "325x430", 325, 430, 2100, 3300),
|
| 70 |
+
("I300", "Ivory 300gsm", 300, "325x430", 325, 430, 2600, 4200),
|
| 71 |
+
("I350", "Ivory 350gsm", 350, "325x430", 325, 430, 3300, 5300),
|
| 72 |
+
("F100", "Fort 100gsm", 100, "325x430", 325, 430, 1600, 2800),
|
| 73 |
+
("F120", "Fort 120gsm", 120, "325x430", 325, 430, 1700, 2900),
|
| 74 |
+
("F180", "Fort 180gsm", 180, "325x430", 325, 430, 1900, 3100),
|
| 75 |
+
("F230", "Fort 230gsm", 230, "325x430", 325, 430, 2100, 3300),
|
| 76 |
+
("F250", "Fort 250gsm", 250, "325x430", 325, 430, 2300, 3500),
|
| 77 |
+
("F180_355", "Fort 180gsm (325x355)", 180, "325x355", 325, 355, 1400, 2200),
|
| 78 |
+
("F230_355", "Fort 230gsm (325x355)", 230, "325x355", 325, 355, 1500, 2300),
|
| 79 |
+
]
|
| 80 |
+
|
| 81 |
+
cur.execute("DELETE FROM digital_pricing_kem_giay;")
|
| 82 |
+
cur.executemany("""
|
| 83 |
+
INSERT INTO digital_pricing_kem_giay (paper_code, paper_name, gsm, sheet_size, width_mm, height_mm, price_1side, price_2side)
|
| 84 |
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?);
|
| 85 |
+
""", kem_giay_data)
|
| 86 |
+
|
| 87 |
+
conn.commit()
|
| 88 |
+
print("Saved tables to SQLite successfully!")
|
| 89 |
+
|
| 90 |
+
# Export to JSON
|
| 91 |
+
with open("data/digital_pricing_giacong.json", "w", encoding="utf-8") as f:
|
| 92 |
+
json.dump([
|
| 93 |
+
{
|
| 94 |
+
"machine_name": r[0], "sheet_size": r[1], "width_mm": r[2], "height_mm": r[3],
|
| 95 |
+
"paper_lt_249": r[4], "paper_250_349": r[5], "paper_350_450": r[6],
|
| 96 |
+
"decal_paper_plastic": r[7], "decal_clear": r[8], "synthetic_paper": r[9], "pvc_plastic": r[10]
|
| 97 |
+
}
|
| 98 |
+
for r in giacong_data
|
| 99 |
+
], f, ensure_ascii=False, indent=2)
|
| 100 |
+
|
| 101 |
+
with open("data/digital_pricing_kem_giay.json", "w", encoding="utf-8") as f:
|
| 102 |
+
json.dump([
|
| 103 |
+
{
|
| 104 |
+
"paper_code": r[0], "paper_name": r[1], "gsm": r[2], "sheet_size": r[3],
|
| 105 |
+
"width_mm": r[4], "height_mm": r[5], "price_1side": r[6], "price_2side": r[7]
|
| 106 |
+
}
|
| 107 |
+
for r in kem_giay_data
|
| 108 |
+
], f, ensure_ascii=False, indent=2)
|
| 109 |
+
|
| 110 |
+
print("Exported JSON files successfully!")
|
scripts/update_cost_calculator.py
ADDED
|
@@ -0,0 +1,690 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
new_content = """import {
|
| 2 |
+
CalculationInput,
|
| 3 |
+
CalculationResult,
|
| 4 |
+
ComparisonItem,
|
| 5 |
+
DigitalMachine,
|
| 6 |
+
DigitalMode,
|
| 7 |
+
DigitalPrintGiaCong,
|
| 8 |
+
DigitalPrintKemGiay,
|
| 9 |
+
FinishingCostDetail,
|
| 10 |
+
FinishingService,
|
| 11 |
+
OffsetMachine,
|
| 12 |
+
PaperType,
|
| 13 |
+
} from '@/types';
|
| 14 |
+
import { calculateImposition } from './imposition';
|
| 15 |
+
|
| 16 |
+
export const DEFAULT_DIGITAL_GIA_CONG: DigitalPrintGiaCong[] = [
|
| 17 |
+
{ id: 1, machineName: 'KONICA C12000 / C12010S 5 MÀU', sheetSize: 'A4', widthMm: 210, heightMm: 297, paperLt249: 600, paper250To349: 800, paper350To450: 1000, decalPaperPlastic: 1200, decalClear: 1500, syntheticPaper: 1000, pvcPlastic: 1500 },
|
| 18 |
+
{ id: 2, machineName: 'KONICA C12000 / C12010S 5 MÀU', sheetSize: '330x355', widthMm: 330, heightMm: 355, paperLt249: 800, paper250To349: 1000, paper350To450: 1800, decalPaperPlastic: 1500, decalClear: 1700, syntheticPaper: 1800, pvcPlastic: 2000 },
|
| 19 |
+
{ id: 3, machineName: 'KONICA C12000 / C12010S 5 MÀU', sheetSize: 'A3', widthMm: 297, heightMm: 420, paperLt249: 1200, paper250To349: 1600, paper350To450: 2100, decalPaperPlastic: 2100, decalClear: 2300, syntheticPaper: 2100, pvcPlastic: 2500 },
|
| 20 |
+
{ id: 4, machineName: 'KONICA C12000 / C12010S 5 MÀU', sheetSize: '330x483', widthMm: 330, heightMm: 483, paperLt249: 2000, paper250To349: 2500, paper350To450: 2700, decalPaperPlastic: 2500, decalClear: 2800, syntheticPaper: 2700, pvcPlastic: 3000 },
|
| 21 |
+
{ id: 5, machineName: 'KONICA C12000 / C12010S 5 MÀU', sheetSize: '330x1200', widthMm: 330, heightMm: 1200, paperLt249: 4000, paper250To349: 7000, paper350To450: 10000, decalPaperPlastic: 6000, decalClear: 7500, syntheticPaper: 15000, pvcPlastic: 0 },
|
| 22 |
+
];
|
| 23 |
+
|
| 24 |
+
export const DEFAULT_DIGITAL_KEM_GIAY: DigitalPrintKemGiay[] = [
|
| 25 |
+
{ id: 1, paperCode: 'C100', paperName: 'Couche 100gsm', gsm: 100, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 1600, price2Side: 2800 },
|
| 26 |
+
{ id: 2, paperCode: 'C120', paperName: 'Couche 120gsm', gsm: 120, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 1700, price2Side: 2900 },
|
| 27 |
+
{ id: 3, paperCode: 'C150', paperName: 'Couche 150gsm', gsm: 150, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 1800, price2Side: 3000 },
|
| 28 |
+
{ id: 4, paperCode: 'C200', paperName: 'Couche 200gsm', gsm: 200, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 1900, price2Side: 3100 },
|
| 29 |
+
{ id: 5, paperCode: 'C250', paperName: 'Couche 250gsm', gsm: 250, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 2100, price2Side: 3300 },
|
| 30 |
+
{ id: 6, paperCode: 'C300', paperName: 'Couche 300gsm', gsm: 300, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 2700, price2Side: 4300 },
|
| 31 |
+
{ id: 7, paperCode: 'I250', paperName: 'Ivory 250gsm', gsm: 250, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 2100, price2Side: 3300 },
|
| 32 |
+
{ id: 8, paperCode: 'I300', paperName: 'Ivory 300gsm', gsm: 300, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 2600, price2Side: 4200 },
|
| 33 |
+
{ id: 9, paperCode: 'I350', paperName: 'Ivory 350gsm', gsm: 350, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 3300, price2Side: 5300 },
|
| 34 |
+
{ id: 10, paperCode: 'F100', paperName: 'Fort 100gsm', gsm: 100, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 1600, price2Side: 2800 },
|
| 35 |
+
{ id: 11, paperCode: 'F120', paperName: 'Fort 120gsm', gsm: 120, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 1700, price2Side: 2900 },
|
| 36 |
+
{ id: 12, paperCode: 'F180', paperName: 'Fort 180gsm', gsm: 180, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 1900, price2Side: 3100 },
|
| 37 |
+
{ id: 13, paperCode: 'F230', paperName: 'Fort 230gsm', gsm: 230, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 2100, price2Side: 3300 },
|
| 38 |
+
{ id: 14, paperCode: 'F250', paperName: 'Fort 250gsm', gsm: 250, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 2300, price2Side: 3500 },
|
| 39 |
+
{ id: 15, paperCode: 'F180_355', paperName: 'Fort 180gsm (325x355)', gsm: 180, sheetSize: '325x355', widthMm: 325, heightMm: 355, price1Side: 1400, price2Side: 2200 },
|
| 40 |
+
{ id: 16, paperCode: 'F230_355', paperName: 'Fort 230gsm (325x355)', gsm: 230, sheetSize: '325x355', widthMm: 325, heightMm: 355, price1Side: 1500, price2Side: 2300 },
|
| 41 |
+
];
|
| 42 |
+
|
| 43 |
+
interface CostCalculatorOptions {
|
| 44 |
+
input: CalculationInput;
|
| 45 |
+
paperType: PaperType;
|
| 46 |
+
offsetMachine?: OffsetMachine;
|
| 47 |
+
allOffsetMachines?: OffsetMachine[];
|
| 48 |
+
digitalMachine?: DigitalMachine;
|
| 49 |
+
digitalPricingGiaCong?: DigitalPrintGiaCong[];
|
| 50 |
+
digitalPricingKemGiay?: DigitalPrintKemGiay[];
|
| 51 |
+
allFinishingServices: FinishingService[];
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
export function matchOffsetMachine(
|
| 55 |
+
printSheet: { widthMm: number; heightMm: number },
|
| 56 |
+
allMachines?: OffsetMachine[],
|
| 57 |
+
selectedMachineId?: number
|
| 58 |
+
): OffsetMachine {
|
| 59 |
+
if (selectedMachineId && allMachines) {
|
| 60 |
+
const found = allMachines.find((m) => m.id === selectedMachineId);
|
| 61 |
+
if (found) return found;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
const dimMin = Math.min(printSheet.widthMm, printSheet.heightMm);
|
| 65 |
+
const dimMax = Math.max(printSheet.widthMm, printSheet.heightMm);
|
| 66 |
+
|
| 67 |
+
if (allMachines && allMachines.length > 0) {
|
| 68 |
+
// Khổ nhỏ dưới 65x43cm (nếu vừa trong 650 x 430 mm)
|
| 69 |
+
if (dimMin <= 435 && dimMax <= 655) {
|
| 70 |
+
const small = allMachines.find(
|
| 71 |
+
(m) =>
|
| 72 |
+
m.name.toLowerCase().includes('nhỏ') ||
|
| 73 |
+
(m.maxWidthMm <= 655 && m.maxHeightMm <= 435) ||
|
| 74 |
+
(m.maxWidthMm <= 435 && m.maxHeightMm <= 655)
|
| 75 |
+
);
|
| 76 |
+
if (small) return small;
|
| 77 |
+
}
|
| 78 |
+
// Khổ 65x86cm bắt nhíp chiều 86
|
| 79 |
+
const large = allMachines.find(
|
| 80 |
+
(m) => m.name.includes('65x86') || m.maxWidthMm >= 800 || m.maxHeightMm >= 800
|
| 81 |
+
);
|
| 82 |
+
if (large) return large;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
// Fallback mặc định chuẩn theo yêu cầu xưởng in
|
| 86 |
+
if (dimMin <= 435 && dimMax <= 655) {
|
| 87 |
+
return {
|
| 88 |
+
id: 1,
|
| 89 |
+
name: 'Máy Offset Khổ Nhỏ (dưới 65x43cm)',
|
| 90 |
+
maxWidthMm: 650,
|
| 91 |
+
maxHeightMm: 430,
|
| 92 |
+
minWidthMm: 210,
|
| 93 |
+
minHeightMm: 297,
|
| 94 |
+
platePrice: 75000,
|
| 95 |
+
setupCost: 900000,
|
| 96 |
+
stepCost: 150000,
|
| 97 |
+
defaultWasteSheets: 80,
|
| 98 |
+
gripperMarginMm: 10,
|
| 99 |
+
baseImpressions: 3000,
|
| 100 |
+
includesPlate: true,
|
| 101 |
+
};
|
| 102 |
+
} else {
|
| 103 |
+
return {
|
| 104 |
+
id: 2,
|
| 105 |
+
name: 'Máy Offset Khổ 65x86cm (Bắt nhíp chiều 86)',
|
| 106 |
+
maxWidthMm: 860,
|
| 107 |
+
maxHeightMm: 650,
|
| 108 |
+
minWidthMm: 430,
|
| 109 |
+
minHeightMm: 650,
|
| 110 |
+
platePrice: 100000,
|
| 111 |
+
setupCost: 1200000,
|
| 112 |
+
stepCost: 200000,
|
| 113 |
+
defaultWasteSheets: 100,
|
| 114 |
+
gripperMarginMm: 10,
|
| 115 |
+
baseImpressions: 3000,
|
| 116 |
+
includesPlate: true,
|
| 117 |
+
};
|
| 118 |
+
}
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
export function calculatePrintCost({
|
| 122 |
+
input,
|
| 123 |
+
paperType,
|
| 124 |
+
offsetMachine,
|
| 125 |
+
allOffsetMachines,
|
| 126 |
+
digitalMachine,
|
| 127 |
+
digitalPricingGiaCong = DEFAULT_DIGITAL_GIA_CONG,
|
| 128 |
+
digitalPricingKemGiay = DEFAULT_DIGITAL_KEM_GIAY,
|
| 129 |
+
allFinishingServices,
|
| 130 |
+
}: CostCalculatorOptions): CalculationResult {
|
| 131 |
+
const digitalMode: DigitalMode = input.digitalMode || 'with_paper';
|
| 132 |
+
|
| 133 |
+
// 1. Xác định công nghệ in mục tiêu
|
| 134 |
+
let chosenTech: 'offset' | 'digital' = 'offset';
|
| 135 |
+
if (input.printTech === 'digital') {
|
| 136 |
+
chosenTech = 'digital';
|
| 137 |
+
} else if (input.printTech === 'offset') {
|
| 138 |
+
chosenTech = 'offset';
|
| 139 |
+
} else {
|
| 140 |
+
// Tự động: số lượng < 300 con thường in nhanh rẻ hơn, >= 300 in offset rẻ hơn
|
| 141 |
+
chosenTech = input.quantity < 300 ? 'digital' : 'offset';
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
// 2. Tính bình trang & số con
|
| 145 |
+
const preferredSheet = chosenTech === 'offset' && offsetMachine
|
| 146 |
+
? { widthMm: offsetMachine.maxWidthMm, heightMm: offsetMachine.maxHeightMm, name: offsetMachine.name }
|
| 147 |
+
: undefined;
|
| 148 |
+
|
| 149 |
+
const imposition = calculateImposition({
|
| 150 |
+
widthMm: input.widthMm,
|
| 151 |
+
heightMm: input.heightMm,
|
| 152 |
+
bleedMm: input.bleedMm ?? 2,
|
| 153 |
+
paperType,
|
| 154 |
+
printTech: chosenTech,
|
| 155 |
+
digitalMode,
|
| 156 |
+
preferredMachineSheet: preferredSheet,
|
| 157 |
+
gripperMarginMm: offsetMachine?.gripperMarginMm ?? 10,
|
| 158 |
+
});
|
| 159 |
+
|
| 160 |
+
const ups = Math.max(1, imposition.upsPerPrintSheet);
|
| 161 |
+
const netPrintSheets = Math.ceil(input.quantity / ups);
|
| 162 |
+
|
| 163 |
+
// 3. Tính bù hao gia công
|
| 164 |
+
let finishingWasteSheets = 0;
|
| 165 |
+
let totalFinishingWastePercent = 0;
|
| 166 |
+
const activeFinishingDetails: FinishingCostDetail[] = [];
|
| 167 |
+
|
| 168 |
+
for (const sel of (input.selectedFinishing || [])) {
|
| 169 |
+
const service = allFinishingServices.find((s) => s.id === sel.serviceId);
|
| 170 |
+
if (!service) continue;
|
| 171 |
+
|
| 172 |
+
totalFinishingWastePercent += service.wastePercent;
|
| 173 |
+
finishingWasteSheets += service.wasteSheets;
|
| 174 |
+
|
| 175 |
+
// Tính chi phí từng mục gia công
|
| 176 |
+
let subtotal = 0;
|
| 177 |
+
let formula = '';
|
| 178 |
+
|
| 179 |
+
if (service.calcType === 'per_m2') {
|
| 180 |
+
const areaM2 = ((input.widthMm + (input.bleedMm || 2) * 2) / 1000) *
|
| 181 |
+
((input.heightMm + (input.bleedMm || 2) * 2) / 1000) *
|
| 182 |
+
input.quantity * (sel.sides || 1);
|
| 183 |
+
const rawCost = areaM2 * service.unitPrice;
|
| 184 |
+
subtotal = Math.max(service.minPrice, Math.round(rawCost)) + service.setupFee;
|
| 185 |
+
formula = `${areaM2.toFixed(2)} m² × ${service.unitPrice.toLocaleString('vi-VN')}đ (tối thiểu ${service.minPrice.toLocaleString('vi-VN')}đ)`;
|
| 186 |
+
} else if (service.calcType === 'die_cut') {
|
| 187 |
+
const runCost = input.quantity * service.unitPrice;
|
| 188 |
+
subtotal = Math.max(service.minPrice, runCost) + service.setupFee;
|
| 189 |
+
formula = `Khuôn bế ${service.setupFee.toLocaleString('vi-VN')}đ + Công bế (${input.quantity} con × ${service.unitPrice}đ)`;
|
| 190 |
+
} else if (service.calcType === 'per_product') {
|
| 191 |
+
const rawCost = input.quantity * service.unitPrice;
|
| 192 |
+
subtotal = Math.max(service.minPrice, rawCost) + service.setupFee;
|
| 193 |
+
formula = `${input.quantity} con × ${service.unitPrice.toLocaleString('vi-VN')}đ`;
|
| 194 |
+
} else if (service.calcType === 'per_sheet') {
|
| 195 |
+
const rawCost = netPrintSheets * service.unitPrice;
|
| 196 |
+
subtotal = Math.max(service.minPrice, rawCost) + service.setupFee;
|
| 197 |
+
formula = `${netPrintSheets} tờ in × ${service.unitPrice.toLocaleString('vi-VN')}đ`;
|
| 198 |
+
} else {
|
| 199 |
+
// Fixed
|
| 200 |
+
subtotal = service.unitPrice + service.setupFee;
|
| 201 |
+
formula = `Trọn gói ${service.unitPrice.toLocaleString('vi-VN')}đ`;
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
activeFinishingDetails.push({
|
| 205 |
+
serviceId: service.id,
|
| 206 |
+
name: service.name,
|
| 207 |
+
category: service.category,
|
| 208 |
+
calcType: service.calcType,
|
| 209 |
+
quantity: input.quantity,
|
| 210 |
+
unitPrice: service.unitPrice,
|
| 211 |
+
setupFee: service.setupFee,
|
| 212 |
+
subtotal,
|
| 213 |
+
wasteSheets: service.wasteSheets,
|
| 214 |
+
formula,
|
| 215 |
+
});
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
// Bù hao gia công theo %
|
| 219 |
+
finishingWasteSheets += Math.ceil((netPrintSheets * totalFinishingWastePercent) / 100);
|
| 220 |
+
|
| 221 |
+
// 4. Bù hao in ấn & Tổng số tờ in
|
| 222 |
+
let printWasteSheets = 0;
|
| 223 |
+
if (chosenTech === 'offset') {
|
| 224 |
+
printWasteSheets = offsetMachine?.defaultWasteSheets ?? 100;
|
| 225 |
+
} else {
|
| 226 |
+
printWasteSheets = digitalMachine?.defaultWasteSheets ?? 4;
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
const totalPrintSheets = netPrintSheets + printWasteSheets + finishingWasteSheets;
|
| 230 |
+
const cuts = Math.max(1, imposition.cutsPerParentSheet);
|
| 231 |
+
const parentSheetsNeeded = Math.ceil(totalPrintSheets / cuts);
|
| 232 |
+
const ramsNeeded = Math.round((parentSheetsNeeded / 500) * 100) / 100;
|
| 233 |
+
|
| 234 |
+
// Tính kg giấy = Số tờ lớn * Dài (m) * Rộng (m) * (gsm / 1000)
|
| 235 |
+
const parentAreaM2 = (paperType.parentWidthCm / 100) * (paperType.parentHeightCm / 100);
|
| 236 |
+
const kgNeeded = Math.round(((parentSheetsNeeded * parentAreaM2 * paperType.gsm) / 1000) * 10) / 10;
|
| 237 |
+
|
| 238 |
+
// 5. Tiền giấy: Bỏ giá /kg, chỉ áp dụng giá trên 500 tờ (trên ram) và giá dưới 500 tờ (dưới ram)
|
| 239 |
+
const priceAbove500 = paperType.priceAbove500 || paperType.pricePerRam;
|
| 240 |
+
const priceBelow500 = paperType.priceBelow500 || priceAbove500;
|
| 241 |
+
|
| 242 |
+
// Nếu số tờ mẹ >= 500 (trên 1 ram) áp dụng giá sỉ nguyên ram, nếu < 500 tờ áp dụng giá lẻ dưới ram
|
| 243 |
+
const isWholesale = parentSheetsNeeded >= 500;
|
| 244 |
+
const effectiveRamPrice = isWholesale ? priceAbove500 : priceBelow500;
|
| 245 |
+
let paperCost = Math.round(parentSheetsNeeded * (effectiveRamPrice / 500));
|
| 246 |
+
|
| 247 |
+
// 6. Tiền in ấn
|
| 248 |
+
let plateCost = 0;
|
| 249 |
+
let platesCount = 0;
|
| 250 |
+
let printRunCost = 0;
|
| 251 |
+
let shortRunFee = 0;
|
| 252 |
+
let digitalDetails: CalculationResult['costs']['digitalDetails'] | undefined;
|
| 253 |
+
|
| 254 |
+
if (chosenTech === 'offset') {
|
| 255 |
+
const machine = offsetMachine || matchOffsetMachine(imposition.printSheet, allOffsetMachines, input.offsetMachineId);
|
| 256 |
+
|
| 257 |
+
const is2Sides = input.printSides === '2_side';
|
| 258 |
+
const workType = input.offsetWorkType || 'self_turn';
|
| 259 |
+
const baseImpressions = machine.baseImpressions || 3000;
|
| 260 |
+
const includesPlate = machine.includesPlate ?? true;
|
| 261 |
+
|
| 262 |
+
if (!is2Sides) {
|
| 263 |
+
// In 1 mặt: 1 ca máy, 1 bộ kẽm (chuẩn 4 màu)
|
| 264 |
+
const colors = input.colorsFront || 4;
|
| 265 |
+
platesCount = colors;
|
| 266 |
+
|
| 267 |
+
if (includesPlate) {
|
| 268 |
+
// Gói mở máy đã bao gồm kẽm 4 màu, chỉ tính thêm tiền kẽm nếu khách in màu thứ 5+
|
| 269 |
+
const extraColors = Math.max(0, colors - 4);
|
| 270 |
+
plateCost = extraColors * machine.platePrice;
|
| 271 |
+
} else {
|
| 272 |
+
plateCost = platesCount * machine.platePrice;
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
const impressions = totalPrintSheets;
|
| 276 |
+
if (impressions <= baseImpressions) {
|
| 277 |
+
printRunCost = machine.setupCost;
|
| 278 |
+
} else {
|
| 279 |
+
const extraThousands = Math.ceil((impressions - baseImpressions) / 1000);
|
| 280 |
+
printRunCost = machine.setupCost + extraThousands * machine.stepCost;
|
| 281 |
+
}
|
| 282 |
+
} else {
|
| 283 |
+
// In 2 mặt
|
| 284 |
+
if (workType === 'sheetwise') {
|
| 285 |
+
// In 2 bài riêng: 2 ca in độc lập, 2 bộ kẽm
|
| 286 |
+
const colors = (input.colorsFront || 4) + (input.colorsBack || 4);
|
| 287 |
+
platesCount = colors;
|
| 288 |
+
|
| 289 |
+
if (includesPlate) {
|
| 290 |
+
// Gói mở máy đã bao gồm 1 bộ kẽm 4 màu cơ bản, bài thứ 2 hoặc màu thứ 5+ tính riêng kẽm
|
| 291 |
+
const extraColors = Math.max(0, colors - 4);
|
| 292 |
+
plateCost = extraColors * machine.platePrice;
|
| 293 |
+
} else {
|
| 294 |
+
plateCost = platesCount * machine.platePrice;
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
// 2 ca in độc lập
|
| 298 |
+
const impressionsPerSide = totalPrintSheets;
|
| 299 |
+
const costSide1 = impressionsPerSide <= baseImpressions
|
| 300 |
+
? machine.setupCost
|
| 301 |
+
: machine.setupCost + Math.ceil((impressionsPerSide - baseImpressions) / 1000) * machine.stepCost;
|
| 302 |
+
const costSide2 = impressionsPerSide <= baseImpressions
|
| 303 |
+
? machine.setupCost
|
| 304 |
+
: machine.setupCost + Math.ceil((impressionsPerSide - baseImpressions) / 1000) * machine.stepCost;
|
| 305 |
+
printRunCost = costSide1 + costSide2;
|
| 306 |
+
} else {
|
| 307 |
+
// Tự trở (Work & Turn) hoặc Trở nhíp (Tumble): Dùng chung 1 bộ kẽm ghép cả 2 mặt (1 ca máy)
|
| 308 |
+
const colors = Math.max(input.colorsFront || 4, input.colorsBack || 4);
|
| 309 |
+
platesCount = colors;
|
| 310 |
+
|
| 311 |
+
if (includesPlate) {
|
| 312 |
+
const extraColors = Math.max(0, colors - 4);
|
| 313 |
+
plateCost = extraColors * machine.platePrice;
|
| 314 |
+
} else {
|
| 315 |
+
plateCost = platesCount * machine.platePrice;
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
// Chạy 2 lượt in (lượt ép x 2)
|
| 319 |
+
const totalImpressions = totalPrintSheets * 2;
|
| 320 |
+
if (totalImpressions <= baseImpressions) {
|
| 321 |
+
printRunCost = machine.setupCost;
|
| 322 |
+
} else {
|
| 323 |
+
const extraThousands = Math.ceil((totalImpressions - baseImpressions) / 1000);
|
| 324 |
+
printRunCost = machine.setupCost + extraThousands * machine.stepCost;
|
| 325 |
+
}
|
| 326 |
+
}
|
| 327 |
+
}
|
| 328 |
+
} else {
|
| 329 |
+
// In nhanh kỹ thuật số (Digital)
|
| 330 |
+
const is2Sides = input.printSides === '2_side';
|
| 331 |
+
let ratePerSheet = 0;
|
| 332 |
+
let machineName = '';
|
| 333 |
+
let sheetSizeName = imposition.printSheet.name;
|
| 334 |
+
let whiteInkCost = 0;
|
| 335 |
+
|
| 336 |
+
// Phụ phí số lượng ít:
|
| 337 |
+
// < 50 tờ: +30.000đ
|
| 338 |
+
// < 100 tờ: +20.000đ
|
| 339 |
+
// >= 100 tờ: 0đ
|
| 340 |
+
if (totalPrintSheets < 50) {
|
| 341 |
+
shortRunFee = 30000;
|
| 342 |
+
} else if (totalPrintSheets < 100) {
|
| 343 |
+
shortRunFee = 20000;
|
| 344 |
+
} else {
|
| 345 |
+
shortRunFee = 0;
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
if (digitalMode === 'with_paper') {
|
| 349 |
+
// 1. In nhanh kèm giấy (INTC) - Đã gồm tiền giấy trọn gói
|
| 350 |
+
machineName = 'In Nhanh Kèm Giấy (INTC)';
|
| 351 |
+
const kemGiayList = digitalPricingKemGiay && digitalPricingKemGiay.length > 0
|
| 352 |
+
? digitalPricingKemGiay
|
| 353 |
+
: DEFAULT_DIGITAL_KEM_GIAY;
|
| 354 |
+
|
| 355 |
+
// Tìm dòng giá phù hợp theo mã giấy
|
| 356 |
+
let matchedPricing = kemGiayList.find((kg) =>
|
| 357 |
+
paperType.code.toUpperCase().includes(kg.paperCode.toUpperCase())
|
| 358 |
+
);
|
| 359 |
+
|
| 360 |
+
// Nếu không khớp chính xác mã, so khớp theo tiền tố chất liệu (C, I, F) + GSM gần nhất
|
| 361 |
+
if (!matchedPricing) {
|
| 362 |
+
const pName = paperType.name.toLowerCase();
|
| 363 |
+
let prefix = 'C';
|
| 364 |
+
if (pName.includes('ivory') || paperType.code.toUpperCase().startsWith('I')) prefix = 'I';
|
| 365 |
+
else if (pName.includes('fort') || pName.includes('ford') || paperType.code.toUpperCase().startsWith('F')) prefix = 'F';
|
| 366 |
+
|
| 367 |
+
const sameType = kemGiayList.filter((kg) => kg.paperCode.startsWith(prefix));
|
| 368 |
+
if (sameType.length > 0) {
|
| 369 |
+
matchedPricing = sameType.reduce((prev, curr) =>
|
| 370 |
+
Math.abs(curr.gsm - paperType.gsm) < Math.abs(prev.gsm - paperType.gsm) ? curr : prev
|
| 371 |
+
);
|
| 372 |
+
} else {
|
| 373 |
+
matchedPricing = kemGiayList[0];
|
| 374 |
+
}
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
if (matchedPricing) {
|
| 378 |
+
ratePerSheet = is2Sides ? matchedPricing.price2Side : matchedPricing.price1Side;
|
| 379 |
+
sheetSizeName = `${matchedPricing.sheetSize} mm (${matchedPricing.paperName})`;
|
| 380 |
+
} else {
|
| 381 |
+
ratePerSheet = is2Sides ? 3000 : 1800;
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
printRunCost = Math.round(totalPrintSheets * ratePerSheet) + shortRunFee;
|
| 385 |
+
|
| 386 |
+
// Đã kèm giấy nên tiền giấy = 0
|
| 387 |
+
paperCost = 0;
|
| 388 |
+
plateCost = 0;
|
| 389 |
+
platesCount = 0;
|
| 390 |
+
|
| 391 |
+
digitalDetails = {
|
| 392 |
+
mode: 'with_paper',
|
| 393 |
+
machineName,
|
| 394 |
+
sheetSizeName,
|
| 395 |
+
ratePerSheet,
|
| 396 |
+
shortRunFee,
|
| 397 |
+
note: `Bảng giá In Nhanh Kèm Giấy INTC (Đã gồm công in + giấy ${matchedPricing?.paperName || ''}). Phí SL ít: +${shortRunFee.toLocaleString('vi-VN')}đ`,
|
| 398 |
+
};
|
| 399 |
+
} else {
|
| 400 |
+
// 2. In nhanh gia công (Konica C12000 / C12010S 5 màu - Trang 2 PDF)
|
| 401 |
+
machineName = 'Konica C12000 / C12010S 5 Màu';
|
| 402 |
+
const giaCongList = digitalPricingGiaCong && digitalPricingGiaCong.length > 0
|
| 403 |
+
? digitalPricingGiaCong
|
| 404 |
+
: DEFAULT_DIGITAL_GIA_CONG;
|
| 405 |
+
|
| 406 |
+
// Nhận diện khổ in máy
|
| 407 |
+
const pW = imposition.printSheet.widthMm;
|
| 408 |
+
const pH = imposition.printSheet.heightMm;
|
| 409 |
+
const maxDim = Math.max(pW, pH);
|
| 410 |
+
const minDim = Math.min(pW, pH);
|
| 411 |
+
|
| 412 |
+
let sheetRow: DigitalPrintGiaCong | undefined;
|
| 413 |
+
if (maxDim > 500) {
|
| 414 |
+
sheetRow = giaCongList.find((g) => g.sheetSize === '330x1200');
|
| 415 |
+
} else if (maxDim > 430 || minDim > 300) {
|
| 416 |
+
sheetRow = giaCongList.find((g) => g.sheetSize === '330x483');
|
| 417 |
+
} else if (maxDim > 360 || minDim > 250) {
|
| 418 |
+
sheetRow = giaCongList.find((g) => g.sheetSize === 'A3');
|
| 419 |
+
} else if (maxDim > 300) {
|
| 420 |
+
sheetRow = giaCongList.find((g) => g.sheetSize === '330x355');
|
| 421 |
+
} else {
|
| 422 |
+
sheetRow = giaCongList.find((g) => g.sheetSize === 'A4');
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
if (!sheetRow) {
|
| 426 |
+
sheetRow = giaCongList.find((g) => g.sheetSize === '330x483') || giaCongList[0];
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
// Nhận diện nhóm chất liệu
|
| 430 |
+
const pName = paperType.name.toLowerCase();
|
| 431 |
+
let click1Side = 0;
|
| 432 |
+
|
| 433 |
+
if (sheetRow) {
|
| 434 |
+
if (pName.includes('pvc')) {
|
| 435 |
+
click1Side = sheetRow.pvcPlastic || sheetRow.paper350To450;
|
| 436 |
+
} else if (pName.includes('nhựa') || pName.includes('synthetic')) {
|
| 437 |
+
click1Side = sheetRow.syntheticPaper;
|
| 438 |
+
} else if (pName.includes('trong') && pName.includes('decal')) {
|
| 439 |
+
click1Side = sheetRow.decalClear;
|
| 440 |
+
} else if (pName.includes('decal')) {
|
| 441 |
+
click1Side = sheetRow.decalPaperPlastic;
|
| 442 |
+
} else {
|
| 443 |
+
// Giấy thường theo định lượng GSM
|
| 444 |
+
if (paperType.gsm < 250) {
|
| 445 |
+
click1Side = sheetRow.paperLt249;
|
| 446 |
+
} else if (paperType.gsm <= 349) {
|
| 447 |
+
click1Side = sheetRow.paper250To349;
|
| 448 |
+
} else {
|
| 449 |
+
click1Side = sheetRow.paper350To450;
|
| 450 |
+
}
|
| 451 |
+
}
|
| 452 |
+
sheetSizeName = `${sheetRow.sheetSize} (${sheetRow.widthMm}x${sheetRow.heightMm}mm)`;
|
| 453 |
+
} else {
|
| 454 |
+
click1Side = is2Sides ? 2000 : 1000;
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
// Giá in 2 mặt = x2 giá 1 mặt
|
| 458 |
+
ratePerSheet = is2Sides ? (click1Side * 2) : click1Side;
|
| 459 |
+
|
| 460 |
+
// In mực trắng nếu có tùy chọn
|
| 461 |
+
if (input.whiteInk) {
|
| 462 |
+
whiteInkCost = totalPrintSheets * (is2Sides ? 4000 : 2000);
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
printRunCost = Math.round(totalPrintSheets * ratePerSheet) + whiteInkCost + shortRunFee;
|
| 466 |
+
|
| 467 |
+
// Nếu khách tự cấp giấy -> tiền giấy = 0
|
| 468 |
+
if (input.customerSuppliedPaper) {
|
| 469 |
+
paperCost = 0;
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
plateCost = 0;
|
| 473 |
+
platesCount = 0;
|
| 474 |
+
|
| 475 |
+
digitalDetails = {
|
| 476 |
+
mode: 'without_paper',
|
| 477 |
+
machineName,
|
| 478 |
+
sheetSizeName,
|
| 479 |
+
ratePerSheet,
|
| 480 |
+
shortRunFee,
|
| 481 |
+
whiteInkCost,
|
| 482 |
+
note: `In Gia Công Konica C12000 (Khổ ${sheetSizeName}, Đơn giá ${ratePerSheet.toLocaleString('vi-VN')}đ/tờ). Phí SL ít: +${shortRunFee.toLocaleString('vi-VN')}đ${input.customerSuppliedPaper ? ' • Khách cấp giấy' : ''}`,
|
| 483 |
+
};
|
| 484 |
+
}
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
const totalPrintCost = plateCost + printRunCost;
|
| 488 |
+
const totalFinishingCost = activeFinishingDetails.reduce((sum, item) => sum + item.subtotal, 0);
|
| 489 |
+
|
| 490 |
+
// 7. Tổng chi phí cơ sở & Giá bán
|
| 491 |
+
const totalBaseCost = paperCost + totalPrintCost + totalFinishingCost;
|
| 492 |
+
const profitMarginPercent = input.profitMarginPercent ?? 25;
|
| 493 |
+
const profitAmount = Math.round((totalBaseCost * profitMarginPercent) / 100);
|
| 494 |
+
const discountAmount = input.discountAmount ?? 0;
|
| 495 |
+
const preTaxTotal = Math.max(0, totalBaseCost + profitAmount - discountAmount);
|
| 496 |
+
const vatPercent = input.vatPercent ?? 0;
|
| 497 |
+
const vatAmount = Math.round((preTaxTotal * vatPercent) / 100);
|
| 498 |
+
const finalPrice = preTaxTotal + vatAmount;
|
| 499 |
+
const unitPrice = input.quantity > 0 ? Math.round(finalPrice / input.quantity) : 0;
|
| 500 |
+
|
| 501 |
+
// 8. Thuật toán So sánh In Nhanh vs In Offset & Tìm điểm hòa vốn
|
| 502 |
+
const comparisonTable: ComparisonItem[] = [];
|
| 503 |
+
const testQuantities = [50, 100, 200, 300, 500, 1000, 2000, 5000];
|
| 504 |
+
|
| 505 |
+
for (const qty of testQuantities) {
|
| 506 |
+
// Tính thử chi phí digital
|
| 507 |
+
const digResult = runQuickEstimate({
|
| 508 |
+
...input,
|
| 509 |
+
quantity: qty,
|
| 510 |
+
printTech: 'digital',
|
| 511 |
+
}, paperType, offsetMachine, digitalMachine, allFinishingServices, digitalPricingGiaCong, digitalPricingKemGiay);
|
| 512 |
+
|
| 513 |
+
// Tính thử chi phí offset
|
| 514 |
+
const offResult = runQuickEstimate({
|
| 515 |
+
...input,
|
| 516 |
+
quantity: qty,
|
| 517 |
+
printTech: 'offset',
|
| 518 |
+
}, paperType, offsetMachine, digitalMachine, allFinishingServices, digitalPricingGiaCong, digitalPricingKemGiay);
|
| 519 |
+
|
| 520 |
+
comparisonTable.push({
|
| 521 |
+
quantity: qty,
|
| 522 |
+
digitalTotal: digResult.finalPrice,
|
| 523 |
+
digitalUnit: digResult.unitPrice,
|
| 524 |
+
offsetTotal: offResult.finalPrice,
|
| 525 |
+
offsetUnit: offResult.unitPrice,
|
| 526 |
+
recommended: digResult.finalPrice <= offResult.finalPrice ? 'digital' : 'offset',
|
| 527 |
+
});
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
+
// Điểm hòa vốn (Break-even): Tìm mốc số lượng mà offset bắt đầu rẻ hơn digital
|
| 531 |
+
let breakEvenQty = 300;
|
| 532 |
+
for (let i = 0; i < comparisonTable.length - 1; i++) {
|
| 533 |
+
if (comparisonTable[i].recommended === 'digital' && comparisonTable[i + 1].recommended === 'offset') {
|
| 534 |
+
breakEvenQty = Math.round((comparisonTable[i].quantity + comparisonTable[i + 1].quantity) / 2);
|
| 535 |
+
break;
|
| 536 |
+
}
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
// Tính lại cho số lượng hiện tại nếu người dùng chọn 'auto'
|
| 540 |
+
const curDigital = runQuickEstimate({ ...input, printTech: 'digital' }, paperType, offsetMachine, digitalMachine, allFinishingServices, digitalPricingGiaCong, digitalPricingKemGiay);
|
| 541 |
+
const curOffset = runQuickEstimate({ ...input, printTech: 'offset' }, paperType, offsetMachine, digitalMachine, allFinishingServices, digitalPricingGiaCong, digitalPricingKemGiay);
|
| 542 |
+
|
| 543 |
+
let recommendationText = '';
|
| 544 |
+
const diff = Math.abs(curDigital.finalPrice - curOffset.finalPrice);
|
| 545 |
+
|
| 546 |
+
if (curDigital.finalPrice < curOffset.finalPrice) {
|
| 547 |
+
recommendationText = `Với số lượng ${input.quantity.toLocaleString('vi-VN')} sản phẩm, In Nhanh KTS tiết kiệm hơn In Offset ${diff.toLocaleString('vi-VN')}đ (${Math.round((diff / curOffset.finalPrice) * 100)}%), không tốn phí mở máy và kẽm CTP, lấy nhanh trong ngày.`;
|
| 548 |
+
} else {
|
| 549 |
+
recommendationText = `Với số lượng ${input.quantity.toLocaleString('vi-VN')} sản phẩm, In Offset tối ưu chi phí hơn In Nhanh ${diff.toLocaleString('vi-VN')}đ (${Math.round((diff / curDigital.finalPrice) * 100)}%), giá đơn vị giảm sâu theo số lượng lớn.`;
|
| 550 |
+
}
|
| 551 |
+
|
| 552 |
+
return {
|
| 553 |
+
imposition,
|
| 554 |
+
quantities: {
|
| 555 |
+
productQty: input.quantity,
|
| 556 |
+
netPrintSheets,
|
| 557 |
+
printWasteSheets,
|
| 558 |
+
finishingWasteSheets,
|
| 559 |
+
totalPrintSheets,
|
| 560 |
+
parentSheetsNeeded,
|
| 561 |
+
ramsNeeded,
|
| 562 |
+
kgNeeded,
|
| 563 |
+
},
|
| 564 |
+
costs: {
|
| 565 |
+
paperCost,
|
| 566 |
+
plateCost,
|
| 567 |
+
platesCount,
|
| 568 |
+
printRunCost,
|
| 569 |
+
totalPrintCost,
|
| 570 |
+
finishingCost: totalFinishingCost,
|
| 571 |
+
finishingDetails: activeFinishingDetails,
|
| 572 |
+
totalBaseCost,
|
| 573 |
+
profitAmount,
|
| 574 |
+
preTaxTotal,
|
| 575 |
+
discountAmount,
|
| 576 |
+
vatAmount,
|
| 577 |
+
finalPrice,
|
| 578 |
+
unitPrice,
|
| 579 |
+
shortRunFee,
|
| 580 |
+
digitalDetails,
|
| 581 |
+
},
|
| 582 |
+
chosenTech,
|
| 583 |
+
recommendation: {
|
| 584 |
+
bestTech: curDigital.finalPrice <= curOffset.finalPrice ? 'digital' : 'offset',
|
| 585 |
+
reason: recommendationText,
|
| 586 |
+
digitalTotal: curDigital.finalPrice,
|
| 587 |
+
offsetTotal: curOffset.finalPrice,
|
| 588 |
+
difference: diff,
|
| 589 |
+
breakEvenQty,
|
| 590 |
+
comparisonTable,
|
| 591 |
+
},
|
| 592 |
+
};
|
| 593 |
+
}
|
| 594 |
+
|
| 595 |
+
// Hàm tính nhanh phục vụ bảng so sánh (tránh đệ quy)
|
| 596 |
+
function runQuickEstimate(
|
| 597 |
+
input: CalculationInput,
|
| 598 |
+
paperType: PaperType,
|
| 599 |
+
offsetMachine?: OffsetMachine,
|
| 600 |
+
digitalMachine?: DigitalMachine,
|
| 601 |
+
allFinishingServices: FinishingService[] = [],
|
| 602 |
+
digitalPricingGiaCong: DigitalPrintGiaCong[] = DEFAULT_DIGITAL_GIA_CONG,
|
| 603 |
+
digitalPricingKemGiay: DigitalPrintKemGiay[] = DEFAULT_DIGITAL_KEM_GIAY
|
| 604 |
+
): { finalPrice: number; unitPrice: number } {
|
| 605 |
+
const isDigital = input.printTech === 'digital';
|
| 606 |
+
const digitalMode: DigitalMode = input.digitalMode || 'with_paper';
|
| 607 |
+
const preferredSheet = !isDigital && offsetMachine
|
| 608 |
+
? { widthMm: offsetMachine.maxWidthMm, heightMm: offsetMachine.maxHeightMm, name: offsetMachine.name }
|
| 609 |
+
: undefined;
|
| 610 |
+
|
| 611 |
+
const imposition = calculateImposition({
|
| 612 |
+
widthMm: input.widthMm,
|
| 613 |
+
heightMm: input.heightMm,
|
| 614 |
+
bleedMm: input.bleedMm ?? 2,
|
| 615 |
+
paperType,
|
| 616 |
+
printTech: input.printTech,
|
| 617 |
+
digitalMode,
|
| 618 |
+
preferredMachineSheet: preferredSheet,
|
| 619 |
+
});
|
| 620 |
+
|
| 621 |
+
const ups = Math.max(1, imposition.upsPerPrintSheet);
|
| 622 |
+
const netSheets = Math.ceil(input.quantity / ups);
|
| 623 |
+
const printWaste = isDigital ? 4 : (offsetMachine?.defaultWasteSheets ?? 100);
|
| 624 |
+
const totalSheets = netSheets + printWaste + 10;
|
| 625 |
+
|
| 626 |
+
const cuts = Math.max(1, imposition.cutsPerParentSheet);
|
| 627 |
+
const parentSheets = Math.ceil(totalSheets / cuts);
|
| 628 |
+
const pAbove = paperType.priceAbove500 || paperType.pricePerRam;
|
| 629 |
+
const pBelow = paperType.priceBelow500 || pAbove;
|
| 630 |
+
const effectiveRamPrice = parentSheets >= 500 ? pAbove : pBelow;
|
| 631 |
+
let paperCost = Math.round(parentSheets * (effectiveRamPrice / 500));
|
| 632 |
+
|
| 633 |
+
let printCost = 0;
|
| 634 |
+
if (isDigital) {
|
| 635 |
+
const is2Sides = input.printSides === '2_side';
|
| 636 |
+
let shortFee = 0;
|
| 637 |
+
if (totalSheets < 50) shortFee = 30000;
|
| 638 |
+
else if (totalSheets < 100) shortFee = 20000;
|
| 639 |
+
|
| 640 |
+
if (digitalMode === 'with_paper') {
|
| 641 |
+
const matched = digitalPricingKemGiay.find((kg) =>
|
| 642 |
+
paperType.code.toUpperCase().includes(kg.paperCode.toUpperCase())
|
| 643 |
+
) || digitalPricingKemGiay[0];
|
| 644 |
+
const rate = is2Sides ? matched.price2Side : matched.price1Side;
|
| 645 |
+
printCost = Math.round(totalSheets * rate) + shortFee;
|
| 646 |
+
paperCost = 0;
|
| 647 |
+
} else {
|
| 648 |
+
const isA3 = imposition.printSheet.widthMm > 220 || imposition.printSheet.heightMm > 310;
|
| 649 |
+
const rate = isA3 ? (is2Sides ? 4000 : 2000) : (is2Sides ? 1600 : 800);
|
| 650 |
+
printCost = Math.round(totalSheets * rate) + shortFee;
|
| 651 |
+
if (input.customerSuppliedPaper) paperCost = 0;
|
| 652 |
+
}
|
| 653 |
+
} else {
|
| 654 |
+
const machine = offsetMachine || matchOffsetMachine(imposition.printSheet);
|
| 655 |
+
const baseImpressions = machine.baseImpressions || 3000;
|
| 656 |
+
const includesPlate = machine.includesPlate ?? true;
|
| 657 |
+
const platePrice = includesPlate ? 0 : 4 * machine.platePrice;
|
| 658 |
+
const impressions = input.printSides === '2_side' ? totalSheets * 2 : totalSheets;
|
| 659 |
+
const runPrice = impressions <= baseImpressions
|
| 660 |
+
? machine.setupCost
|
| 661 |
+
: machine.setupCost + Math.ceil((impressions - baseImpressions) / 1000) * machine.stepCost;
|
| 662 |
+
printCost = platePrice + runPrice;
|
| 663 |
+
}
|
| 664 |
+
|
| 665 |
+
// Ước tính gia công cơ bản
|
| 666 |
+
let finishingCost = 0;
|
| 667 |
+
for (const sel of (input.selectedFinishing || [])) {
|
| 668 |
+
const service = allFinishingServices.find((s) => s.id === sel.serviceId);
|
| 669 |
+
if (service) {
|
| 670 |
+
if (service.calcType === 'per_m2') {
|
| 671 |
+
const area = ((input.widthMm + 4) / 1000) * ((input.heightMm + 4) / 1000) * input.quantity * (sel.sides || 1);
|
| 672 |
+
finishingCost += Math.max(service.minPrice, Math.round(area * service.unitPrice)) + service.setupFee;
|
| 673 |
+
} else {
|
| 674 |
+
finishingCost += Math.max(service.minPrice, input.quantity * service.unitPrice) + service.setupFee;
|
| 675 |
+
}
|
| 676 |
+
}
|
| 677 |
+
}
|
| 678 |
+
|
| 679 |
+
const baseCost = paperCost + printCost + finishingCost;
|
| 680 |
+
const margin = input.profitMarginPercent ?? 25;
|
| 681 |
+
const finalPrice = Math.round(baseCost * (1 + margin / 100));
|
| 682 |
+
const unitPrice = input.quantity > 0 ? Math.round(finalPrice / input.quantity) : 0;
|
| 683 |
+
|
| 684 |
+
return { finalPrice, unitPrice };
|
| 685 |
+
}
|
| 686 |
+
"""
|
| 687 |
+
|
| 688 |
+
with open("src/lib/engine/costCalculator.ts", "w", encoding="utf-8") as f:
|
| 689 |
+
f.write(new_content)
|
| 690 |
+
print("Updated costCalculator.ts successfully!")
|
src/app/api/calculate/route.ts
CHANGED
|
@@ -1,7 +1,15 @@
|
|
| 1 |
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
import { getDb } from '@/lib/db';
|
| 3 |
import { calculatePrintCost } from '@/lib/engine/costCalculator';
|
| 4 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
export async function POST(req: NextRequest) {
|
| 7 |
try {
|
|
@@ -13,7 +21,8 @@ export async function POST(req: NextRequest) {
|
|
| 13 |
if (input.paperTypeId) {
|
| 14 |
paperRow = db.prepare('SELECT * FROM paper_types WHERE id = ?').get(input.paperTypeId) as Record<string, unknown> | undefined;
|
| 15 |
} else if ((input as unknown as Record<string, unknown>).paperCode) {
|
| 16 |
-
|
|
|
|
| 17 |
} else {
|
| 18 |
paperRow = db.prepare('SELECT * FROM paper_types WHERE is_active = 1 LIMIT 1').get() as Record<string, unknown> | undefined;
|
| 19 |
}
|
|
@@ -63,6 +72,37 @@ export async function POST(req: NextRequest) {
|
|
| 63 |
if (row) digitalMachine = mapDigitalMachine(row);
|
| 64 |
}
|
| 65 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
// Lấy danh mục dịch vụ gia công
|
| 67 |
const finishingRows = db.prepare('SELECT * FROM finishing_services WHERE is_active = 1').all() as Record<string, unknown>[];
|
| 68 |
const allFinishingServices: FinishingService[] = finishingRows.map((r) => ({
|
|
@@ -115,6 +155,9 @@ export async function POST(req: NextRequest) {
|
|
| 115 |
offsetWorkType: (rawBody.offsetWorkType as CalculationInput['offsetWorkType']) || 'self_turn',
|
| 116 |
offsetMachineId: rawBody.offsetMachineId ? Number(rawBody.offsetMachineId) : undefined,
|
| 117 |
digitalMachineId: rawBody.digitalMachineId ? Number(rawBody.digitalMachineId) : undefined,
|
|
|
|
|
|
|
|
|
|
| 118 |
selectedFinishing: sanitizedFinishing,
|
| 119 |
profitMarginPercent: Number(rawBody.profitMarginPercent) || 0,
|
| 120 |
discountAmount: Number(rawBody.discountAmount) || 0,
|
|
@@ -128,6 +171,8 @@ export async function POST(req: NextRequest) {
|
|
| 128 |
allOffsetMachines,
|
| 129 |
digitalMachine,
|
| 130 |
allFinishingServices,
|
|
|
|
|
|
|
| 131 |
});
|
| 132 |
|
| 133 |
return NextResponse.json(result);
|
|
|
|
| 1 |
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
import { getDb } from '@/lib/db';
|
| 3 |
import { calculatePrintCost } from '@/lib/engine/costCalculator';
|
| 4 |
+
import {
|
| 5 |
+
CalculationInput,
|
| 6 |
+
DigitalMachine,
|
| 7 |
+
DigitalPrintGiaCong,
|
| 8 |
+
DigitalPrintKemGiay,
|
| 9 |
+
FinishingService,
|
| 10 |
+
OffsetMachine,
|
| 11 |
+
PaperType,
|
| 12 |
+
} from '@/types';
|
| 13 |
|
| 14 |
export async function POST(req: NextRequest) {
|
| 15 |
try {
|
|
|
|
| 21 |
if (input.paperTypeId) {
|
| 22 |
paperRow = db.prepare('SELECT * FROM paper_types WHERE id = ?').get(input.paperTypeId) as Record<string, unknown> | undefined;
|
| 23 |
} else if ((input as unknown as Record<string, unknown>).paperCode) {
|
| 24 |
+
const codeStr = (input as unknown as Record<string, unknown>).paperCode as string;
|
| 25 |
+
paperRow = db.prepare('SELECT * FROM paper_types WHERE code = ? OR code LIKE ? LIMIT 1').get(codeStr, `${codeStr}%`) as Record<string, unknown> | undefined;
|
| 26 |
} else {
|
| 27 |
paperRow = db.prepare('SELECT * FROM paper_types WHERE is_active = 1 LIMIT 1').get() as Record<string, unknown> | undefined;
|
| 28 |
}
|
|
|
|
| 72 |
if (row) digitalMachine = mapDigitalMachine(row);
|
| 73 |
}
|
| 74 |
|
| 75 |
+
// Lấy bảng giá in nhanh gia công (Konica C12000)
|
| 76 |
+
const digitalGiaCongRows = db.prepare('SELECT * FROM digital_pricing_giacong ORDER BY id ASC').all() as Record<string, unknown>[];
|
| 77 |
+
const digitalPricingGiaCong: DigitalPrintGiaCong[] = digitalGiaCongRows.map((r) => ({
|
| 78 |
+
id: r.id as number,
|
| 79 |
+
machineName: r.machine_name as string,
|
| 80 |
+
sheetSize: r.sheet_size as string,
|
| 81 |
+
widthMm: r.width_mm as number,
|
| 82 |
+
heightMm: r.height_mm as number,
|
| 83 |
+
paperLt249: r.paper_lt_249 as number,
|
| 84 |
+
paper250To349: r.paper_250_349 as number,
|
| 85 |
+
paper350To450: r.paper_350_450 as number,
|
| 86 |
+
decalPaperPlastic: r.decal_paper_plastic as number,
|
| 87 |
+
decalClear: r.decal_clear as number,
|
| 88 |
+
syntheticPaper: r.synthetic_paper as number,
|
| 89 |
+
pvcPlastic: r.pvc_plastic as number,
|
| 90 |
+
}));
|
| 91 |
+
|
| 92 |
+
// Lấy bảng giá in nhanh kèm giấy (INTC)
|
| 93 |
+
const digitalKemGiayRows = db.prepare('SELECT * FROM digital_pricing_kem_giay ORDER BY id ASC').all() as Record<string, unknown>[];
|
| 94 |
+
const digitalPricingKemGiay: DigitalPrintKemGiay[] = digitalKemGiayRows.map((r) => ({
|
| 95 |
+
id: r.id as number,
|
| 96 |
+
paperCode: r.paper_code as string,
|
| 97 |
+
paperName: r.paper_name as string,
|
| 98 |
+
gsm: r.gsm as number,
|
| 99 |
+
sheetSize: r.sheet_size as string,
|
| 100 |
+
widthMm: r.width_mm as number,
|
| 101 |
+
heightMm: r.height_mm as number,
|
| 102 |
+
price1Side: r.price_1side as number,
|
| 103 |
+
price2Side: r.price_2side as number,
|
| 104 |
+
}));
|
| 105 |
+
|
| 106 |
// Lấy danh mục dịch vụ gia công
|
| 107 |
const finishingRows = db.prepare('SELECT * FROM finishing_services WHERE is_active = 1').all() as Record<string, unknown>[];
|
| 108 |
const allFinishingServices: FinishingService[] = finishingRows.map((r) => ({
|
|
|
|
| 155 |
offsetWorkType: (rawBody.offsetWorkType as CalculationInput['offsetWorkType']) || 'self_turn',
|
| 156 |
offsetMachineId: rawBody.offsetMachineId ? Number(rawBody.offsetMachineId) : undefined,
|
| 157 |
digitalMachineId: rawBody.digitalMachineId ? Number(rawBody.digitalMachineId) : undefined,
|
| 158 |
+
digitalMode: (rawBody.digitalMode as CalculationInput['digitalMode']) || 'with_paper',
|
| 159 |
+
customerSuppliedPaper: Boolean(rawBody.customerSuppliedPaper),
|
| 160 |
+
whiteInk: Boolean(rawBody.whiteInk),
|
| 161 |
selectedFinishing: sanitizedFinishing,
|
| 162 |
profitMarginPercent: Number(rawBody.profitMarginPercent) || 0,
|
| 163 |
discountAmount: Number(rawBody.discountAmount) || 0,
|
|
|
|
| 171 |
allOffsetMachines,
|
| 172 |
digitalMachine,
|
| 173 |
allFinishingServices,
|
| 174 |
+
digitalPricingGiaCong,
|
| 175 |
+
digitalPricingKemGiay,
|
| 176 |
});
|
| 177 |
|
| 178 |
return NextResponse.json(result);
|
src/app/api/machines/route.ts
CHANGED
|
@@ -37,7 +37,42 @@ export async function GET() {
|
|
| 37 |
minCharge: r.min_charge as number,
|
| 38 |
}));
|
| 39 |
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
} catch (err: unknown) {
|
| 42 |
return NextResponse.json({ error: (err as Error).message }, { status: 500 });
|
| 43 |
}
|
|
@@ -85,6 +120,33 @@ export async function PUT(req: NextRequest) {
|
|
| 85 |
body.minCharge,
|
| 86 |
body.id
|
| 87 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
}
|
| 89 |
|
| 90 |
return NextResponse.json({ success: true });
|
|
|
|
| 37 |
minCharge: r.min_charge as number,
|
| 38 |
}));
|
| 39 |
|
| 40 |
+
const digitalGiaCongRows = db.prepare('SELECT * FROM digital_pricing_giacong ORDER BY id ASC').all() as Record<string, unknown>[];
|
| 41 |
+
const digitalKemGiayRows = db.prepare('SELECT * FROM digital_pricing_kem_giay ORDER BY id ASC').all() as Record<string, unknown>[];
|
| 42 |
+
|
| 43 |
+
const digitalPricingGiaCong = digitalGiaCongRows.map((r) => ({
|
| 44 |
+
id: r.id as number,
|
| 45 |
+
machineName: r.machine_name as string,
|
| 46 |
+
sheetSize: r.sheet_size as string,
|
| 47 |
+
widthMm: r.width_mm as number,
|
| 48 |
+
heightMm: r.height_mm as number,
|
| 49 |
+
paperLt249: r.paper_lt_249 as number,
|
| 50 |
+
paper250To349: r.paper_250_349 as number,
|
| 51 |
+
paper350To450: r.paper_350_450 as number,
|
| 52 |
+
decalPaperPlastic: r.decal_paper_plastic as number,
|
| 53 |
+
decalClear: r.decal_clear as number,
|
| 54 |
+
syntheticPaper: r.synthetic_paper as number,
|
| 55 |
+
pvcPlastic: r.pvc_plastic as number,
|
| 56 |
+
}));
|
| 57 |
+
|
| 58 |
+
const digitalPricingKemGiay = digitalKemGiayRows.map((r) => ({
|
| 59 |
+
id: r.id as number,
|
| 60 |
+
paperCode: r.paper_code as string,
|
| 61 |
+
paperName: r.paper_name as string,
|
| 62 |
+
gsm: r.gsm as number,
|
| 63 |
+
sheetSize: r.sheet_size as string,
|
| 64 |
+
widthMm: r.width_mm as number,
|
| 65 |
+
heightMm: r.height_mm as number,
|
| 66 |
+
price1Side: r.price_1side as number,
|
| 67 |
+
price2Side: r.price_2side as number,
|
| 68 |
+
}));
|
| 69 |
+
|
| 70 |
+
return NextResponse.json({
|
| 71 |
+
offsetMachines,
|
| 72 |
+
digitalMachines,
|
| 73 |
+
digitalPricingGiaCong,
|
| 74 |
+
digitalPricingKemGiay,
|
| 75 |
+
});
|
| 76 |
} catch (err: unknown) {
|
| 77 |
return NextResponse.json({ error: (err as Error).message }, { status: 500 });
|
| 78 |
}
|
|
|
|
| 120 |
body.minCharge,
|
| 121 |
body.id
|
| 122 |
);
|
| 123 |
+
} else if (body.type === 'digital_giacong') {
|
| 124 |
+
const stmt = db.prepare(`
|
| 125 |
+
UPDATE digital_pricing_giacong
|
| 126 |
+
SET paper_lt_249 = ?, paper_250_349 = ?, paper_350_450 = ?, decal_paper_plastic = ?, decal_clear = ?, synthetic_paper = ?, pvc_plastic = ?, updated_at = CURRENT_TIMESTAMP
|
| 127 |
+
WHERE id = ?
|
| 128 |
+
`);
|
| 129 |
+
stmt.run(
|
| 130 |
+
body.paperLt249,
|
| 131 |
+
body.paper250To349,
|
| 132 |
+
body.paper350To450,
|
| 133 |
+
body.decalPaperPlastic,
|
| 134 |
+
body.decalClear,
|
| 135 |
+
body.syntheticPaper,
|
| 136 |
+
body.pvcPlastic,
|
| 137 |
+
body.id
|
| 138 |
+
);
|
| 139 |
+
} else if (body.type === 'digital_kem_giay') {
|
| 140 |
+
const stmt = db.prepare(`
|
| 141 |
+
UPDATE digital_pricing_kem_giay
|
| 142 |
+
SET price_1side = ?, price_2side = ?, updated_at = CURRENT_TIMESTAMP
|
| 143 |
+
WHERE id = ?
|
| 144 |
+
`);
|
| 145 |
+
stmt.run(
|
| 146 |
+
body.price1Side,
|
| 147 |
+
body.price2Side,
|
| 148 |
+
body.id
|
| 149 |
+
);
|
| 150 |
}
|
| 151 |
|
| 152 |
return NextResponse.json({ success: true });
|
src/app/page.tsx
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
| 17 |
CalculationInput,
|
| 18 |
CalculationResult,
|
| 19 |
DigitalMachine,
|
|
|
|
| 20 |
FinishingService,
|
| 21 |
OffsetMachine,
|
| 22 |
PaperType,
|
|
@@ -55,6 +56,9 @@ export default function HomePage() {
|
|
| 55 |
const [jobName, setJobName] = useState('In Tờ Rơi A4');
|
| 56 |
const [productType, setProductType] = useState<ProductType>('to_roi');
|
| 57 |
const [printTech, setPrintTech] = useState<PrintTech>('auto');
|
|
|
|
|
|
|
|
|
|
| 58 |
const [quantity, setQuantity] = useState(1000);
|
| 59 |
const [widthMm, setWidthMm] = useState(210);
|
| 60 |
const [heightMm, setHeightMm] = useState(297);
|
|
@@ -143,6 +147,9 @@ export default function HomePage() {
|
|
| 143 |
colorsFront: Number(colorsFront) || 4,
|
| 144 |
colorsBack: printSides === '2_side' ? (Number(colorsBack) || 4) : 0,
|
| 145 |
offsetWorkType,
|
|
|
|
|
|
|
|
|
|
| 146 |
selectedFinishing,
|
| 147 |
profitMarginPercent: Number(profitMarginPercent) || 0,
|
| 148 |
discountAmount: Number(discountAmount) || 0,
|
|
@@ -168,6 +175,9 @@ export default function HomePage() {
|
|
| 168 |
jobName,
|
| 169 |
productType,
|
| 170 |
printTech,
|
|
|
|
|
|
|
|
|
|
| 171 |
quantity,
|
| 172 |
widthMm,
|
| 173 |
heightMm,
|
|
@@ -591,6 +601,74 @@ export default function HomePage() {
|
|
| 591 |
</div>
|
| 592 |
</div>
|
| 593 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 594 |
</div>
|
| 595 |
</div>
|
| 596 |
|
|
|
|
| 17 |
CalculationInput,
|
| 18 |
CalculationResult,
|
| 19 |
DigitalMachine,
|
| 20 |
+
DigitalMode,
|
| 21 |
FinishingService,
|
| 22 |
OffsetMachine,
|
| 23 |
PaperType,
|
|
|
|
| 56 |
const [jobName, setJobName] = useState('In Tờ Rơi A4');
|
| 57 |
const [productType, setProductType] = useState<ProductType>('to_roi');
|
| 58 |
const [printTech, setPrintTech] = useState<PrintTech>('auto');
|
| 59 |
+
const [digitalMode, setDigitalMode] = useState<DigitalMode>('with_paper');
|
| 60 |
+
const [customerSuppliedPaper, setCustomerSuppliedPaper] = useState(false);
|
| 61 |
+
const [whiteInk, setWhiteInk] = useState(false);
|
| 62 |
const [quantity, setQuantity] = useState(1000);
|
| 63 |
const [widthMm, setWidthMm] = useState(210);
|
| 64 |
const [heightMm, setHeightMm] = useState(297);
|
|
|
|
| 147 |
colorsFront: Number(colorsFront) || 4,
|
| 148 |
colorsBack: printSides === '2_side' ? (Number(colorsBack) || 4) : 0,
|
| 149 |
offsetWorkType,
|
| 150 |
+
digitalMode,
|
| 151 |
+
customerSuppliedPaper,
|
| 152 |
+
whiteInk,
|
| 153 |
selectedFinishing,
|
| 154 |
profitMarginPercent: Number(profitMarginPercent) || 0,
|
| 155 |
discountAmount: Number(discountAmount) || 0,
|
|
|
|
| 175 |
jobName,
|
| 176 |
productType,
|
| 177 |
printTech,
|
| 178 |
+
digitalMode,
|
| 179 |
+
customerSuppliedPaper,
|
| 180 |
+
whiteInk,
|
| 181 |
quantity,
|
| 182 |
widthMm,
|
| 183 |
heightMm,
|
|
|
|
| 601 |
</div>
|
| 602 |
</div>
|
| 603 |
</div>
|
| 604 |
+
|
| 605 |
+
{/* Tùy chọn in nhanh KTS (Kèm giấy INTC vs Gia công Konica C12000) */}
|
| 606 |
+
{(printTech === 'digital' || printTech === 'auto') && (
|
| 607 |
+
<div className="mt-3 p-3 bg-sky-50/70 border border-sky-200/80 rounded-xl space-y-2.5">
|
| 608 |
+
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-1.5">
|
| 609 |
+
<span className="text-xs font-bold text-sky-950 flex items-center gap-1.5">
|
| 610 |
+
<Sparkles className="w-3.5 h-3.5 text-sky-600" />
|
| 611 |
+
Chế độ in nhanh KTS:
|
| 612 |
+
</span>
|
| 613 |
+
<div className="flex gap-1 bg-white p-0.5 rounded-lg border border-sky-200 text-xs">
|
| 614 |
+
<button
|
| 615 |
+
type="button"
|
| 616 |
+
onClick={() => setDigitalMode('with_paper')}
|
| 617 |
+
className={`px-2.5 py-1 rounded font-semibold text-[11px] transition-colors ${
|
| 618 |
+
digitalMode === 'with_paper'
|
| 619 |
+
? 'bg-sky-600 text-white shadow-2xs'
|
| 620 |
+
: 'text-slate-600 hover:text-slate-900'
|
| 621 |
+
}`}
|
| 622 |
+
>
|
| 623 |
+
📦 Kèm giấy (INTC)
|
| 624 |
+
</button>
|
| 625 |
+
<button
|
| 626 |
+
type="button"
|
| 627 |
+
onClick={() => setDigitalMode('without_paper')}
|
| 628 |
+
className={`px-2.5 py-1 rounded font-semibold text-[11px] transition-colors ${
|
| 629 |
+
digitalMode === 'without_paper'
|
| 630 |
+
? 'bg-sky-600 text-white shadow-2xs'
|
| 631 |
+
: 'text-slate-600 hover:text-slate-900'
|
| 632 |
+
}`}
|
| 633 |
+
>
|
| 634 |
+
⚙️ In gia công (Konica C12000)
|
| 635 |
+
</button>
|
| 636 |
+
</div>
|
| 637 |
+
</div>
|
| 638 |
+
|
| 639 |
+
{digitalMode === 'with_paper' ? (
|
| 640 |
+
<div className="text-[11px] text-sky-800 bg-white/70 p-2 rounded-lg border border-sky-100">
|
| 641 |
+
<strong>Bảng giá INTC (14.03.2026):</strong> Trọn gói công in + giấy theo mã giấy. Khổ in 325×430 & 325×355mm. Phụ phí SL ít: <50 tờ (+30k), <100 tờ (+20k), ≥100 tờ miễn phí.
|
| 642 |
+
</div>
|
| 643 |
+
) : (
|
| 644 |
+
<div className="text-[11px] text-sky-800 bg-white/70 p-2 rounded-lg border border-sky-100 space-y-2">
|
| 645 |
+
<div>
|
| 646 |
+
<strong>Bảng giá Konica C12000 (Trang 2 PDF 26.02.2026):</strong> 5 khổ in (A4, 330×355, A3, 330×483, 330×1200) cho 7 nhóm chất liệu. In 2 mặt ×2. Phụ phí SL ít: <50 tờ (+30k), <100 tờ (+20k).
|
| 647 |
+
</div>
|
| 648 |
+
<div className="flex flex-wrap gap-4 text-xs font-semibold text-slate-800 pt-1 border-t border-sky-100">
|
| 649 |
+
<label className="flex items-center gap-1.5 cursor-pointer">
|
| 650 |
+
<input
|
| 651 |
+
type="checkbox"
|
| 652 |
+
checked={customerSuppliedPaper}
|
| 653 |
+
onChange={(e) => setCustomerSuppliedPaper(e.target.checked)}
|
| 654 |
+
className="rounded text-sky-600 focus:ring-sky-500 w-3.5 h-3.5"
|
| 655 |
+
/>
|
| 656 |
+
<span>Khách tự cấp giấy (Tiền giấy = 0đ)</span>
|
| 657 |
+
</label>
|
| 658 |
+
<label className="flex items-center gap-1.5 cursor-pointer">
|
| 659 |
+
<input
|
| 660 |
+
type="checkbox"
|
| 661 |
+
checked={whiteInk}
|
| 662 |
+
onChange={(e) => setWhiteInk(e.target.checked)}
|
| 663 |
+
className="rounded text-sky-600 focus:ring-sky-500 w-3.5 h-3.5"
|
| 664 |
+
/>
|
| 665 |
+
<span>In mực trắng (5 màu)</span>
|
| 666 |
+
</label>
|
| 667 |
+
</div>
|
| 668 |
+
</div>
|
| 669 |
+
)}
|
| 670 |
+
</div>
|
| 671 |
+
)}
|
| 672 |
</div>
|
| 673 |
</div>
|
| 674 |
|
src/components/CostBreakdownCard.tsx
CHANGED
|
@@ -112,17 +112,52 @@ ${costs.finishingDetails.length > 0 ? `• Gia công: ${costs.finishingDetails.m
|
|
| 112 |
<Layers className="w-3.5 h-3.5 text-blue-600" />
|
| 113 |
Tiền giấy ({quantities.parentSheetsNeeded} tờ mẹ):
|
| 114 |
</span>
|
| 115 |
-
<span className="font-semibold text-slate-900">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
</div>
|
| 117 |
|
| 118 |
<div className="py-2 flex justify-between items-center">
|
| 119 |
<span className="flex items-center gap-1.5 text-slate-600">
|
| 120 |
<Sparkles className="w-3.5 h-3.5 text-indigo-600" />
|
| 121 |
-
Tiền in {chosenTech === 'offset' ? (costs.plateCost === 0 ? '(Gói trọn gồm kẽm)' : `(${costs.platesCount} kẽm + công)`) :
|
| 122 |
</span>
|
| 123 |
<span className="font-semibold text-slate-900">{costs.totalPrintCost.toLocaleString('vi-VN')}đ</span>
|
| 124 |
</div>
|
| 125 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
<div className="py-2 flex justify-between items-center">
|
| 127 |
<span className="flex items-center gap-1.5 text-slate-600">
|
| 128 |
<Scissors className="w-3.5 h-3.5 text-purple-600" />
|
|
|
|
| 112 |
<Layers className="w-3.5 h-3.5 text-blue-600" />
|
| 113 |
Tiền giấy ({quantities.parentSheetsNeeded} tờ mẹ):
|
| 114 |
</span>
|
| 115 |
+
<span className="font-semibold text-slate-900">
|
| 116 |
+
{costs.paperCost === 0 && costs.digitalDetails?.mode === 'with_paper' ? (
|
| 117 |
+
<span className="text-emerald-600 font-bold text-xs bg-emerald-50 px-2 py-0.5 rounded">
|
| 118 |
+
Đã gồm trong giá in
|
| 119 |
+
</span>
|
| 120 |
+
) : costs.paperCost === 0 && input.customerSuppliedPaper ? (
|
| 121 |
+
<span className="text-emerald-600 font-bold text-xs bg-emerald-50 px-2 py-0.5 rounded">
|
| 122 |
+
Khách tự cấp giấy
|
| 123 |
+
</span>
|
| 124 |
+
) : (
|
| 125 |
+
`${costs.paperCost.toLocaleString('vi-VN')}đ`
|
| 126 |
+
)}
|
| 127 |
+
</span>
|
| 128 |
</div>
|
| 129 |
|
| 130 |
<div className="py-2 flex justify-between items-center">
|
| 131 |
<span className="flex items-center gap-1.5 text-slate-600">
|
| 132 |
<Sparkles className="w-3.5 h-3.5 text-indigo-600" />
|
| 133 |
+
Tiền in {chosenTech === 'offset' ? (costs.plateCost === 0 ? '(Gói trọn gồm kẽm)' : `(${costs.platesCount} kẽm + công)`) : `(${costs.digitalDetails?.mode === 'with_paper' ? 'Kèm giấy INTC' : 'Gia công Konica'})`}:
|
| 134 |
</span>
|
| 135 |
<span className="font-semibold text-slate-900">{costs.totalPrintCost.toLocaleString('vi-VN')}đ</span>
|
| 136 |
</div>
|
| 137 |
|
| 138 |
+
{chosenTech === 'digital' && costs.digitalDetails && (
|
| 139 |
+
<div className="py-2 px-3 bg-sky-50/80 border border-sky-100 rounded-xl text-[11px] text-sky-900 space-y-1 my-1">
|
| 140 |
+
<div className="flex justify-between font-bold">
|
| 141 |
+
<span>
|
| 142 |
+
{costs.digitalDetails.mode === 'with_paper'
|
| 143 |
+
? '📦 In kèm giấy (INTC 14.03)'
|
| 144 |
+
: '⚙️ In gia công (Konica C12000)'}
|
| 145 |
+
</span>
|
| 146 |
+
<span className="text-sky-700">Khổ: {costs.digitalDetails.sheetSizeName || imposition.printSheet.name}</span>
|
| 147 |
+
</div>
|
| 148 |
+
<div className="flex justify-between text-slate-600">
|
| 149 |
+
<span>Đơn giá: {costs.digitalDetails.ratePerSheet.toLocaleString('vi-VN')}đ / tờ in</span>
|
| 150 |
+
{costs.shortRunFee && costs.shortRunFee > 0 ? (
|
| 151 |
+
<span className="font-bold text-amber-700 bg-amber-50 px-1.5 py-0.5 rounded">
|
| 152 |
+
Phụ phí SL ít: +{costs.shortRunFee.toLocaleString('vi-VN')}đ
|
| 153 |
+
</span>
|
| 154 |
+
) : (
|
| 155 |
+
<span className="text-emerald-700 font-semibold">Miễn phụ phí</span>
|
| 156 |
+
)}
|
| 157 |
+
</div>
|
| 158 |
+
</div>
|
| 159 |
+
)}
|
| 160 |
+
|
| 161 |
<div className="py-2 flex justify-between items-center">
|
| 162 |
<span className="flex items-center gap-1.5 text-slate-600">
|
| 163 |
<Scissors className="w-3.5 h-3.5 text-purple-600" />
|
src/components/MachineManager.tsx
CHANGED
|
@@ -1,15 +1,24 @@
|
|
| 1 |
'use client';
|
| 2 |
|
| 3 |
import React, { useState, useEffect } from 'react';
|
| 4 |
-
import { DigitalMachine, OffsetMachine } from '@/types';
|
| 5 |
-
import { Cpu, Printer, Edit2, Check, RefreshCw } from 'lucide-react';
|
| 6 |
|
| 7 |
export const MachineManager: React.FC = () => {
|
|
|
|
| 8 |
const [offsetMachines, setOffsetMachines] = useState<OffsetMachine[]>([]);
|
| 9 |
const [digitalMachines, setDigitalMachines] = useState<DigitalMachine[]>([]);
|
|
|
|
|
|
|
| 10 |
const [loading, setLoading] = useState(true);
|
|
|
|
|
|
|
| 11 |
const [editingOffset, setEditingOffset] = useState<OffsetMachine | null>(null);
|
| 12 |
-
const [
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
const fetchMachines = async () => {
|
| 15 |
try {
|
|
@@ -18,6 +27,8 @@ export const MachineManager: React.FC = () => {
|
|
| 18 |
const data = await res.json();
|
| 19 |
if (data.offsetMachines) setOffsetMachines(data.offsetMachines);
|
| 20 |
if (data.digitalMachines) setDigitalMachines(data.digitalMachines);
|
|
|
|
|
|
|
| 21 |
} catch (err) {
|
| 22 |
console.error(err);
|
| 23 |
} finally {
|
|
@@ -45,15 +56,31 @@ export const MachineManager: React.FC = () => {
|
|
| 45 |
}
|
| 46 |
};
|
| 47 |
|
| 48 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
try {
|
| 50 |
const res = await fetch('/api/machines', {
|
| 51 |
method: 'PUT',
|
| 52 |
headers: { 'Content-Type': 'application/json' },
|
| 53 |
-
body: JSON.stringify({ type: '
|
| 54 |
});
|
| 55 |
if (res.ok) {
|
| 56 |
-
|
| 57 |
fetchMachines();
|
| 58 |
}
|
| 59 |
} catch (err) {
|
|
@@ -61,301 +88,597 @@ export const MachineManager: React.FC = () => {
|
|
| 61 |
}
|
| 62 |
};
|
| 63 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
return (
|
| 65 |
-
<div className="space-y-
|
| 66 |
-
{/*
|
| 67 |
-
<div className="bg-white rounded-
|
| 68 |
-
<div
|
| 69 |
-
<
|
| 70 |
-
<
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
</div>
|
| 76 |
-
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
</button>
|
| 79 |
</div>
|
|
|
|
| 80 |
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
</div>
|
|
|
|
| 97 |
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
<input
|
| 110 |
-
type="number"
|
| 111 |
-
className="w-1/2 p-1 border rounded text-right"
|
| 112 |
-
value={current.maxHeightMm}
|
| 113 |
-
onChange={(e) => setEditingOffset({ ...current, maxHeightMm: Number(e.target.value) })}
|
| 114 |
-
/>
|
| 115 |
-
</div>
|
| 116 |
-
) : (
|
| 117 |
-
<span className="font-medium text-slate-800">{current.maxWidthMm} × {current.maxHeightMm} mm</span>
|
| 118 |
-
)}
|
| 119 |
-
</div>
|
| 120 |
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
</
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
<span className="font-bold text-slate-800">{current.setupCost.toLocaleString('vi-VN')}đ</span>
|
| 147 |
-
{current.includesPlate && (
|
| 148 |
-
<span className="text-[10px] font-semibold text-emerald-600 block">Đã gồm kẽm</span>
|
| 149 |
)}
|
| 150 |
-
</
|
| 151 |
-
)}
|
| 152 |
-
</div>
|
| 153 |
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
</
|
| 181 |
-
)}
|
| 182 |
-
</div>
|
| 183 |
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
</div>
|
| 212 |
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
</div>
|
| 233 |
-
|
| 234 |
|
| 235 |
-
{/*
|
| 236 |
-
|
| 237 |
-
<div className="
|
| 238 |
-
<div className="
|
| 239 |
-
<
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
</div>
|
| 244 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
</div>
|
|
|
|
| 246 |
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
{isEditing ? (
|
| 269 |
<input
|
| 270 |
type="number"
|
| 271 |
-
className="w-
|
| 272 |
-
value={current.
|
| 273 |
-
onChange={(e) =>
|
| 274 |
/>
|
| 275 |
) : (
|
| 276 |
-
<
|
| 277 |
)}
|
| 278 |
</div>
|
| 279 |
-
|
| 280 |
-
|
|
|
|
| 281 |
{isEditing ? (
|
| 282 |
<input
|
| 283 |
type="number"
|
| 284 |
-
className="w-
|
| 285 |
-
value={current.
|
| 286 |
-
onChange={(e) =>
|
| 287 |
/>
|
| 288 |
) : (
|
| 289 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
)}
|
| 291 |
</div>
|
| 292 |
-
</div>
|
| 293 |
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
<span className="text-slate-400 block text-[10px]">Click A3/A3+ 1 mặt</span>
|
| 297 |
{isEditing ? (
|
| 298 |
<input
|
| 299 |
type="number"
|
| 300 |
-
className="w-
|
| 301 |
-
value={current.
|
| 302 |
-
onChange={(e) =>
|
| 303 |
/>
|
| 304 |
) : (
|
| 305 |
-
<
|
| 306 |
)}
|
| 307 |
</div>
|
| 308 |
-
|
| 309 |
-
|
|
|
|
| 310 |
{isEditing ? (
|
| 311 |
<input
|
| 312 |
type="number"
|
| 313 |
-
className="w-
|
| 314 |
-
value={current.
|
| 315 |
-
onChange={(e) =>
|
| 316 |
/>
|
| 317 |
) : (
|
| 318 |
-
<
|
| 319 |
)}
|
| 320 |
</div>
|
| 321 |
-
</div>
|
| 322 |
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
|
|
|
| 335 |
</div>
|
| 336 |
-
</div>
|
| 337 |
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
|
|
|
| 357 |
</div>
|
| 358 |
-
|
| 359 |
</div>
|
| 360 |
);
|
| 361 |
};
|
|
|
|
| 1 |
'use client';
|
| 2 |
|
| 3 |
import React, { useState, useEffect } from 'react';
|
| 4 |
+
import { DigitalMachine, DigitalPrintGiaCong, DigitalPrintKemGiay, OffsetMachine } from '@/types';
|
| 5 |
+
import { Cpu, Printer, Edit2, Check, RefreshCw, Layers, FileText, Info, Save, X } from 'lucide-react';
|
| 6 |
|
| 7 |
export const MachineManager: React.FC = () => {
|
| 8 |
+
const [activeTab, setActiveTab] = useState<'giacong' | 'kem_giay' | 'offset'>('giacong');
|
| 9 |
const [offsetMachines, setOffsetMachines] = useState<OffsetMachine[]>([]);
|
| 10 |
const [digitalMachines, setDigitalMachines] = useState<DigitalMachine[]>([]);
|
| 11 |
+
const [digitalPricingGiaCong, setDigitalPricingGiaCong] = useState<DigitalPrintGiaCong[]>([]);
|
| 12 |
+
const [digitalPricingKemGiay, setDigitalPricingKemGiay] = useState<DigitalPrintKemGiay[]>([]);
|
| 13 |
const [loading, setLoading] = useState(true);
|
| 14 |
+
|
| 15 |
+
// Editing state
|
| 16 |
const [editingOffset, setEditingOffset] = useState<OffsetMachine | null>(null);
|
| 17 |
+
const [editingGiaCong, setEditingGiaCong] = useState<DigitalPrintGiaCong | null>(null);
|
| 18 |
+
const [editingKemGiay, setEditingKemGiay] = useState<DigitalPrintKemGiay | null>(null);
|
| 19 |
+
|
| 20 |
+
// Filter for INTC paper
|
| 21 |
+
const [paperFilter, setPaperFilter] = useState<'all' | 'C' | 'I' | 'F'>('all');
|
| 22 |
|
| 23 |
const fetchMachines = async () => {
|
| 24 |
try {
|
|
|
|
| 27 |
const data = await res.json();
|
| 28 |
if (data.offsetMachines) setOffsetMachines(data.offsetMachines);
|
| 29 |
if (data.digitalMachines) setDigitalMachines(data.digitalMachines);
|
| 30 |
+
if (data.digitalPricingGiaCong) setDigitalPricingGiaCong(data.digitalPricingGiaCong);
|
| 31 |
+
if (data.digitalPricingKemGiay) setDigitalPricingKemGiay(data.digitalPricingKemGiay);
|
| 32 |
} catch (err) {
|
| 33 |
console.error(err);
|
| 34 |
} finally {
|
|
|
|
| 56 |
}
|
| 57 |
};
|
| 58 |
|
| 59 |
+
const handleSaveGiaCong = async (item: DigitalPrintGiaCong) => {
|
| 60 |
+
try {
|
| 61 |
+
const res = await fetch('/api/machines', {
|
| 62 |
+
method: 'PUT',
|
| 63 |
+
headers: { 'Content-Type': 'application/json' },
|
| 64 |
+
body: JSON.stringify({ type: 'digital_giacong', ...item }),
|
| 65 |
+
});
|
| 66 |
+
if (res.ok) {
|
| 67 |
+
setEditingGiaCong(null);
|
| 68 |
+
fetchMachines();
|
| 69 |
+
}
|
| 70 |
+
} catch (err) {
|
| 71 |
+
console.error(err);
|
| 72 |
+
}
|
| 73 |
+
};
|
| 74 |
+
|
| 75 |
+
const handleSaveKemGiay = async (item: DigitalPrintKemGiay) => {
|
| 76 |
try {
|
| 77 |
const res = await fetch('/api/machines', {
|
| 78 |
method: 'PUT',
|
| 79 |
headers: { 'Content-Type': 'application/json' },
|
| 80 |
+
body: JSON.stringify({ type: 'digital_kem_giay', ...item }),
|
| 81 |
});
|
| 82 |
if (res.ok) {
|
| 83 |
+
setEditingKemGiay(null);
|
| 84 |
fetchMachines();
|
| 85 |
}
|
| 86 |
} catch (err) {
|
|
|
|
| 88 |
}
|
| 89 |
};
|
| 90 |
|
| 91 |
+
const filteredKemGiay = digitalPricingKemGiay.filter((item) => {
|
| 92 |
+
if (paperFilter === 'all') return true;
|
| 93 |
+
if (paperFilter === 'C') return item.paperCode.startsWith('C');
|
| 94 |
+
if (paperFilter === 'I') return item.paperCode.startsWith('I');
|
| 95 |
+
if (paperFilter === 'F') return item.paperCode.startsWith('F');
|
| 96 |
+
return true;
|
| 97 |
+
});
|
| 98 |
+
|
| 99 |
return (
|
| 100 |
+
<div className="space-y-5">
|
| 101 |
+
{/* Top Header & Navigation Tabs */}
|
| 102 |
+
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-white p-4 rounded-2xl border border-slate-200 shadow-2xs">
|
| 103 |
+
<div>
|
| 104 |
+
<h2 className="text-base font-bold text-slate-900 flex items-center gap-2">
|
| 105 |
+
<Printer className="w-5 h-5 text-blue-600" />
|
| 106 |
+
Quản Lý Bảng Giá In Ấn
|
| 107 |
+
</h2>
|
| 108 |
+
<p className="text-xs text-slate-500 mt-0.5">
|
| 109 |
+
Cập nhật biểu giá In nhanh gia công (Konica C12000), In nhanh kèm giấy (INTC) và Máy in Offset
|
| 110 |
+
</p>
|
| 111 |
+
</div>
|
| 112 |
+
|
| 113 |
+
<div className="flex items-center gap-2">
|
| 114 |
+
<div className="flex bg-slate-100 p-1 rounded-xl text-xs font-semibold">
|
| 115 |
+
<button
|
| 116 |
+
onClick={() => setActiveTab('giacong')}
|
| 117 |
+
className={`px-3 py-1.5 rounded-lg transition-all ${
|
| 118 |
+
activeTab === 'giacong'
|
| 119 |
+
? 'bg-white shadow text-sky-700 font-bold'
|
| 120 |
+
: 'text-slate-600 hover:text-slate-900'
|
| 121 |
+
}`}
|
| 122 |
+
>
|
| 123 |
+
⚙️ Konica C12000 (Gia Công)
|
| 124 |
+
</button>
|
| 125 |
+
<button
|
| 126 |
+
onClick={() => setActiveTab('kem_giay')}
|
| 127 |
+
className={`px-3 py-1.5 rounded-lg transition-all ${
|
| 128 |
+
activeTab === 'kem_giay'
|
| 129 |
+
? 'bg-white shadow text-indigo-700 font-bold'
|
| 130 |
+
: 'text-slate-600 hover:text-slate-900'
|
| 131 |
+
}`}
|
| 132 |
+
>
|
| 133 |
+
📦 INTC (Kèm Giấy)
|
| 134 |
+
</button>
|
| 135 |
+
<button
|
| 136 |
+
onClick={() => setActiveTab('offset')}
|
| 137 |
+
className={`px-3 py-1.5 rounded-lg transition-all ${
|
| 138 |
+
activeTab === 'offset'
|
| 139 |
+
? 'bg-white shadow text-blue-700 font-bold'
|
| 140 |
+
: 'text-slate-600 hover:text-slate-900'
|
| 141 |
+
}`}
|
| 142 |
+
>
|
| 143 |
+
🏭 Máy In Offset
|
| 144 |
+
</button>
|
| 145 |
</div>
|
| 146 |
+
|
| 147 |
+
<button
|
| 148 |
+
onClick={fetchMachines}
|
| 149 |
+
disabled={loading}
|
| 150 |
+
className="p-2 text-slate-500 hover:text-blue-600 rounded-lg hover:bg-slate-100 transition-colors"
|
| 151 |
+
title="Làm mới dữ liệu"
|
| 152 |
+
>
|
| 153 |
+
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
| 154 |
</button>
|
| 155 |
</div>
|
| 156 |
+
</div>
|
| 157 |
|
| 158 |
+
{/* TAB 1: BẢNG GIÁ IN GIA CÔNG KONICA C12000 (TRANG 2 PDF 26.02.2026) */}
|
| 159 |
+
{activeTab === 'giacong' && (
|
| 160 |
+
<div className="bg-white rounded-2xl border border-slate-200 shadow-2xs overflow-hidden space-y-4">
|
| 161 |
+
<div className="p-5 border-b border-slate-200 bg-gradient-to-r from-sky-50 to-blue-50/40">
|
| 162 |
+
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
| 163 |
+
<div>
|
| 164 |
+
<span className="px-2.5 py-0.5 bg-sky-600 text-white rounded-md text-[10px] font-bold uppercase tracking-wider">
|
| 165 |
+
Trang 2 PDF 26.02.2026
|
| 166 |
+
</span>
|
| 167 |
+
<h3 className="text-base font-black text-slate-900 mt-1">
|
| 168 |
+
MÁY IN KONICA MINOLTA C12000 / C12010S 5 MÀU
|
| 169 |
+
</h3>
|
| 170 |
+
<p className="text-xs text-slate-600 mt-0.5">
|
| 171 |
+
Đơn giá in gia công (chưa gồm giấy) theo 5 kích thước tiêu chuẩn & 7 nhóm chất liệu.
|
| 172 |
+
</p>
|
| 173 |
+
</div>
|
| 174 |
+
</div>
|
| 175 |
+
|
| 176 |
+
{/* Quy tắc phụ phí & lưu ý */}
|
| 177 |
+
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-2 text-xs">
|
| 178 |
+
<div className="bg-white/80 border border-sky-200 rounded-xl p-2.5 flex items-start gap-2">
|
| 179 |
+
<Info className="w-4 h-4 text-sky-600 shrink-0 mt-0.5" />
|
| 180 |
+
<div>
|
| 181 |
+
<strong className="text-sky-900 block font-semibold">Phụ phí số lượng ít:</strong>
|
| 182 |
+
<span className="text-slate-600 text-[11px]">
|
| 183 |
+
< 50 tờ: <strong>+30.000đ</strong> | < 100 tờ: <strong>+20.000đ</strong> | ≥ 100 tờ: <strong>Miễn phí</strong>
|
| 184 |
+
</span>
|
| 185 |
</div>
|
| 186 |
+
</div>
|
| 187 |
|
| 188 |
+
<div className="bg-white/80 border border-sky-200 rounded-xl p-2.5 flex items-start gap-2">
|
| 189 |
+
<Info className="w-4 h-4 text-indigo-600 shrink-0 mt-0.5" />
|
| 190 |
+
<div>
|
| 191 |
+
<strong className="text-indigo-900 block font-semibold">Quy ước tính mặt in:</strong>
|
| 192 |
+
<span className="text-slate-600 text-[11px]">
|
| 193 |
+
Đơn giá hiển thị dưới đây là đơn giá <strong>in 1 mặt</strong>. In 2 mặt được tính <strong>nhân đôi (×2)</strong>.
|
| 194 |
+
</span>
|
| 195 |
+
</div>
|
| 196 |
+
</div>
|
| 197 |
+
</div>
|
| 198 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
|
| 200 |
+
<div className="p-5 pt-0 overflow-x-auto">
|
| 201 |
+
<table className="w-full text-xs text-left border-collapse min-w-[760px]">
|
| 202 |
+
<thead>
|
| 203 |
+
<tr className="bg-slate-100 text-slate-700 font-bold border-b border-slate-200">
|
| 204 |
+
<th className="p-3 text-center w-36">Khổ in</th>
|
| 205 |
+
<th className="p-3 text-right">Giấy <249gsm</th>
|
| 206 |
+
<th className="p-3 text-right">Giấy 250-349gsm</th>
|
| 207 |
+
<th className="p-3 text-right">Giấy 350-450gsm</th>
|
| 208 |
+
<th className="p-3 text-right">Decal (Giấy-nhựa)</th>
|
| 209 |
+
<th className="p-3 text-right">Decal trong</th>
|
| 210 |
+
<th className="p-3 text-right">Giấy nhựa <280gsm</th>
|
| 211 |
+
<th className="p-3 text-right">Nhựa PVC</th>
|
| 212 |
+
<th className="p-3 text-center w-24">Thao tác</th>
|
| 213 |
+
</tr>
|
| 214 |
+
</thead>
|
| 215 |
+
<tbody className="divide-y divide-slate-100">
|
| 216 |
+
{digitalPricingGiaCong.map((row) => {
|
| 217 |
+
const isEditing = editingGiaCong?.id === row.id;
|
| 218 |
+
const cur = isEditing ? editingGiaCong : row;
|
| 219 |
+
|
| 220 |
+
return (
|
| 221 |
+
<tr key={row.id} className="hover:bg-slate-50/70 transition-colors">
|
| 222 |
+
<td className="p-3 text-center font-bold text-slate-800">
|
| 223 |
+
<span className="text-sm block">{row.sheetSize}</span>
|
| 224 |
+
<span className="text-[10px] text-slate-500 font-normal">
|
| 225 |
+
{row.widthMm} × {row.heightMm} mm
|
| 226 |
+
</span>
|
| 227 |
+
</td>
|
| 228 |
|
| 229 |
+
{/* Giấy <249gsm */}
|
| 230 |
+
<td className="p-2 text-right">
|
| 231 |
+
{isEditing ? (
|
| 232 |
+
<input
|
| 233 |
+
type="number"
|
| 234 |
+
className="w-20 p-1 border rounded text-right font-bold text-blue-700 text-xs"
|
| 235 |
+
value={cur.paperLt249}
|
| 236 |
+
onChange={(e) => setEditingGiaCong({ ...cur, paperLt249: Number(e.target.value) })}
|
| 237 |
+
/>
|
| 238 |
+
) : (
|
| 239 |
+
<span className="font-bold text-slate-800">{row.paperLt249.toLocaleString('vi-VN')}đ</span>
|
|
|
|
|
|
|
|
|
|
| 240 |
)}
|
| 241 |
+
</td>
|
|
|
|
|
|
|
| 242 |
|
| 243 |
+
{/* Giấy 250-349gsm */}
|
| 244 |
+
<td className="p-2 text-right">
|
| 245 |
+
{isEditing ? (
|
| 246 |
+
<input
|
| 247 |
+
type="number"
|
| 248 |
+
className="w-20 p-1 border rounded text-right font-bold text-blue-700 text-xs"
|
| 249 |
+
value={cur.paper250To349}
|
| 250 |
+
onChange={(e) => setEditingGiaCong({ ...cur, paper250To349: Number(e.target.value) })}
|
| 251 |
+
/>
|
| 252 |
+
) : (
|
| 253 |
+
<span className="font-bold text-slate-800">{row.paper250To349.toLocaleString('vi-VN')}đ</span>
|
| 254 |
+
)}
|
| 255 |
+
</td>
|
| 256 |
|
| 257 |
+
{/* Giấy 350-450gsm */}
|
| 258 |
+
<td className="p-2 text-right">
|
| 259 |
+
{isEditing ? (
|
| 260 |
+
<input
|
| 261 |
+
type="number"
|
| 262 |
+
className="w-20 p-1 border rounded text-right font-bold text-blue-700 text-xs"
|
| 263 |
+
value={cur.paper350To450}
|
| 264 |
+
onChange={(e) => setEditingGiaCong({ ...cur, paper350To450: Number(e.target.value) })}
|
| 265 |
+
/>
|
| 266 |
+
) : (
|
| 267 |
+
<span className="font-bold text-slate-800">{row.paper350To450.toLocaleString('vi-VN')}đ</span>
|
| 268 |
+
)}
|
| 269 |
+
</td>
|
|
|
|
|
|
|
| 270 |
|
| 271 |
+
{/* Decal giấy nhựa */}
|
| 272 |
+
<td className="p-2 text-right">
|
| 273 |
+
{isEditing ? (
|
| 274 |
+
<input
|
| 275 |
+
type="number"
|
| 276 |
+
className="w-20 p-1 border rounded text-right font-bold text-blue-700 text-xs"
|
| 277 |
+
value={cur.decalPaperPlastic}
|
| 278 |
+
onChange={(e) => setEditingGiaCong({ ...cur, decalPaperPlastic: Number(e.target.value) })}
|
| 279 |
+
/>
|
| 280 |
+
) : (
|
| 281 |
+
<span className="font-medium text-slate-700">{row.decalPaperPlastic.toLocaleString('vi-VN')}đ</span>
|
| 282 |
+
)}
|
| 283 |
+
</td>
|
| 284 |
|
| 285 |
+
{/* Decal trong */}
|
| 286 |
+
<td className="p-2 text-right">
|
| 287 |
+
{isEditing ? (
|
| 288 |
+
<input
|
| 289 |
+
type="number"
|
| 290 |
+
className="w-20 p-1 border rounded text-right font-bold text-blue-700 text-xs"
|
| 291 |
+
value={cur.decalClear}
|
| 292 |
+
onChange={(e) => setEditingGiaCong({ ...cur, decalClear: Number(e.target.value) })}
|
| 293 |
+
/>
|
| 294 |
+
) : (
|
| 295 |
+
<span className="font-medium text-slate-700">{row.decalClear.toLocaleString('vi-VN')}đ</span>
|
| 296 |
+
)}
|
| 297 |
+
</td>
|
|
|
|
| 298 |
|
| 299 |
+
{/* Giấy nhựa <280 */}
|
| 300 |
+
<td className="p-2 text-right">
|
| 301 |
+
{isEditing ? (
|
| 302 |
+
<input
|
| 303 |
+
type="number"
|
| 304 |
+
className="w-20 p-1 border rounded text-right font-bold text-blue-700 text-xs"
|
| 305 |
+
value={cur.syntheticPaper}
|
| 306 |
+
onChange={(e) => setEditingGiaCong({ ...cur, syntheticPaper: Number(e.target.value) })}
|
| 307 |
+
/>
|
| 308 |
+
) : (
|
| 309 |
+
<span className="font-medium text-slate-700">{row.syntheticPaper.toLocaleString('vi-VN')}đ</span>
|
| 310 |
+
)}
|
| 311 |
+
</td>
|
| 312 |
+
|
| 313 |
+
{/* Nhựa PVC */}
|
| 314 |
+
<td className="p-2 text-right">
|
| 315 |
+
{isEditing ? (
|
| 316 |
+
<input
|
| 317 |
+
type="number"
|
| 318 |
+
className="w-20 p-1 border rounded text-right font-bold text-blue-700 text-xs"
|
| 319 |
+
value={cur.pvcPlastic}
|
| 320 |
+
onChange={(e) => setEditingGiaCong({ ...cur, pvcPlastic: Number(e.target.value) })}
|
| 321 |
+
/>
|
| 322 |
+
) : (
|
| 323 |
+
<span className="font-medium text-slate-700">
|
| 324 |
+
{row.pvcPlastic > 0 ? `${row.pvcPlastic.toLocaleString('vi-VN')}đ` : '—'}
|
| 325 |
+
</span>
|
| 326 |
+
)}
|
| 327 |
+
</td>
|
| 328 |
+
|
| 329 |
+
{/* Nút hành động */}
|
| 330 |
+
<td className="p-2 text-center">
|
| 331 |
+
{isEditing ? (
|
| 332 |
+
<div className="flex items-center justify-center gap-1">
|
| 333 |
+
<button
|
| 334 |
+
onClick={() => handleSaveGiaCong(cur)}
|
| 335 |
+
className="p-1.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded shadow-xs"
|
| 336 |
+
title="Lưu thay đổi"
|
| 337 |
+
>
|
| 338 |
+
<Save className="w-3.5 h-3.5" />
|
| 339 |
+
</button>
|
| 340 |
+
<button
|
| 341 |
+
onClick={() => setEditingGiaCong(null)}
|
| 342 |
+
className="p-1.5 bg-slate-200 hover:bg-slate-300 text-slate-700 rounded"
|
| 343 |
+
title="Hủy"
|
| 344 |
+
>
|
| 345 |
+
<X className="w-3.5 h-3.5" />
|
| 346 |
+
</button>
|
| 347 |
+
</div>
|
| 348 |
+
) : (
|
| 349 |
+
<button
|
| 350 |
+
onClick={() => setEditingGiaCong({ ...row })}
|
| 351 |
+
className="p-1.5 text-slate-400 hover:text-sky-600 rounded hover:bg-sky-50 transition-colors"
|
| 352 |
+
title="Chỉnh sửa đơn giá hàng này"
|
| 353 |
+
>
|
| 354 |
+
<Edit2 className="w-3.5 h-3.5" />
|
| 355 |
+
</button>
|
| 356 |
+
)}
|
| 357 |
+
</td>
|
| 358 |
+
</tr>
|
| 359 |
+
);
|
| 360 |
+
})}
|
| 361 |
+
</tbody>
|
| 362 |
+
</table>
|
| 363 |
+
</div>
|
| 364 |
</div>
|
| 365 |
+
)}
|
| 366 |
|
| 367 |
+
{/* TAB 2: BẢNG GIÁ IN NHANH KÈM GIẤY INTC (PDF 14.03.2026) */}
|
| 368 |
+
{activeTab === 'kem_giay' && (
|
| 369 |
+
<div className="bg-white rounded-2xl border border-slate-200 shadow-2xs overflow-hidden space-y-4">
|
| 370 |
+
<div className="p-5 border-b border-slate-200 bg-gradient-to-r from-indigo-50 to-purple-50/40">
|
| 371 |
+
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
| 372 |
+
<div>
|
| 373 |
+
<span className="px-2.5 py-0.5 bg-indigo-600 text-white rounded-md text-[10px] font-bold uppercase tracking-wider">
|
| 374 |
+
Bảng Giá INTC 14.03.2026
|
| 375 |
+
</span>
|
| 376 |
+
<h3 className="text-base font-black text-slate-900 mt-1">
|
| 377 |
+
BẢNG GIÁ IN NHANH KÈM GIẤY (TRỌN GÓI)
|
| 378 |
+
</h3>
|
| 379 |
+
<p className="text-xs text-slate-600 mt-0.5">
|
| 380 |
+
Đơn giá trọn gói đã bao gồm tiền giấy và công in. Khổ in 325 × 430 mm và 325 × 355 mm.
|
| 381 |
+
</p>
|
| 382 |
+
</div>
|
| 383 |
+
|
| 384 |
+
{/* Bộ lọc loại giấy */}
|
| 385 |
+
<div className="flex items-center gap-1 bg-white p-1 rounded-xl border border-indigo-200 text-xs font-semibold">
|
| 386 |
+
<button
|
| 387 |
+
onClick={() => setPaperFilter('all')}
|
| 388 |
+
className={`px-2.5 py-1 rounded-lg ${paperFilter === 'all' ? 'bg-indigo-600 text-white' : 'text-slate-600'}`}
|
| 389 |
+
>
|
| 390 |
+
Tất cả ({digitalPricingKemGiay.length})
|
| 391 |
+
</button>
|
| 392 |
+
<button
|
| 393 |
+
onClick={() => setPaperFilter('C')}
|
| 394 |
+
className={`px-2.5 py-1 rounded-lg ${paperFilter === 'C' ? 'bg-indigo-600 text-white' : 'text-slate-600'}`}
|
| 395 |
+
>
|
| 396 |
+
Couche (6)
|
| 397 |
+
</button>
|
| 398 |
+
<button
|
| 399 |
+
onClick={() => setPaperFilter('I')}
|
| 400 |
+
className={`px-2.5 py-1 rounded-lg ${paperFilter === 'I' ? 'bg-indigo-600 text-white' : 'text-slate-600'}`}
|
| 401 |
+
>
|
| 402 |
+
Ivory (3)
|
| 403 |
+
</button>
|
| 404 |
+
<button
|
| 405 |
+
onClick={() => setPaperFilter('F')}
|
| 406 |
+
className={`px-2.5 py-1 rounded-lg ${paperFilter === 'F' ? 'bg-indigo-600 text-white' : 'text-slate-600'}`}
|
| 407 |
+
>
|
| 408 |
+
Fort (7)
|
| 409 |
+
</button>
|
| 410 |
+
</div>
|
| 411 |
+
</div>
|
| 412 |
+
|
| 413 |
+
{/* Phụ phí */}
|
| 414 |
+
<div className="mt-3 bg-white/80 border border-indigo-200 rounded-xl p-2.5 flex items-start gap-2 text-xs">
|
| 415 |
+
<Info className="w-4 h-4 text-indigo-600 shrink-0 mt-0.5" />
|
| 416 |
+
<div>
|
| 417 |
+
<strong className="text-indigo-900 block font-semibold">Quy định phụ phí số lượng ít:</strong>
|
| 418 |
+
<span className="text-slate-600 text-[11px]">
|
| 419 |
+
Số lượng < 50 tờ: <strong>+30.000đ</strong> | < 100 tờ: <strong>+20.000đ</strong> | ≥ 100 tờ: <strong>Miễn phí phụ phí</strong>
|
| 420 |
+
</span>
|
| 421 |
+
</div>
|
| 422 |
</div>
|
| 423 |
</div>
|
| 424 |
+
|
| 425 |
+
<div className="p-5 pt-0 overflow-x-auto">
|
| 426 |
+
<table className="w-full text-xs text-left border-collapse">
|
| 427 |
+
<thead>
|
| 428 |
+
<tr className="bg-slate-100 text-slate-700 font-bold border-b border-slate-200">
|
| 429 |
+
<th className="p-3 text-center w-20">Mã</th>
|
| 430 |
+
<th className="p-3 text-left">Chất liệu & Định lượng</th>
|
| 431 |
+
<th className="p-3 text-center w-32">Khổ in</th>
|
| 432 |
+
<th className="p-3 text-right w-36">In 1 mặt (+ Giấy)</th>
|
| 433 |
+
<th className="p-3 text-right w-36">In 2 mặt (+ Giấy)</th>
|
| 434 |
+
<th className="p-3 text-center w-24">Thao tác</th>
|
| 435 |
+
</tr>
|
| 436 |
+
</thead>
|
| 437 |
+
<tbody className="divide-y divide-slate-100">
|
| 438 |
+
{filteredKemGiay.map((row) => {
|
| 439 |
+
const isEditing = editingKemGiay?.id === row.id;
|
| 440 |
+
const cur = isEditing ? editingKemGiay : row;
|
| 441 |
+
|
| 442 |
+
return (
|
| 443 |
+
<tr key={row.id} className="hover:bg-slate-50/70 transition-colors">
|
| 444 |
+
<td className="p-3 text-center font-bold text-indigo-700">
|
| 445 |
+
<span className="px-2 py-0.5 bg-indigo-50 border border-indigo-200 rounded font-mono">
|
| 446 |
+
{row.paperCode}
|
| 447 |
+
</span>
|
| 448 |
+
</td>
|
| 449 |
+
|
| 450 |
+
<td className="p-3">
|
| 451 |
+
<strong className="text-slate-900 block text-xs">{row.paperName}</strong>
|
| 452 |
+
<span className="text-[11px] text-slate-500 font-medium">Định lượng: {row.gsm} gsm</span>
|
| 453 |
+
</td>
|
| 454 |
+
|
| 455 |
+
<td className="p-3 text-center font-semibold text-slate-700">
|
| 456 |
+
<span className="px-2 py-0.5 bg-slate-100 rounded text-[11px]">
|
| 457 |
+
{row.sheetSize} mm
|
| 458 |
+
</span>
|
| 459 |
+
</td>
|
| 460 |
+
|
| 461 |
+
{/* Đơn giá 1 mặt */}
|
| 462 |
+
<td className="p-3 text-right">
|
| 463 |
+
{isEditing ? (
|
| 464 |
+
<input
|
| 465 |
+
type="number"
|
| 466 |
+
className="w-24 p-1.5 border rounded text-right font-bold text-indigo-700 text-xs"
|
| 467 |
+
value={cur.price1Side}
|
| 468 |
+
onChange={(e) => setEditingKemGiay({ ...cur, price1Side: Number(e.target.value) })}
|
| 469 |
+
/>
|
| 470 |
+
) : (
|
| 471 |
+
<span className="font-bold text-slate-900 text-sm">
|
| 472 |
+
{row.price1Side.toLocaleString('vi-VN')}đ
|
| 473 |
+
</span>
|
| 474 |
+
)}
|
| 475 |
+
</td>
|
| 476 |
+
|
| 477 |
+
{/* Đơn giá 2 mặt */}
|
| 478 |
+
<td className="p-3 text-right">
|
| 479 |
+
{isEditing ? (
|
| 480 |
+
<input
|
| 481 |
+
type="number"
|
| 482 |
+
className="w-24 p-1.5 border rounded text-right font-bold text-purple-700 text-xs"
|
| 483 |
+
value={cur.price2Side}
|
| 484 |
+
onChange={(e) => setEditingKemGiay({ ...cur, price2Side: Number(e.target.value) })}
|
| 485 |
+
/>
|
| 486 |
+
) : (
|
| 487 |
+
<span className="font-bold text-purple-700 text-sm">
|
| 488 |
+
{row.price2Side.toLocaleString('vi-VN')}đ
|
| 489 |
+
</span>
|
| 490 |
+
)}
|
| 491 |
+
</td>
|
| 492 |
+
|
| 493 |
+
{/* Nút thao tác */}
|
| 494 |
+
<td className="p-3 text-center">
|
| 495 |
+
{isEditing ? (
|
| 496 |
+
<div className="flex items-center justify-center gap-1">
|
| 497 |
+
<button
|
| 498 |
+
onClick={() => handleSaveKemGiay(cur)}
|
| 499 |
+
className="p-1.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded shadow-xs"
|
| 500 |
+
title="Lưu thay đổi"
|
| 501 |
+
>
|
| 502 |
+
<Save className="w-3.5 h-3.5" />
|
| 503 |
+
</button>
|
| 504 |
+
<button
|
| 505 |
+
onClick={() => setEditingKemGiay(null)}
|
| 506 |
+
className="p-1.5 bg-slate-200 hover:bg-slate-300 text-slate-700 rounded"
|
| 507 |
+
title="Hủy"
|
| 508 |
+
>
|
| 509 |
+
<X className="w-3.5 h-3.5" />
|
| 510 |
+
</button>
|
| 511 |
+
</div>
|
| 512 |
+
) : (
|
| 513 |
+
<button
|
| 514 |
+
onClick={() => setEditingKemGiay({ ...row })}
|
| 515 |
+
className="p-1.5 text-slate-400 hover:text-indigo-600 rounded hover:bg-indigo-50 transition-colors"
|
| 516 |
+
title="Sửa giá"
|
| 517 |
+
>
|
| 518 |
+
<Edit2 className="w-3.5 h-3.5" />
|
| 519 |
+
</button>
|
| 520 |
+
)}
|
| 521 |
+
</td>
|
| 522 |
+
</tr>
|
| 523 |
+
);
|
| 524 |
+
})}
|
| 525 |
+
</tbody>
|
| 526 |
+
</table>
|
| 527 |
+
</div>
|
| 528 |
</div>
|
| 529 |
+
)}
|
| 530 |
|
| 531 |
+
{/* TAB 3: MÁY IN OFFSET */}
|
| 532 |
+
{activeTab === 'offset' && (
|
| 533 |
+
<div className="bg-white rounded-2xl border border-slate-200 shadow-2xs overflow-hidden space-y-4">
|
| 534 |
+
<div className="p-5 border-b border-slate-200 bg-gradient-to-r from-blue-50 to-indigo-50/40">
|
| 535 |
+
<h3 className="font-bold text-slate-900 text-sm flex items-center gap-2">
|
| 536 |
+
<Cpu className="w-5 h-5 text-indigo-600" />
|
| 537 |
+
Cấu Hình Máy In Offset & Gói In Trọn Gói Gồm Kẽm
|
| 538 |
+
</h3>
|
| 539 |
+
<p className="text-xs text-slate-600 mt-0.5">
|
| 540 |
+
Khổ 65 × 86 cm (bắt nhíp chiều 86): 1.200.000đ | Khổ nhỏ dưới 65 × 43 cm: 900.000đ (Dưới 3.000 lượt in đã bao gồm 4 kẽm)
|
| 541 |
+
</p>
|
| 542 |
+
</div>
|
| 543 |
+
|
| 544 |
+
<div className="p-5 pt-0 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
| 545 |
+
{offsetMachines.map((m) => {
|
| 546 |
+
const isEditing = editingOffset?.id === m.id;
|
| 547 |
+
const current = isEditing ? editingOffset : m;
|
| 548 |
+
|
| 549 |
+
return (
|
| 550 |
+
<div key={m.id} className="border border-slate-200 rounded-xl p-4 bg-slate-50/50 flex flex-col justify-between space-y-3">
|
| 551 |
+
<div className="flex items-center justify-between border-b border-slate-200 pb-2">
|
| 552 |
+
<strong className="text-xs font-bold text-slate-900">{current.name}</strong>
|
| 553 |
+
<button
|
| 554 |
+
onClick={() => setEditingOffset(isEditing ? null : { ...m })}
|
| 555 |
+
className="p-1 text-slate-400 hover:text-indigo-600"
|
| 556 |
+
>
|
| 557 |
+
<Edit2 className="w-3.5 h-3.5" />
|
| 558 |
+
</button>
|
| 559 |
+
</div>
|
| 560 |
|
| 561 |
+
<div className="space-y-2 text-xs">
|
| 562 |
+
<div className="flex justify-between items-center">
|
| 563 |
+
<span className="text-slate-500">Khổ in max:</span>
|
| 564 |
+
{isEditing ? (
|
| 565 |
+
<div className="flex gap-1 w-32">
|
| 566 |
+
<input
|
| 567 |
+
type="number"
|
| 568 |
+
className="w-1/2 p-1 border rounded text-right"
|
| 569 |
+
value={current.maxWidthMm}
|
| 570 |
+
onChange={(e) => setEditingOffset({ ...current, maxWidthMm: Number(e.target.value) })}
|
| 571 |
+
/>
|
| 572 |
+
<input
|
| 573 |
+
type="number"
|
| 574 |
+
className="w-1/2 p-1 border rounded text-right"
|
| 575 |
+
value={current.maxHeightMm}
|
| 576 |
+
onChange={(e) => setEditingOffset({ ...current, maxHeightMm: Number(e.target.value) })}
|
| 577 |
+
/>
|
| 578 |
+
</div>
|
| 579 |
+
) : (
|
| 580 |
+
<span className="font-medium text-slate-800">{current.maxWidthMm} × {current.maxHeightMm} mm</span>
|
| 581 |
+
)}
|
| 582 |
+
</div>
|
| 583 |
+
|
| 584 |
+
<div className="flex justify-between items-center">
|
| 585 |
+
<span className="text-slate-500">Giá kẽm CTP / lá:</span>
|
| 586 |
{isEditing ? (
|
| 587 |
<input
|
| 588 |
type="number"
|
| 589 |
+
className="w-28 p-1 border rounded text-right"
|
| 590 |
+
value={current.platePrice}
|
| 591 |
+
onChange={(e) => setEditingOffset({ ...current, platePrice: Number(e.target.value) })}
|
| 592 |
/>
|
| 593 |
) : (
|
| 594 |
+
<span className="font-bold text-indigo-700">{current.platePrice.toLocaleString('vi-VN')}đ</span>
|
| 595 |
)}
|
| 596 |
</div>
|
| 597 |
+
|
| 598 |
+
<div className="flex justify-between items-center">
|
| 599 |
+
<span className="text-slate-500">Mở máy (≤{current.baseImpressions || 3000} lượt):</span>
|
| 600 |
{isEditing ? (
|
| 601 |
<input
|
| 602 |
type="number"
|
| 603 |
+
className="w-28 p-1 border rounded text-right"
|
| 604 |
+
value={current.setupCost}
|
| 605 |
+
onChange={(e) => setEditingOffset({ ...current, setupCost: Number(e.target.value) })}
|
| 606 |
/>
|
| 607 |
) : (
|
| 608 |
+
<div className="text-right">
|
| 609 |
+
<span className="font-bold text-slate-800">{current.setupCost.toLocaleString('vi-VN')}đ</span>
|
| 610 |
+
{current.includesPlate && (
|
| 611 |
+
<span className="text-[10px] font-semibold text-emerald-600 block">Đã gồm kẽm</span>
|
| 612 |
+
)}
|
| 613 |
+
</div>
|
| 614 |
)}
|
| 615 |
</div>
|
|
|
|
| 616 |
|
| 617 |
+
<div className="flex justify-between items-center">
|
| 618 |
+
<span className="text-slate-500">Lũy tiến / 1.000 lượt:</span>
|
|
|
|
| 619 |
{isEditing ? (
|
| 620 |
<input
|
| 621 |
type="number"
|
| 622 |
+
className="w-28 p-1 border rounded text-right"
|
| 623 |
+
value={current.stepCost}
|
| 624 |
+
onChange={(e) => setEditingOffset({ ...current, stepCost: Number(e.target.value) })}
|
| 625 |
/>
|
| 626 |
) : (
|
| 627 |
+
<span className="font-medium text-slate-800">{current.stepCost.toLocaleString('vi-VN')}đ</span>
|
| 628 |
)}
|
| 629 |
</div>
|
| 630 |
+
|
| 631 |
+
<div className="flex justify-between items-center">
|
| 632 |
+
<span className="text-slate-500">Bù hao kẹp nhíp:</span>
|
| 633 |
{isEditing ? (
|
| 634 |
<input
|
| 635 |
type="number"
|
| 636 |
+
className="w-28 p-1 border rounded text-right"
|
| 637 |
+
value={current.gripperMarginMm || 10}
|
| 638 |
+
onChange={(e) => setEditingOffset({ ...current, gripperMarginMm: Number(e.target.value) })}
|
| 639 |
/>
|
| 640 |
) : (
|
| 641 |
+
<span className="font-medium text-slate-800">{current.gripperMarginMm || 10} mm</span>
|
| 642 |
)}
|
| 643 |
</div>
|
|
|
|
| 644 |
|
| 645 |
+
<div className="flex justify-between items-center">
|
| 646 |
+
<span className="text-slate-500">Bù hao giấy in:</span>
|
| 647 |
+
{isEditing ? (
|
| 648 |
+
<input
|
| 649 |
+
type="number"
|
| 650 |
+
className="w-28 p-1 border rounded text-right"
|
| 651 |
+
value={current.defaultWasteSheets}
|
| 652 |
+
onChange={(e) => setEditingOffset({ ...current, defaultWasteSheets: Number(e.target.value) })}
|
| 653 |
+
/>
|
| 654 |
+
) : (
|
| 655 |
+
<span className="font-medium text-amber-700">{current.defaultWasteSheets} tờ</span>
|
| 656 |
+
)}
|
| 657 |
+
</div>
|
| 658 |
</div>
|
|
|
|
| 659 |
|
| 660 |
+
{isEditing && (
|
| 661 |
+
<div className="pt-2 border-t border-slate-200 flex justify-end gap-1.5">
|
| 662 |
+
<button
|
| 663 |
+
onClick={() => setEditingOffset(null)}
|
| 664 |
+
className="px-2 py-1 border rounded text-[11px] text-slate-600 hover:bg-slate-100"
|
| 665 |
+
>
|
| 666 |
+
Hủy
|
| 667 |
+
</button>
|
| 668 |
+
<button
|
| 669 |
+
onClick={() => handleSaveOffset(current)}
|
| 670 |
+
className="px-2.5 py-1 bg-indigo-600 text-white rounded text-[11px] font-semibold hover:bg-indigo-700"
|
| 671 |
+
>
|
| 672 |
+
Lưu
|
| 673 |
+
</button>
|
| 674 |
+
</div>
|
| 675 |
+
)}
|
| 676 |
+
</div>
|
| 677 |
+
);
|
| 678 |
+
})}
|
| 679 |
+
</div>
|
| 680 |
</div>
|
| 681 |
+
)}
|
| 682 |
</div>
|
| 683 |
);
|
| 684 |
};
|
src/lib/db.ts
CHANGED
|
@@ -71,6 +71,35 @@ function initSchema(db: DatabaseSync) {
|
|
| 71 |
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
| 72 |
);
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
CREATE TABLE IF NOT EXISTS finishing_services (
|
| 75 |
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 76 |
code TEXT NOT NULL UNIQUE,
|
|
@@ -256,10 +285,57 @@ function seedDefaultData(db: DatabaseSync) {
|
|
| 256 |
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
| 257 |
`);
|
| 258 |
|
| 259 |
-
insertDigital.run('Máy In Nhanh
|
| 260 |
insertDigital.run('Máy In Nhanh Fuji Xerox Versant 3100 (Cao Cấp)', 660, 330, 1500, 2800, 3000, 5400, 3, 30000);
|
| 261 |
}
|
| 262 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
const finishingCount = db.prepare('SELECT COUNT(*) as cnt FROM finishing_services').get() as { cnt: number };
|
| 264 |
if (finishingCount.cnt === 0) {
|
| 265 |
const insertFinishing = db.prepare(`
|
|
|
|
| 71 |
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
| 72 |
);
|
| 73 |
|
| 74 |
+
CREATE TABLE IF NOT EXISTS digital_pricing_giacong (
|
| 75 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 76 |
+
machine_name TEXT NOT NULL,
|
| 77 |
+
sheet_size TEXT NOT NULL UNIQUE,
|
| 78 |
+
width_mm REAL NOT NULL,
|
| 79 |
+
height_mm REAL NOT NULL,
|
| 80 |
+
paper_lt_249 REAL NOT NULL,
|
| 81 |
+
paper_250_349 REAL NOT NULL,
|
| 82 |
+
paper_350_450 REAL NOT NULL,
|
| 83 |
+
decal_paper_plastic REAL NOT NULL,
|
| 84 |
+
decal_clear REAL NOT NULL,
|
| 85 |
+
synthetic_paper REAL NOT NULL,
|
| 86 |
+
pvc_plastic REAL NOT NULL,
|
| 87 |
+
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
| 88 |
+
);
|
| 89 |
+
|
| 90 |
+
CREATE TABLE IF NOT EXISTS digital_pricing_kem_giay (
|
| 91 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 92 |
+
paper_code TEXT NOT NULL UNIQUE,
|
| 93 |
+
paper_name TEXT NOT NULL,
|
| 94 |
+
gsm INTEGER NOT NULL,
|
| 95 |
+
sheet_size TEXT NOT NULL,
|
| 96 |
+
width_mm REAL NOT NULL,
|
| 97 |
+
height_mm REAL NOT NULL,
|
| 98 |
+
price_1side REAL NOT NULL,
|
| 99 |
+
price_2side REAL NOT NULL,
|
| 100 |
+
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
| 101 |
+
);
|
| 102 |
+
|
| 103 |
CREATE TABLE IF NOT EXISTS finishing_services (
|
| 104 |
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 105 |
code TEXT NOT NULL UNIQUE,
|
|
|
|
| 285 |
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
| 286 |
`);
|
| 287 |
|
| 288 |
+
insertDigital.run('Máy In Nhanh Konica C12000 / C12010S (5 Màu - Khổ 33x48)', 488, 330, 800, 1600, 2000, 4000, 3, 20000);
|
| 289 |
insertDigital.run('Máy In Nhanh Fuji Xerox Versant 3100 (Cao Cấp)', 660, 330, 1500, 2800, 3000, 5400, 3, 30000);
|
| 290 |
}
|
| 291 |
|
| 292 |
+
// Seed digital_pricing_giacong
|
| 293 |
+
const giaCongCount = db.prepare('SELECT COUNT(*) as cnt FROM digital_pricing_giacong').get() as { cnt: number };
|
| 294 |
+
if (giaCongCount.cnt === 0) {
|
| 295 |
+
const jsonPath = path.join(process.cwd(), 'data', 'digital_pricing_giacong.json');
|
| 296 |
+
if (fs.existsSync(jsonPath)) {
|
| 297 |
+
try {
|
| 298 |
+
const rows = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
|
| 299 |
+
const insertGC = db.prepare(`
|
| 300 |
+
INSERT INTO digital_pricing_giacong (machine_name, sheet_size, width_mm, height_mm, paper_lt_249, paper_250_349, paper_350_450, decal_paper_plastic, decal_clear, synthetic_paper, pvc_plastic)
|
| 301 |
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
| 302 |
+
`);
|
| 303 |
+
for (const r of rows) {
|
| 304 |
+
insertGC.run(
|
| 305 |
+
r.machine_name, r.sheet_size, r.width_mm, r.height_mm,
|
| 306 |
+
r.paper_lt_249, r.paper_250_349, r.paper_350_450,
|
| 307 |
+
r.decal_paper_plastic, r.decal_clear, r.synthetic_paper, r.pvc_plastic
|
| 308 |
+
);
|
| 309 |
+
}
|
| 310 |
+
} catch (err) {
|
| 311 |
+
console.error('Error seeding digital_pricing_giacong:', err);
|
| 312 |
+
}
|
| 313 |
+
}
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
// Seed digital_pricing_kem_giay
|
| 317 |
+
const kemGiayCount = db.prepare('SELECT COUNT(*) as cnt FROM digital_pricing_kem_giay').get() as { cnt: number };
|
| 318 |
+
if (kemGiayCount.cnt === 0) {
|
| 319 |
+
const jsonPath = path.join(process.cwd(), 'data', 'digital_pricing_kem_giay.json');
|
| 320 |
+
if (fs.existsSync(jsonPath)) {
|
| 321 |
+
try {
|
| 322 |
+
const rows = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
|
| 323 |
+
const insertKG = db.prepare(`
|
| 324 |
+
INSERT INTO digital_pricing_kem_giay (paper_code, paper_name, gsm, sheet_size, width_mm, height_mm, price_1side, price_2side)
|
| 325 |
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
| 326 |
+
`);
|
| 327 |
+
for (const r of rows) {
|
| 328 |
+
insertKG.run(
|
| 329 |
+
r.paper_code, r.paper_name, r.gsm, r.sheet_size,
|
| 330 |
+
r.width_mm, r.height_mm, r.price_1side, r.price_2side
|
| 331 |
+
);
|
| 332 |
+
}
|
| 333 |
+
} catch (err) {
|
| 334 |
+
console.error('Error seeding digital_pricing_kem_giay:', err);
|
| 335 |
+
}
|
| 336 |
+
}
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
const finishingCount = db.prepare('SELECT COUNT(*) as cnt FROM finishing_services').get() as { cnt: number };
|
| 340 |
if (finishingCount.cnt === 0) {
|
| 341 |
const insertFinishing = db.prepare(`
|
src/lib/engine/costCalculator.ts
CHANGED
|
@@ -3,6 +3,9 @@ import {
|
|
| 3 |
CalculationResult,
|
| 4 |
ComparisonItem,
|
| 5 |
DigitalMachine,
|
|
|
|
|
|
|
|
|
|
| 6 |
FinishingCostDetail,
|
| 7 |
FinishingService,
|
| 8 |
OffsetMachine,
|
|
@@ -10,12 +13,41 @@ import {
|
|
| 10 |
} from '@/types';
|
| 11 |
import { calculateImposition } from './imposition';
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
interface CostCalculatorOptions {
|
| 14 |
input: CalculationInput;
|
| 15 |
paperType: PaperType;
|
| 16 |
offsetMachine?: OffsetMachine;
|
| 17 |
allOffsetMachines?: OffsetMachine[];
|
| 18 |
digitalMachine?: DigitalMachine;
|
|
|
|
|
|
|
| 19 |
allFinishingServices: FinishingService[];
|
| 20 |
}
|
| 21 |
|
|
@@ -48,8 +80,6 @@ export function matchOffsetMachine(
|
|
| 48 |
(m) => m.name.includes('65x86') || m.maxWidthMm >= 800 || m.maxHeightMm >= 800
|
| 49 |
);
|
| 50 |
if (large) return large;
|
| 51 |
-
|
| 52 |
-
return allMachines[0];
|
| 53 |
}
|
| 54 |
|
| 55 |
// Fallback mặc định chuẩn theo yêu cầu xưởng in
|
|
@@ -94,8 +124,12 @@ export function calculatePrintCost({
|
|
| 94 |
offsetMachine,
|
| 95 |
allOffsetMachines,
|
| 96 |
digitalMachine,
|
|
|
|
|
|
|
| 97 |
allFinishingServices,
|
| 98 |
}: CostCalculatorOptions): CalculationResult {
|
|
|
|
|
|
|
| 99 |
// 1. Xác định công nghệ in mục tiêu
|
| 100 |
let chosenTech: 'offset' | 'digital' = 'offset';
|
| 101 |
if (input.printTech === 'digital') {
|
|
@@ -118,6 +152,7 @@ export function calculatePrintCost({
|
|
| 118 |
bleedMm: input.bleedMm ?? 2,
|
| 119 |
paperType,
|
| 120 |
printTech: chosenTech,
|
|
|
|
| 121 |
preferredMachineSheet: preferredSheet,
|
| 122 |
gripperMarginMm: offsetMachine?.gripperMarginMm ?? 10,
|
| 123 |
});
|
|
@@ -140,13 +175,11 @@ export function calculatePrintCost({
|
|
| 140 |
// Tính chi phí từng mục gia công
|
| 141 |
let subtotal = 0;
|
| 142 |
let formula = '';
|
| 143 |
-
const sides = sel.sides || 1;
|
| 144 |
|
| 145 |
if (service.calcType === 'per_m2') {
|
| 146 |
-
// Tính theo m2 sản phẩm hoặc m2 tờ in
|
| 147 |
const areaM2 = ((input.widthMm + (input.bleedMm || 2) * 2) / 1000) *
|
| 148 |
((input.heightMm + (input.bleedMm || 2) * 2) / 1000) *
|
| 149 |
-
input.quantity * sides;
|
| 150 |
const rawCost = areaM2 * service.unitPrice;
|
| 151 |
subtotal = Math.max(service.minPrice, Math.round(rawCost)) + service.setupFee;
|
| 152 |
formula = `${areaM2.toFixed(2)} m² × ${service.unitPrice.toLocaleString('vi-VN')}đ (tối thiểu ${service.minPrice.toLocaleString('vi-VN')}đ)`;
|
|
@@ -209,12 +242,14 @@ export function calculatePrintCost({
|
|
| 209 |
// Nếu số tờ mẹ >= 500 (trên 1 ram) áp dụng giá sỉ nguyên ram, nếu < 500 tờ áp dụng giá lẻ dưới ram
|
| 210 |
const isWholesale = parentSheetsNeeded >= 500;
|
| 211 |
const effectiveRamPrice = isWholesale ? priceAbove500 : priceBelow500;
|
| 212 |
-
|
| 213 |
|
| 214 |
// 6. Tiền in ấn
|
| 215 |
let plateCost = 0;
|
| 216 |
let platesCount = 0;
|
| 217 |
let printRunCost = 0;
|
|
|
|
|
|
|
| 218 |
|
| 219 |
if (chosenTech === 'offset') {
|
| 220 |
const machine = offsetMachine || matchOffsetMachine(imposition.printSheet, allOffsetMachines, input.offsetMachineId);
|
|
@@ -248,14 +283,13 @@ export function calculatePrintCost({
|
|
| 248 |
// In 2 mặt
|
| 249 |
if (workType === 'sheetwise') {
|
| 250 |
// In 2 bài riêng: 2 ca in độc lập, 2 bộ kẽm
|
| 251 |
-
const
|
| 252 |
-
|
| 253 |
-
platesCount = colorsF + colorsB;
|
| 254 |
|
| 255 |
if (includesPlate) {
|
| 256 |
-
|
| 257 |
-
const
|
| 258 |
-
plateCost =
|
| 259 |
} else {
|
| 260 |
plateCost = platesCount * machine.platePrice;
|
| 261 |
}
|
|
@@ -292,40 +326,162 @@ export function calculatePrintCost({
|
|
| 292 |
}
|
| 293 |
}
|
| 294 |
} else {
|
| 295 |
-
// In nhanh kỹ thuật số
|
| 296 |
-
const machine = digitalMachine || {
|
| 297 |
-
clickA41Side: 1200,
|
| 298 |
-
clickA42Side: 2200,
|
| 299 |
-
clickA31Side: 2400,
|
| 300 |
-
clickA32Side: 4200,
|
| 301 |
-
minCharge: 20000,
|
| 302 |
-
};
|
| 303 |
-
|
| 304 |
const is2Sides = input.printSides === '2_side';
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
let
|
| 309 |
-
|
| 310 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 311 |
} else {
|
| 312 |
-
|
| 313 |
}
|
| 314 |
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 324 |
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 329 |
}
|
| 330 |
|
| 331 |
const totalPrintCost = plateCost + printRunCost;
|
|
@@ -352,14 +508,14 @@ export function calculatePrintCost({
|
|
| 352 |
...input,
|
| 353 |
quantity: qty,
|
| 354 |
printTech: 'digital',
|
| 355 |
-
}, paperType, offsetMachine, digitalMachine, allFinishingServices);
|
| 356 |
|
| 357 |
// Tính thử chi phí offset
|
| 358 |
const offResult = runQuickEstimate({
|
| 359 |
...input,
|
| 360 |
quantity: qty,
|
| 361 |
printTech: 'offset',
|
| 362 |
-
}, paperType, offsetMachine, digitalMachine, allFinishingServices);
|
| 363 |
|
| 364 |
comparisonTable.push({
|
| 365 |
quantity: qty,
|
|
@@ -381,16 +537,16 @@ export function calculatePrintCost({
|
|
| 381 |
}
|
| 382 |
|
| 383 |
// Tính lại cho số lượng hiện tại nếu người dùng chọn 'auto'
|
| 384 |
-
const curDigital = runQuickEstimate({ ...input, printTech: 'digital' }, paperType, offsetMachine, digitalMachine, allFinishingServices);
|
| 385 |
-
const curOffset = runQuickEstimate({ ...input, printTech: 'offset' }, paperType, offsetMachine, digitalMachine, allFinishingServices);
|
| 386 |
|
| 387 |
let recommendationText = '';
|
| 388 |
const diff = Math.abs(curDigital.finalPrice - curOffset.finalPrice);
|
| 389 |
|
| 390 |
if (curDigital.finalPrice < curOffset.finalPrice) {
|
| 391 |
-
recommendationText = `Với số lượng ${input.quantity} sản phẩm, In Nhanh
|
| 392 |
} else {
|
| 393 |
-
recommendationText = `Với số lượng ${input.quantity} sản phẩm, In Offset
|
| 394 |
}
|
| 395 |
|
| 396 |
return {
|
|
@@ -420,6 +576,8 @@ export function calculatePrintCost({
|
|
| 420 |
vatAmount,
|
| 421 |
finalPrice,
|
| 422 |
unitPrice,
|
|
|
|
|
|
|
| 423 |
},
|
| 424 |
chosenTech,
|
| 425 |
recommendation: {
|
|
@@ -440,9 +598,12 @@ function runQuickEstimate(
|
|
| 440 |
paperType: PaperType,
|
| 441 |
offsetMachine?: OffsetMachine,
|
| 442 |
digitalMachine?: DigitalMachine,
|
| 443 |
-
allFinishingServices: FinishingService[] = []
|
|
|
|
|
|
|
| 444 |
): { finalPrice: number; unitPrice: number } {
|
| 445 |
const isDigital = input.printTech === 'digital';
|
|
|
|
| 446 |
const preferredSheet = !isDigital && offsetMachine
|
| 447 |
? { widthMm: offsetMachine.maxWidthMm, heightMm: offsetMachine.maxHeightMm, name: offsetMachine.name }
|
| 448 |
: undefined;
|
|
@@ -453,28 +614,42 @@ function runQuickEstimate(
|
|
| 453 |
bleedMm: input.bleedMm ?? 2,
|
| 454 |
paperType,
|
| 455 |
printTech: input.printTech,
|
|
|
|
| 456 |
preferredMachineSheet: preferredSheet,
|
| 457 |
});
|
| 458 |
|
| 459 |
const ups = Math.max(1, imposition.upsPerPrintSheet);
|
| 460 |
const netSheets = Math.ceil(input.quantity / ups);
|
| 461 |
const printWaste = isDigital ? 4 : (offsetMachine?.defaultWasteSheets ?? 100);
|
| 462 |
-
const totalSheets = netSheets + printWaste +
|
| 463 |
|
| 464 |
const cuts = Math.max(1, imposition.cutsPerParentSheet);
|
| 465 |
const parentSheets = Math.ceil(totalSheets / cuts);
|
| 466 |
const pAbove = paperType.priceAbove500 || paperType.pricePerRam;
|
| 467 |
const pBelow = paperType.priceBelow500 || pAbove;
|
| 468 |
const effectiveRamPrice = parentSheets >= 500 ? pAbove : pBelow;
|
| 469 |
-
|
| 470 |
|
| 471 |
let printCost = 0;
|
| 472 |
if (isDigital) {
|
| 473 |
-
const
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 478 |
} else {
|
| 479 |
const machine = offsetMachine || matchOffsetMachine(imposition.printSheet);
|
| 480 |
const baseImpressions = machine.baseImpressions || 3000;
|
|
|
|
| 3 |
CalculationResult,
|
| 4 |
ComparisonItem,
|
| 5 |
DigitalMachine,
|
| 6 |
+
DigitalMode,
|
| 7 |
+
DigitalPrintGiaCong,
|
| 8 |
+
DigitalPrintKemGiay,
|
| 9 |
FinishingCostDetail,
|
| 10 |
FinishingService,
|
| 11 |
OffsetMachine,
|
|
|
|
| 13 |
} from '@/types';
|
| 14 |
import { calculateImposition } from './imposition';
|
| 15 |
|
| 16 |
+
export const DEFAULT_DIGITAL_GIA_CONG: DigitalPrintGiaCong[] = [
|
| 17 |
+
{ id: 1, machineName: 'KONICA C12000 / C12010S 5 MÀU', sheetSize: 'A4', widthMm: 210, heightMm: 297, paperLt249: 600, paper250To349: 800, paper350To450: 1000, decalPaperPlastic: 1200, decalClear: 1500, syntheticPaper: 1000, pvcPlastic: 1500 },
|
| 18 |
+
{ id: 2, machineName: 'KONICA C12000 / C12010S 5 MÀU', sheetSize: '330x355', widthMm: 330, heightMm: 355, paperLt249: 800, paper250To349: 1000, paper350To450: 1800, decalPaperPlastic: 1500, decalClear: 1700, syntheticPaper: 1800, pvcPlastic: 2000 },
|
| 19 |
+
{ id: 3, machineName: 'KONICA C12000 / C12010S 5 MÀU', sheetSize: 'A3', widthMm: 297, heightMm: 420, paperLt249: 1200, paper250To349: 1600, paper350To450: 2100, decalPaperPlastic: 2100, decalClear: 2300, syntheticPaper: 2100, pvcPlastic: 2500 },
|
| 20 |
+
{ id: 4, machineName: 'KONICA C12000 / C12010S 5 MÀU', sheetSize: '330x483', widthMm: 330, heightMm: 483, paperLt249: 2000, paper250To349: 2500, paper350To450: 2700, decalPaperPlastic: 2500, decalClear: 2800, syntheticPaper: 2700, pvcPlastic: 3000 },
|
| 21 |
+
{ id: 5, machineName: 'KONICA C12000 / C12010S 5 MÀU', sheetSize: '330x1200', widthMm: 330, heightMm: 1200, paperLt249: 4000, paper250To349: 7000, paper350To450: 10000, decalPaperPlastic: 6000, decalClear: 7500, syntheticPaper: 15000, pvcPlastic: 0 },
|
| 22 |
+
];
|
| 23 |
+
|
| 24 |
+
export const DEFAULT_DIGITAL_KEM_GIAY: DigitalPrintKemGiay[] = [
|
| 25 |
+
{ id: 1, paperCode: 'C100', paperName: 'Couche 100gsm', gsm: 100, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 1600, price2Side: 2800 },
|
| 26 |
+
{ id: 2, paperCode: 'C120', paperName: 'Couche 120gsm', gsm: 120, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 1700, price2Side: 2900 },
|
| 27 |
+
{ id: 3, paperCode: 'C150', paperName: 'Couche 150gsm', gsm: 150, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 1800, price2Side: 3000 },
|
| 28 |
+
{ id: 4, paperCode: 'C200', paperName: 'Couche 200gsm', gsm: 200, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 1900, price2Side: 3100 },
|
| 29 |
+
{ id: 5, paperCode: 'C250', paperName: 'Couche 250gsm', gsm: 250, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 2100, price2Side: 3300 },
|
| 30 |
+
{ id: 6, paperCode: 'C300', paperName: 'Couche 300gsm', gsm: 300, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 2700, price2Side: 4300 },
|
| 31 |
+
{ id: 7, paperCode: 'I250', paperName: 'Ivory 250gsm', gsm: 250, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 2100, price2Side: 3300 },
|
| 32 |
+
{ id: 8, paperCode: 'I300', paperName: 'Ivory 300gsm', gsm: 300, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 2600, price2Side: 4200 },
|
| 33 |
+
{ id: 9, paperCode: 'I350', paperName: 'Ivory 350gsm', gsm: 350, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 3300, price2Side: 5300 },
|
| 34 |
+
{ id: 10, paperCode: 'F100', paperName: 'Fort 100gsm', gsm: 100, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 1600, price2Side: 2800 },
|
| 35 |
+
{ id: 11, paperCode: 'F120', paperName: 'Fort 120gsm', gsm: 120, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 1700, price2Side: 2900 },
|
| 36 |
+
{ id: 12, paperCode: 'F180', paperName: 'Fort 180gsm', gsm: 180, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 1900, price2Side: 3100 },
|
| 37 |
+
{ id: 13, paperCode: 'F230', paperName: 'Fort 230gsm', gsm: 230, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 2100, price2Side: 3300 },
|
| 38 |
+
{ id: 14, paperCode: 'F250', paperName: 'Fort 250gsm', gsm: 250, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 2300, price2Side: 3500 },
|
| 39 |
+
{ id: 15, paperCode: 'F180_355', paperName: 'Fort 180gsm (325x355)', gsm: 180, sheetSize: '325x355', widthMm: 325, heightMm: 355, price1Side: 1400, price2Side: 2200 },
|
| 40 |
+
{ id: 16, paperCode: 'F230_355', paperName: 'Fort 230gsm (325x355)', gsm: 230, sheetSize: '325x355', widthMm: 325, heightMm: 355, price1Side: 1500, price2Side: 2300 },
|
| 41 |
+
];
|
| 42 |
+
|
| 43 |
interface CostCalculatorOptions {
|
| 44 |
input: CalculationInput;
|
| 45 |
paperType: PaperType;
|
| 46 |
offsetMachine?: OffsetMachine;
|
| 47 |
allOffsetMachines?: OffsetMachine[];
|
| 48 |
digitalMachine?: DigitalMachine;
|
| 49 |
+
digitalPricingGiaCong?: DigitalPrintGiaCong[];
|
| 50 |
+
digitalPricingKemGiay?: DigitalPrintKemGiay[];
|
| 51 |
allFinishingServices: FinishingService[];
|
| 52 |
}
|
| 53 |
|
|
|
|
| 80 |
(m) => m.name.includes('65x86') || m.maxWidthMm >= 800 || m.maxHeightMm >= 800
|
| 81 |
);
|
| 82 |
if (large) return large;
|
|
|
|
|
|
|
| 83 |
}
|
| 84 |
|
| 85 |
// Fallback mặc định chuẩn theo yêu cầu xưởng in
|
|
|
|
| 124 |
offsetMachine,
|
| 125 |
allOffsetMachines,
|
| 126 |
digitalMachine,
|
| 127 |
+
digitalPricingGiaCong = DEFAULT_DIGITAL_GIA_CONG,
|
| 128 |
+
digitalPricingKemGiay = DEFAULT_DIGITAL_KEM_GIAY,
|
| 129 |
allFinishingServices,
|
| 130 |
}: CostCalculatorOptions): CalculationResult {
|
| 131 |
+
const digitalMode: DigitalMode = input.digitalMode || 'with_paper';
|
| 132 |
+
|
| 133 |
// 1. Xác định công nghệ in mục tiêu
|
| 134 |
let chosenTech: 'offset' | 'digital' = 'offset';
|
| 135 |
if (input.printTech === 'digital') {
|
|
|
|
| 152 |
bleedMm: input.bleedMm ?? 2,
|
| 153 |
paperType,
|
| 154 |
printTech: chosenTech,
|
| 155 |
+
digitalMode,
|
| 156 |
preferredMachineSheet: preferredSheet,
|
| 157 |
gripperMarginMm: offsetMachine?.gripperMarginMm ?? 10,
|
| 158 |
});
|
|
|
|
| 175 |
// Tính chi phí từng mục gia công
|
| 176 |
let subtotal = 0;
|
| 177 |
let formula = '';
|
|
|
|
| 178 |
|
| 179 |
if (service.calcType === 'per_m2') {
|
|
|
|
| 180 |
const areaM2 = ((input.widthMm + (input.bleedMm || 2) * 2) / 1000) *
|
| 181 |
((input.heightMm + (input.bleedMm || 2) * 2) / 1000) *
|
| 182 |
+
input.quantity * (sel.sides || 1);
|
| 183 |
const rawCost = areaM2 * service.unitPrice;
|
| 184 |
subtotal = Math.max(service.minPrice, Math.round(rawCost)) + service.setupFee;
|
| 185 |
formula = `${areaM2.toFixed(2)} m² × ${service.unitPrice.toLocaleString('vi-VN')}đ (tối thiểu ${service.minPrice.toLocaleString('vi-VN')}đ)`;
|
|
|
|
| 242 |
// Nếu số tờ mẹ >= 500 (trên 1 ram) áp dụng giá sỉ nguyên ram, nếu < 500 tờ áp dụng giá lẻ dưới ram
|
| 243 |
const isWholesale = parentSheetsNeeded >= 500;
|
| 244 |
const effectiveRamPrice = isWholesale ? priceAbove500 : priceBelow500;
|
| 245 |
+
let paperCost = Math.round(parentSheetsNeeded * (effectiveRamPrice / 500));
|
| 246 |
|
| 247 |
// 6. Tiền in ấn
|
| 248 |
let plateCost = 0;
|
| 249 |
let platesCount = 0;
|
| 250 |
let printRunCost = 0;
|
| 251 |
+
let shortRunFee = 0;
|
| 252 |
+
let digitalDetails: CalculationResult['costs']['digitalDetails'] | undefined;
|
| 253 |
|
| 254 |
if (chosenTech === 'offset') {
|
| 255 |
const machine = offsetMachine || matchOffsetMachine(imposition.printSheet, allOffsetMachines, input.offsetMachineId);
|
|
|
|
| 283 |
// In 2 mặt
|
| 284 |
if (workType === 'sheetwise') {
|
| 285 |
// In 2 bài riêng: 2 ca in độc lập, 2 bộ kẽm
|
| 286 |
+
const colors = (input.colorsFront || 4) + (input.colorsBack || 4);
|
| 287 |
+
platesCount = colors;
|
|
|
|
| 288 |
|
| 289 |
if (includesPlate) {
|
| 290 |
+
// Gói mở máy đã bao gồm 1 bộ kẽm 4 màu cơ bản, bài thứ 2 hoặc màu thứ 5+ tính riêng kẽm
|
| 291 |
+
const extraColors = Math.max(0, colors - 4);
|
| 292 |
+
plateCost = extraColors * machine.platePrice;
|
| 293 |
} else {
|
| 294 |
plateCost = platesCount * machine.platePrice;
|
| 295 |
}
|
|
|
|
| 326 |
}
|
| 327 |
}
|
| 328 |
} else {
|
| 329 |
+
// In nhanh kỹ thuật số (Digital)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 330 |
const is2Sides = input.printSides === '2_side';
|
| 331 |
+
let ratePerSheet = 0;
|
| 332 |
+
let machineName = '';
|
| 333 |
+
let sheetSizeName = imposition.printSheet.name;
|
| 334 |
+
let whiteInkCost = 0;
|
| 335 |
+
|
| 336 |
+
// Phụ phí số lượng ít:
|
| 337 |
+
// < 50 tờ: +30.000đ
|
| 338 |
+
// < 100 tờ: +20.000đ
|
| 339 |
+
// >= 100 tờ: 0đ
|
| 340 |
+
if (totalPrintSheets < 50) {
|
| 341 |
+
shortRunFee = 30000;
|
| 342 |
+
} else if (totalPrintSheets < 100) {
|
| 343 |
+
shortRunFee = 20000;
|
| 344 |
} else {
|
| 345 |
+
shortRunFee = 0;
|
| 346 |
}
|
| 347 |
|
| 348 |
+
if (digitalMode === 'with_paper') {
|
| 349 |
+
// 1. In nhanh kèm giấy (INTC) - Đã gồm tiền giấy trọn gói
|
| 350 |
+
machineName = 'In Nhanh Kèm Giấy (INTC)';
|
| 351 |
+
const kemGiayList = digitalPricingKemGiay && digitalPricingKemGiay.length > 0
|
| 352 |
+
? digitalPricingKemGiay
|
| 353 |
+
: DEFAULT_DIGITAL_KEM_GIAY;
|
| 354 |
+
|
| 355 |
+
// Tìm dòng giá phù hợp theo mã giấy
|
| 356 |
+
let matchedPricing = kemGiayList.find((kg) =>
|
| 357 |
+
paperType.code.toUpperCase().includes(kg.paperCode.toUpperCase())
|
| 358 |
+
);
|
| 359 |
+
|
| 360 |
+
// Nếu không khớp chính xác mã, so khớp theo tiền tố chất liệu (C, I, F) + GSM gần nhất
|
| 361 |
+
if (!matchedPricing) {
|
| 362 |
+
const pName = paperType.name.toLowerCase();
|
| 363 |
+
let prefix = 'C';
|
| 364 |
+
if (pName.includes('ivory') || paperType.code.toUpperCase().startsWith('I')) prefix = 'I';
|
| 365 |
+
else if (pName.includes('fort') || pName.includes('ford') || paperType.code.toUpperCase().startsWith('F')) prefix = 'F';
|
| 366 |
+
|
| 367 |
+
const sameType = kemGiayList.filter((kg) => kg.paperCode.startsWith(prefix));
|
| 368 |
+
if (sameType.length > 0) {
|
| 369 |
+
matchedPricing = sameType.reduce((prev, curr) =>
|
| 370 |
+
Math.abs(curr.gsm - paperType.gsm) < Math.abs(prev.gsm - paperType.gsm) ? curr : prev
|
| 371 |
+
);
|
| 372 |
+
} else {
|
| 373 |
+
matchedPricing = kemGiayList[0];
|
| 374 |
+
}
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
if (matchedPricing) {
|
| 378 |
+
ratePerSheet = is2Sides ? matchedPricing.price2Side : matchedPricing.price1Side;
|
| 379 |
+
sheetSizeName = `${matchedPricing.sheetSize} mm (${matchedPricing.paperName})`;
|
| 380 |
+
} else {
|
| 381 |
+
ratePerSheet = is2Sides ? 3000 : 1800;
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
printRunCost = Math.round(totalPrintSheets * ratePerSheet) + shortRunFee;
|
| 385 |
+
|
| 386 |
+
// Đã kèm giấy nên tiền giấy = 0
|
| 387 |
+
paperCost = 0;
|
| 388 |
+
plateCost = 0;
|
| 389 |
+
platesCount = 0;
|
| 390 |
+
|
| 391 |
+
digitalDetails = {
|
| 392 |
+
mode: 'with_paper',
|
| 393 |
+
machineName,
|
| 394 |
+
sheetSizeName,
|
| 395 |
+
ratePerSheet,
|
| 396 |
+
shortRunFee,
|
| 397 |
+
note: `Bảng giá In Nhanh Kèm Giấy INTC (Đã gồm công in + giấy ${matchedPricing?.paperName || ''}). Phí SL ít: +${shortRunFee.toLocaleString('vi-VN')}đ`,
|
| 398 |
+
};
|
| 399 |
+
} else {
|
| 400 |
+
// 2. In nhanh gia công (Konica C12000 / C12010S 5 màu - Trang 2 PDF)
|
| 401 |
+
machineName = 'Konica C12000 / C12010S 5 Màu';
|
| 402 |
+
const giaCongList = digitalPricingGiaCong && digitalPricingGiaCong.length > 0
|
| 403 |
+
? digitalPricingGiaCong
|
| 404 |
+
: DEFAULT_DIGITAL_GIA_CONG;
|
| 405 |
+
|
| 406 |
+
// Nhận diện khổ in máy
|
| 407 |
+
const pW = imposition.printSheet.widthMm;
|
| 408 |
+
const pH = imposition.printSheet.heightMm;
|
| 409 |
+
const maxDim = Math.max(pW, pH);
|
| 410 |
+
const minDim = Math.min(pW, pH);
|
| 411 |
+
|
| 412 |
+
let sheetRow: DigitalPrintGiaCong | undefined;
|
| 413 |
+
if (maxDim > 500) {
|
| 414 |
+
sheetRow = giaCongList.find((g) => g.sheetSize === '330x1200');
|
| 415 |
+
} else if (maxDim > 430 || minDim > 300) {
|
| 416 |
+
sheetRow = giaCongList.find((g) => g.sheetSize === '330x483');
|
| 417 |
+
} else if (maxDim > 360 || minDim > 250) {
|
| 418 |
+
sheetRow = giaCongList.find((g) => g.sheetSize === 'A3');
|
| 419 |
+
} else if (maxDim > 300) {
|
| 420 |
+
sheetRow = giaCongList.find((g) => g.sheetSize === '330x355');
|
| 421 |
+
} else {
|
| 422 |
+
sheetRow = giaCongList.find((g) => g.sheetSize === 'A4');
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
if (!sheetRow) {
|
| 426 |
+
sheetRow = giaCongList.find((g) => g.sheetSize === '330x483') || giaCongList[0];
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
// Nhận diện nhóm chất liệu
|
| 430 |
+
const pName = paperType.name.toLowerCase();
|
| 431 |
+
let click1Side = 0;
|
| 432 |
+
|
| 433 |
+
if (sheetRow) {
|
| 434 |
+
if (pName.includes('pvc')) {
|
| 435 |
+
click1Side = sheetRow.pvcPlastic || sheetRow.paper350To450;
|
| 436 |
+
} else if (pName.includes('nhựa') || pName.includes('synthetic')) {
|
| 437 |
+
click1Side = sheetRow.syntheticPaper;
|
| 438 |
+
} else if (pName.includes('trong') && pName.includes('decal')) {
|
| 439 |
+
click1Side = sheetRow.decalClear;
|
| 440 |
+
} else if (pName.includes('decal')) {
|
| 441 |
+
click1Side = sheetRow.decalPaperPlastic;
|
| 442 |
+
} else {
|
| 443 |
+
// Giấy thường theo định lượng GSM
|
| 444 |
+
if (paperType.gsm < 250) {
|
| 445 |
+
click1Side = sheetRow.paperLt249;
|
| 446 |
+
} else if (paperType.gsm <= 349) {
|
| 447 |
+
click1Side = sheetRow.paper250To349;
|
| 448 |
+
} else {
|
| 449 |
+
click1Side = sheetRow.paper350To450;
|
| 450 |
+
}
|
| 451 |
+
}
|
| 452 |
+
sheetSizeName = `${sheetRow.sheetSize} (${sheetRow.widthMm}x${sheetRow.heightMm}mm)`;
|
| 453 |
+
} else {
|
| 454 |
+
click1Side = is2Sides ? 2000 : 1000;
|
| 455 |
+
}
|
| 456 |
|
| 457 |
+
// Giá in 2 mặt = x2 giá 1 mặt
|
| 458 |
+
ratePerSheet = is2Sides ? (click1Side * 2) : click1Side;
|
| 459 |
+
|
| 460 |
+
// In mực trắng nếu có tùy chọn
|
| 461 |
+
if (input.whiteInk) {
|
| 462 |
+
whiteInkCost = totalPrintSheets * (is2Sides ? 4000 : 2000);
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
printRunCost = Math.round(totalPrintSheets * ratePerSheet) + whiteInkCost + shortRunFee;
|
| 466 |
+
|
| 467 |
+
// Nếu khách tự cấp giấy -> tiền giấy = 0
|
| 468 |
+
if (input.customerSuppliedPaper) {
|
| 469 |
+
paperCost = 0;
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
plateCost = 0;
|
| 473 |
+
platesCount = 0;
|
| 474 |
+
|
| 475 |
+
digitalDetails = {
|
| 476 |
+
mode: 'without_paper',
|
| 477 |
+
machineName,
|
| 478 |
+
sheetSizeName,
|
| 479 |
+
ratePerSheet,
|
| 480 |
+
shortRunFee,
|
| 481 |
+
whiteInkCost,
|
| 482 |
+
note: `In Gia Công Konica C12000 (Khổ ${sheetSizeName}, Đơn giá ${ratePerSheet.toLocaleString('vi-VN')}đ/tờ). Phí SL ít: +${shortRunFee.toLocaleString('vi-VN')}đ${input.customerSuppliedPaper ? ' • Khách cấp giấy' : ''}`,
|
| 483 |
+
};
|
| 484 |
+
}
|
| 485 |
}
|
| 486 |
|
| 487 |
const totalPrintCost = plateCost + printRunCost;
|
|
|
|
| 508 |
...input,
|
| 509 |
quantity: qty,
|
| 510 |
printTech: 'digital',
|
| 511 |
+
}, paperType, offsetMachine, digitalMachine, allFinishingServices, digitalPricingGiaCong, digitalPricingKemGiay);
|
| 512 |
|
| 513 |
// Tính thử chi phí offset
|
| 514 |
const offResult = runQuickEstimate({
|
| 515 |
...input,
|
| 516 |
quantity: qty,
|
| 517 |
printTech: 'offset',
|
| 518 |
+
}, paperType, offsetMachine, digitalMachine, allFinishingServices, digitalPricingGiaCong, digitalPricingKemGiay);
|
| 519 |
|
| 520 |
comparisonTable.push({
|
| 521 |
quantity: qty,
|
|
|
|
| 537 |
}
|
| 538 |
|
| 539 |
// Tính lại cho số lượng hiện tại nếu người dùng chọn 'auto'
|
| 540 |
+
const curDigital = runQuickEstimate({ ...input, printTech: 'digital' }, paperType, offsetMachine, digitalMachine, allFinishingServices, digitalPricingGiaCong, digitalPricingKemGiay);
|
| 541 |
+
const curOffset = runQuickEstimate({ ...input, printTech: 'offset' }, paperType, offsetMachine, digitalMachine, allFinishingServices, digitalPricingGiaCong, digitalPricingKemGiay);
|
| 542 |
|
| 543 |
let recommendationText = '';
|
| 544 |
const diff = Math.abs(curDigital.finalPrice - curOffset.finalPrice);
|
| 545 |
|
| 546 |
if (curDigital.finalPrice < curOffset.finalPrice) {
|
| 547 |
+
recommendationText = `Với số lượng ${input.quantity.toLocaleString('vi-VN')} sản phẩm, In Nhanh KTS tiết kiệm hơn In Offset ${diff.toLocaleString('vi-VN')}đ (${Math.round((diff / curOffset.finalPrice) * 100)}%), không tốn phí mở máy và kẽm CTP, lấy nhanh trong ngày.`;
|
| 548 |
} else {
|
| 549 |
+
recommendationText = `Với số lượng ${input.quantity.toLocaleString('vi-VN')} sản phẩm, In Offset tối ưu chi phí hơn In Nhanh ${diff.toLocaleString('vi-VN')}đ (${Math.round((diff / curDigital.finalPrice) * 100)}%), giá đơn vị giảm sâu theo số lượng lớn.`;
|
| 550 |
}
|
| 551 |
|
| 552 |
return {
|
|
|
|
| 576 |
vatAmount,
|
| 577 |
finalPrice,
|
| 578 |
unitPrice,
|
| 579 |
+
shortRunFee,
|
| 580 |
+
digitalDetails,
|
| 581 |
},
|
| 582 |
chosenTech,
|
| 583 |
recommendation: {
|
|
|
|
| 598 |
paperType: PaperType,
|
| 599 |
offsetMachine?: OffsetMachine,
|
| 600 |
digitalMachine?: DigitalMachine,
|
| 601 |
+
allFinishingServices: FinishingService[] = [],
|
| 602 |
+
digitalPricingGiaCong: DigitalPrintGiaCong[] = DEFAULT_DIGITAL_GIA_CONG,
|
| 603 |
+
digitalPricingKemGiay: DigitalPrintKemGiay[] = DEFAULT_DIGITAL_KEM_GIAY
|
| 604 |
): { finalPrice: number; unitPrice: number } {
|
| 605 |
const isDigital = input.printTech === 'digital';
|
| 606 |
+
const digitalMode: DigitalMode = input.digitalMode || 'with_paper';
|
| 607 |
const preferredSheet = !isDigital && offsetMachine
|
| 608 |
? { widthMm: offsetMachine.maxWidthMm, heightMm: offsetMachine.maxHeightMm, name: offsetMachine.name }
|
| 609 |
: undefined;
|
|
|
|
| 614 |
bleedMm: input.bleedMm ?? 2,
|
| 615 |
paperType,
|
| 616 |
printTech: input.printTech,
|
| 617 |
+
digitalMode,
|
| 618 |
preferredMachineSheet: preferredSheet,
|
| 619 |
});
|
| 620 |
|
| 621 |
const ups = Math.max(1, imposition.upsPerPrintSheet);
|
| 622 |
const netSheets = Math.ceil(input.quantity / ups);
|
| 623 |
const printWaste = isDigital ? 4 : (offsetMachine?.defaultWasteSheets ?? 100);
|
| 624 |
+
const totalSheets = netSheets + printWaste + 10;
|
| 625 |
|
| 626 |
const cuts = Math.max(1, imposition.cutsPerParentSheet);
|
| 627 |
const parentSheets = Math.ceil(totalSheets / cuts);
|
| 628 |
const pAbove = paperType.priceAbove500 || paperType.pricePerRam;
|
| 629 |
const pBelow = paperType.priceBelow500 || pAbove;
|
| 630 |
const effectiveRamPrice = parentSheets >= 500 ? pAbove : pBelow;
|
| 631 |
+
let paperCost = Math.round(parentSheets * (effectiveRamPrice / 500));
|
| 632 |
|
| 633 |
let printCost = 0;
|
| 634 |
if (isDigital) {
|
| 635 |
+
const is2Sides = input.printSides === '2_side';
|
| 636 |
+
let shortFee = 0;
|
| 637 |
+
if (totalSheets < 50) shortFee = 30000;
|
| 638 |
+
else if (totalSheets < 100) shortFee = 20000;
|
| 639 |
+
|
| 640 |
+
if (digitalMode === 'with_paper') {
|
| 641 |
+
const matched = digitalPricingKemGiay.find((kg) =>
|
| 642 |
+
paperType.code.toUpperCase().includes(kg.paperCode.toUpperCase())
|
| 643 |
+
) || digitalPricingKemGiay[0];
|
| 644 |
+
const rate = is2Sides ? matched.price2Side : matched.price1Side;
|
| 645 |
+
printCost = Math.round(totalSheets * rate) + shortFee;
|
| 646 |
+
paperCost = 0;
|
| 647 |
+
} else {
|
| 648 |
+
const isA3 = imposition.printSheet.widthMm > 220 || imposition.printSheet.heightMm > 310;
|
| 649 |
+
const rate = isA3 ? (is2Sides ? 4000 : 2000) : (is2Sides ? 1600 : 800);
|
| 650 |
+
printCost = Math.round(totalSheets * rate) + shortFee;
|
| 651 |
+
if (input.customerSuppliedPaper) paperCost = 0;
|
| 652 |
+
}
|
| 653 |
} else {
|
| 654 |
const machine = offsetMachine || matchOffsetMachine(imposition.printSheet);
|
| 655 |
const baseImpressions = machine.baseImpressions || 3000;
|
src/lib/engine/imposition.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import { BoxCoordinate, ImpositionResult, PaperCutBlock, PaperCutLine, PaperCutScheme, PaperType, PrintTech } from '@/types';
|
| 2 |
|
| 3 |
interface ImpositionOptions {
|
| 4 |
widthMm: number;
|
|
@@ -6,6 +6,7 @@ interface ImpositionOptions {
|
|
| 6 |
bleedMm?: number;
|
| 7 |
paperType: PaperType;
|
| 8 |
printTech: PrintTech;
|
|
|
|
| 9 |
preferredMachineSheet?: { widthMm: number; heightMm: number; name: string };
|
| 10 |
gripperMarginMm?: number;
|
| 11 |
}
|
|
@@ -25,6 +26,7 @@ export function calculateImposition({
|
|
| 25 |
bleedMm = 2,
|
| 26 |
paperType,
|
| 27 |
printTech,
|
|
|
|
| 28 |
preferredMachineSheet,
|
| 29 |
gripperMarginMm = 10,
|
| 30 |
}: ImpositionOptions): ImpositionResult {
|
|
@@ -47,12 +49,26 @@ export function calculateImposition({
|
|
| 47 |
gripperMarginMm,
|
| 48 |
});
|
| 49 |
} else if (printTech === 'digital') {
|
| 50 |
-
// In nhanh KTS:
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
|
| 57 |
for (const dc of digitalCandidates) {
|
| 58 |
const cuts = calculateCutsFromParent(parentW, parentH, dc.w, dc.h);
|
|
@@ -63,7 +79,7 @@ export function calculateImposition({
|
|
| 63 |
sheetHeightMm: dc.h,
|
| 64 |
cutsFromParent: cuts,
|
| 65 |
cutScheme,
|
| 66 |
-
gripperMarginMm:
|
| 67 |
});
|
| 68 |
}
|
| 69 |
} else {
|
|
|
|
| 1 |
+
import { BoxCoordinate, DigitalMode, ImpositionResult, PaperCutBlock, PaperCutLine, PaperCutScheme, PaperType, PrintTech } from '@/types';
|
| 2 |
|
| 3 |
interface ImpositionOptions {
|
| 4 |
widthMm: number;
|
|
|
|
| 6 |
bleedMm?: number;
|
| 7 |
paperType: PaperType;
|
| 8 |
printTech: PrintTech;
|
| 9 |
+
digitalMode?: DigitalMode;
|
| 10 |
preferredMachineSheet?: { widthMm: number; heightMm: number; name: string };
|
| 11 |
gripperMarginMm?: number;
|
| 12 |
}
|
|
|
|
| 26 |
bleedMm = 2,
|
| 27 |
paperType,
|
| 28 |
printTech,
|
| 29 |
+
digitalMode = 'with_paper',
|
| 30 |
preferredMachineSheet,
|
| 31 |
gripperMarginMm = 10,
|
| 32 |
}: ImpositionOptions): ImpositionResult {
|
|
|
|
| 49 |
gripperMarginMm,
|
| 50 |
});
|
| 51 |
} else if (printTech === 'digital') {
|
| 52 |
+
// In nhanh KTS: Theo chuẩn Bảng Giá In Nhanh Kèm Giấy & In Gia Công Konica C12000
|
| 53 |
+
let digitalCandidates: { name: string; w: number; h: number; cuts: number }[] = [];
|
| 54 |
+
|
| 55 |
+
if (digitalMode === 'with_paper') {
|
| 56 |
+
// BẢNG GIÁ IN NHANH KÈM GIẤY: Khổ 325 x 430 mm (chính) & 325 x 355 mm (Fort)
|
| 57 |
+
digitalCandidates = [
|
| 58 |
+
{ name: 'Khổ in kèm giấy (325 x 430 mm)', w: 430, h: 325, cuts: 4 },
|
| 59 |
+
{ name: 'Khổ in kèm giấy đứng (325 x 430 mm)', w: 325, h: 430, cuts: 4 },
|
| 60 |
+
{ name: 'Khổ in kèm giấy (325 x 355 mm)', w: 355, h: 325, cuts: 4 },
|
| 61 |
+
];
|
| 62 |
+
} else {
|
| 63 |
+
// BẢNG GIÁ IN GIA CÔNG KONICA C12000 / C12010S: 5 khổ in máy
|
| 64 |
+
digitalCandidates = [
|
| 65 |
+
{ name: 'Khổ Konica A3+ (330 x 483 mm)', w: 483, h: 330, cuts: 4 },
|
| 66 |
+
{ name: 'Khổ Konica A3 chuẩn (297 x 420 mm)', w: 420, h: 297, cuts: 4 },
|
| 67 |
+
{ name: 'Khổ Konica 330 x 355 mm', w: 355, h: 330, cuts: 4 },
|
| 68 |
+
{ name: 'Khổ Konica A4 chuẩn (210 x 297 mm)', w: 297, h: 210, cuts: 8 },
|
| 69 |
+
{ name: 'Khổ Konica Banner (330 x 1200 mm)', w: 1200, h: 330, cuts: 1 },
|
| 70 |
+
];
|
| 71 |
+
}
|
| 72 |
|
| 73 |
for (const dc of digitalCandidates) {
|
| 74 |
const cuts = calculateCutsFromParent(parentW, parentH, dc.w, dc.h);
|
|
|
|
| 79 |
sheetHeightMm: dc.h,
|
| 80 |
cutsFromParent: cuts,
|
| 81 |
cutScheme,
|
| 82 |
+
gripperMarginMm: 4,
|
| 83 |
});
|
| 84 |
}
|
| 85 |
} else {
|
src/types/index.ts
CHANGED
|
@@ -36,6 +36,35 @@ export interface OffsetMachine {
|
|
| 36 |
includesPlate?: boolean; // Đã bao gồm kẽm CTP trong phí mở máy
|
| 37 |
}
|
| 38 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
export interface DigitalMachine {
|
| 40 |
id: number;
|
| 41 |
name: string;
|
|
@@ -98,6 +127,9 @@ export interface CalculationInput {
|
|
| 98 |
offsetWorkType?: OffsetWorkType;
|
| 99 |
offsetMachineId?: number;
|
| 100 |
digitalMachineId?: number;
|
|
|
|
|
|
|
|
|
|
| 101 |
selectedFinishing: SelectedFinishing[];
|
| 102 |
profitMarginPercent: number;
|
| 103 |
discountAmount?: number;
|
|
@@ -221,6 +253,16 @@ export interface CalculationResult {
|
|
| 221 |
vatAmount: number;
|
| 222 |
finalPrice: number;
|
| 223 |
unitPrice: number;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
};
|
| 225 |
chosenTech: 'offset' | 'digital';
|
| 226 |
recommendation?: {
|
|
|
|
| 36 |
includesPlate?: boolean; // Đã bao gồm kẽm CTP trong phí mở máy
|
| 37 |
}
|
| 38 |
|
| 39 |
+
export type DigitalMode = 'with_paper' | 'without_paper'; // In nhanh kèm giấy hoặc In gia công không kèm giấy
|
| 40 |
+
|
| 41 |
+
export interface DigitalPrintGiaCong {
|
| 42 |
+
id: number;
|
| 43 |
+
machineName: string;
|
| 44 |
+
sheetSize: string; // 'A4' | '330x355' | 'A3' | '330x483' | '330x1200'
|
| 45 |
+
widthMm: number;
|
| 46 |
+
heightMm: number;
|
| 47 |
+
paperLt249: number; // Giấy (<249gsm)
|
| 48 |
+
paper250To349: number; // Giấy (250gsm - 349gsm)
|
| 49 |
+
paper350To450: number; // Giấy (350gsm - 450gsm)
|
| 50 |
+
decalPaperPlastic: number; // Decal (giấy - nhựa)
|
| 51 |
+
decalClear: number; // Decal trong
|
| 52 |
+
syntheticPaper: number; // Giấy nhựa (<280gsm)
|
| 53 |
+
pvcPlastic: number; // Nhựa PVC
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
export interface DigitalPrintKemGiay {
|
| 57 |
+
id: number;
|
| 58 |
+
paperCode: string;
|
| 59 |
+
paperName: string;
|
| 60 |
+
gsm: number;
|
| 61 |
+
sheetSize: string; // '325x430' | '325x355'
|
| 62 |
+
widthMm: number;
|
| 63 |
+
heightMm: number;
|
| 64 |
+
price1Side: number; // In 1 mặt + giấy
|
| 65 |
+
price2Side: number; // In 2 mặt + giấy
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
export interface DigitalMachine {
|
| 69 |
id: number;
|
| 70 |
name: string;
|
|
|
|
| 127 |
offsetWorkType?: OffsetWorkType;
|
| 128 |
offsetMachineId?: number;
|
| 129 |
digitalMachineId?: number;
|
| 130 |
+
digitalMode?: DigitalMode; // 'with_paper' (Kèm giấy xưởng) | 'without_paper' (In gia công Konica)
|
| 131 |
+
customerSuppliedPaper?: boolean; // Nếu in gia công mà khách mang giấy đến -> tiền giấy = 0
|
| 132 |
+
whiteInk?: boolean; // Tùy chọn in mực trắng (Konica C12000)
|
| 133 |
selectedFinishing: SelectedFinishing[];
|
| 134 |
profitMarginPercent: number;
|
| 135 |
discountAmount?: number;
|
|
|
|
| 253 |
vatAmount: number;
|
| 254 |
finalPrice: number;
|
| 255 |
unitPrice: number;
|
| 256 |
+
shortRunFee?: number; // Phí số lượng ít (<50 tờ: +30k, <100 tờ: +20k)
|
| 257 |
+
digitalDetails?: {
|
| 258 |
+
mode: DigitalMode;
|
| 259 |
+
machineName?: string;
|
| 260 |
+
sheetSizeName?: string;
|
| 261 |
+
ratePerSheet: number;
|
| 262 |
+
shortRunFee: number;
|
| 263 |
+
whiteInkCost?: number;
|
| 264 |
+
note?: string;
|
| 265 |
+
};
|
| 266 |
};
|
| 267 |
chosenTech: 'offset' | 'digital';
|
| 268 |
recommendation?: {
|