tien_nemo

Merge branch '2025/ntctien/14567_edit_template' into 'dev'

2025/ntctien/14567 edit template

See merge request !2
......@@ -17,34 +17,42 @@ class OcrController extends Controller
public function store(Request $request)
{
// dd($request->all());
$request->validate([
'customer_name_text' => 'required|string',
'customer_name_xy' => 'required|string',
'template_name' => 'required|string|unique:mst_template,tpl_name',
]);
$dataDetail = $request->fields ?? [];
try {
// Lưu vào bảng mst_template
$mst = MstTemplate::create([
'tpl_name' => $request->template_name,
$masterTemplate = MstTemplate::updateOrCreate(
['tpl_name' => $request->template_name],
[
'tpl_text' => $request->customer_name_text,
'tpl_xy' => $request->customer_name_xy,
]);
]
);
// Lưu các field khác vào dt_template
foreach ($request->fields as $field => $value) {
DtTemplate::create([
'tpl_id' => $mst->id,
'field_name' => $field,
'field_xy' => is_array($value['coords']) ? implode(',', $value['coords']) : $value['coords'],
]);
foreach ($dataDetail as $field => $value) {
if (empty($value['coords'])) {
continue;
}
DtTemplate::updateOrInsert(
[
'tpl_id' => $masterTemplate->id,
'field_name' => $field,
],
[
'field_xy' => is_array($value['coords'])
? implode(',', $value['coords'])
: $value['coords'],
]
);
}
return response()->json([
'success' => true,
'message' => 'Lưu template thành công',
'template_id' => $mst->id
'template_id' => $masterTemplate->id
]);
} catch (\Exception $e) {
return response()->json([
......@@ -59,7 +67,7 @@ class OcrController extends Controller
{
try {
// Lấy template name từ request hoặc mặc định
$templateName = $request->get('template_name', '');
$templateName = $request->get('template_name', 'nemo12');
// Giả sử file OCR JSON & ảnh nằm trong storage/app/public/image/
$jsonPath = public_path("image/data_picking_detail_1754967679.json");
......@@ -127,52 +135,4 @@ class OcrController extends Controller
], 500);
}
}
/**
* Lấy danh sách template
*/
public function getTemplateList()
{
try {
$templates = MstTemplate::select('id', 'tpl_name', 'tpl_text', 'in_date')
->orderBy('created_at', 'desc')
->get();
return response()->json([
'success' => true,
'templates' => $templates
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'error' => 'Lỗi khi lấy danh sách template: ' . $e->getMessage()
], 500);
}
}
/**
* Xóa template
*/
public function deleteTemplate($id)
{
try {
$template = MstTemplate::findOrFail($id);
// Xóa các field detail trước
DtTemplate::where('tpl_id', $id)->delete();
// Xóa template chính
$template->delete();
return response()->json([
'success' => true,
'message' => 'Xóa template thành công'
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'error' => 'Lỗi khi xóa template: ' . $e->getMessage()
], 500);
}
}
}
......
......@@ -10,6 +10,7 @@ class DtTemplate extends Model
public $timestamps = false;
protected $table = 'dt_template';
protected $primaryKey = 'tpl_detail_id';
protected $guarded = [];
......
......@@ -62,7 +62,6 @@
<body>
<meta name="csrf-token" content="{{ csrf_token() }}">
<div id="app">
<!-- Right: PDF viewer + select tool -->
<div class="right-panel" >
<div class="pdf-container" ref="pdfContainer"
@mousedown="startSelect"
......@@ -120,17 +119,15 @@
</div>
</div>
<!-- Left: Form inputs -->
<div class="left-panel">
<div v-for="field in fieldOptions" :key="field.value" class="form-group">
<label>@{{ field.label }}</label>
<input v-model="formData[field.value]"
@focus="highlightField(field.value)"
@click="onInputClick(field.value)"
@blur="onInputBlur(field.value)"
@blur="removeAllFocus()"
>
{{-- :readonly="field.value === 'customer_name' && !hasCustomerNameXY"--}}
</div>
<button @click="saveTemplate">💾Save</button>
</div>
......@@ -160,7 +157,6 @@
}
},
created() {
// Chỉ tạo formData cho các field cần mapping
this.fieldOptions
.forEach(f => {
this.$set(this.formData, f.value, "");
......@@ -171,7 +167,6 @@
// Thêm event listener để xóa focus khi click ra ngoài
document.addEventListener('click', (e) => {
// Nếu click không phải vào input hoặc box
if (!e.target.closest('.left-panel') && !e.target.closest('.bbox')) {
this.removeAllFocus();
}
......@@ -191,12 +186,11 @@
// Xóa tất cả box có fieldName trùng lặp, chỉ giữ lại box hiện tại
this.ocrData = this.ocrData.filter((box, i) => {
if (i === index) return true; // Giữ lại box hiện tại
if (i === index) return true;
if (box.field === fieldName) {
// Xóa box có field trùng lặp
return false;
}
return true; // Giữ lại các box khác
return true;
});
// Cập nhật lại index sau khi filter
......@@ -214,8 +208,7 @@
this.ocrData[newIndex].field_xy = null;
}
// Gán field mới
const bbox = this.ocrData[newIndex].bbox; // tọa độ OCR gốc [x1, y1, x2, y2]
const bbox = this.ocrData[newIndex].bbox;
const x1 = bbox[0];
const y1 = bbox[1];
......@@ -223,86 +216,17 @@
const h = bbox[3];
const xyStr = `${x1},${y1},${w},${h}`;
this.ocrData[newIndex].field = fieldName;
this.ocrData[newIndex].field_xy = xyStr;
// Set text
this.formData[fieldName] = (text !== null ? text : (this.ocrData[newIndex].text || '')).trim();
// KHÔNG set active index (không focus)
// Nếu là customer_name
if (fieldName === 'customer_name') {
this.hasCustomerNameXY = true;
this.customer_name_xy = xyStr;
}
},
assignFieldToBox(index, fieldName, text = null) {
console.log(`Assigning field "${fieldName}" to box at index ${index} with text: "${text}"`);
if (index == null) return;
// 1. Xóa tất cả box có fieldName trùng lặp, chỉ giữ lại box hiện tại
this.ocrData = this.ocrData.filter((box, i) => {
if (i === index) return true; // Giữ lại box hiện tại
if (box.field === fieldName) {
// Xóa box có field trùng lặp
return false;
}
return true; // Giữ lại các box khác
});
// 2. Cập nhật lại index sau khi filter
const newIndex = this.ocrData.findIndex(box => box.bbox === this.ocrData[index]?.bbox);
if (newIndex === -1) return;
// 3. Nếu box này từng gán field khác thì bỏ
const prev = this.ocrData[newIndex]?.field;
if (prev && prev !== fieldName) {
if (prev === 'customer_name') {
this.hasCustomerNameXY = false;
this.customer_name_xy = '';
}
this.ocrData[newIndex].field = null;
this.ocrData[newIndex].field_xy = null;
}
// 4. Gán field mới
const bbox = this.ocrData[newIndex].bbox; // tọa độ OCR gốc [x1, y1, x2, y2]
console.log('bbox', bbox);
const [x1, y1, w, h] = bbox;
const xyStr = `${x1},${y1},${w},${h}`;
this.ocrData[newIndex].field = fieldName;
this.ocrData[newIndex].field_xy = xyStr;
// 5. Gán text
const finalText = text !== null ? text : (this.ocrData[newIndex].text || '');
this.formData[fieldName] = finalText.trim();
console.log(`formData ${fieldName}`, this.formData[fieldName]);
// Nếu trong manualBoxData tồn tại field này hoặc muốn tạo lại manual box từ ocrData
if (this.manualBoxData[fieldName]) {
// Lấy tọa độ + text từ box hiện tại để tạo manual box mới
this.createManualBoxFromDB(fieldName, this.ocrData[newIndex].bbox, finalText);
// Cập nhật manualBoxData
this.manualBoxData[fieldName] = {
coords: this.ocrData[newIndex].bbox,
text: finalText
};
}
// 6. Active index
this.activeIndex = newIndex;
// 7. Nếu là customer_name
if (fieldName === 'customer_name') {
this.hasCustomerNameXY = true;
this.customer_name_xy = xyStr;
}
},
async saveTemplate() {
let customer_name = null;
......@@ -315,7 +239,6 @@
fields = this.manualBoxData;
console.log('Using manualBoxData for customer_name:', customer_name, customer_coords);
} else {
// Không có → tìm trong ocrData
const found = this.ocrData.find(item => item.field === 'customer_name');
if (found) {
customer_name = found.text;
......@@ -325,20 +248,17 @@
const fieldsByName = {};
this.ocrData.forEach(box => {
if (box.field && !box.isDeleted) {
// chỉ giữ 1 bản ghi cuối cùng cho mỗi field (box gần nhất)
fieldsByName[box.field] = {
text: box.field,
coords: box.field_xy || ''
};
}
});
// // convert to array
fields = (fieldsByName);
console.log('Using ocrData for customer_name:', customer_name, customer_coords);
}
// console.log('fields:', fields);
if (!customer_coords || !customer_name) {
alert("Bạn phải map customer_name (quét/select) trước khi lưu.");
return;
......@@ -350,7 +270,7 @@
customer_name_xy: customer_coords || [],
fields: fields
};
// console.log(fields);
try {
const res = await fetch('/ocr/save-template', {
method: 'POST',
......@@ -377,7 +297,6 @@
const item = this.ocrData[index];
if (item.isManual) {
const manualBbox = item.bbox;
// Hiện lại border các box OCR gốc nằm trong vùng thủ công
this.ocrData.forEach(o => {
if (!o.isManual && this.isBoxInside(o.bbox, manualBbox)) {
......@@ -415,17 +334,8 @@
this.ocrData = data.ocrData;
this.pdfImageUrl = data.pdfImageUrl;
this.fieldOptions = data.fieldOptions;
// this.formData = data.formData;
this.dataMapping = data.dataMapping;
this.is_template = data.is_template;
// Đợi image load xong trước khi xử lý
// if (this.$refs.pdfImage && this.$refs.pdfImage.complete) {
// // this.processLoadedData();
// } else {
// console.log('Image not loaded yet, waiting for onImageLoad');
// // Image sẽ được xử lý trong onImageLoad
// }
} catch (error) {
console.error('Error in loadOCRData:', error);
......@@ -434,16 +344,13 @@
// Xử lý data sau khi image đã load
processLoadedData() {
// Tự động map field cho các box OCR dựa trên formData đã load
this.autoMapFieldsFromFormData();
// Force re-render để đảm bảo các box được vẽ
this.$nextTick(() => {
this.$forceUpdate();
});
},
// Tự động map field cho các box OCR dựa trên formData đã load từ DB
autoMapFieldsFromFormData() {
this.manualBoxData = {}; // reset
......@@ -452,7 +359,6 @@
}
const tolerance = 20; // ngưỡng chenh lech toa do y cho cùng 1 hàng
Object.keys(this.dataMapping).forEach(fieldName => {
let { coords, text } = this.dataMapping[fieldName];
let foundItems = this.ocrData
......@@ -470,13 +376,10 @@
const combinedText = foundItems.map(f => f.text.trim());
const finalText = combinedText.join(" ");
// Lưu vào dataMapping
this.dataMapping[fieldName] = {
text: finalText,
coords: coords
};
// Nếu là template thì gán vào formData
if (this.is_template) {
this.formData[fieldName] = finalText && finalText.trim() !== "" ? finalText : text;
}
......@@ -493,7 +396,6 @@
const img = this.$refs.pdfImage;
this.imageWidth = img.naturalWidth;
this.imageHeight = img.naturalHeight;
// Nếu đã có data, xử lý ngay
if (this.ocrData && this.ocrData.length > 0) {
console.log('Image loaded and data exists, processing now');
this.processLoadedData();
......@@ -555,7 +457,6 @@
}
}
// Nếu có coords thì tạo hoặc hiển thị lại box
if (coords) {
if (isFromDB) {
// Tạo box manual từ DB (không có nút xóa)
......@@ -586,8 +487,6 @@
}
},
// Scroll đến box tương ứng
scrollToBox(index) {
if (!this.$refs.pdfContainer || index < 0 || index >= this.ocrData.length) return;
......@@ -623,8 +522,6 @@
});
},
// Xử lý khi click vào input
onInputClick(fieldName) {
// Kiểm tra xem field này có data không
......@@ -635,12 +532,6 @@
}
},
// Xử lý khi click ra ngoài input (blur)
onInputBlur(fieldName) {
// Khi không focus vào input nào, xóa tất cả focus
this.removeAllFocus();
},
// Xóa tất cả focus
removeAllFocus() {
// Reset active index (chỉ mất màu xanh, không xóa box)
......@@ -779,8 +670,7 @@
e.stopPropagation();
e.preventDefault();
}
,
},
applyMapping() {
const item = this.ocrData[this.selectingIndex];
if (!item) return;
......@@ -790,8 +680,6 @@
box.field = '';
}
});
console.log(`mapping box ${this.selectingIndex} with field "${item.field}" item.isManual: ${item.isManual}`);
if (item.isManual) {
// Nếu là manual box, chuyển sang chế độ manual mapping
this.manualIndex = this.selectingIndex;
......@@ -802,12 +690,9 @@
// Xử lý box OCR (có thể chưa có field hoặc đã có field)
if (item.field) {
// Nếu box đã có field, cập nhật lại
const oldField = item.field;
console.log(`Updating box OCR at index ${this.selectingIndex} from field "${oldField}" to "${item.field}" "${item.bbox}"`);
// Cập nhật formData để hiển thị trong input
this.formData[item.field] = item.text || '';
// Cập nhật manualBoxData với tọa độ mới (box OCR không có nút xóa)
this.manualBoxData[item.field] = {
coords: item.bbox,
......@@ -819,8 +704,6 @@
if (this.dataMapping && this.dataMapping[item.field]) {
delete this.dataMapping[item.field];
}
// Set active index
this.activeIndex = this.selectingIndex;
}
......@@ -871,13 +754,8 @@
const finalText = combinedText.join(" ");
// Gán field trực tiếp cho box manual
this.ocrData[manualIndex].field = this.manualField;
// Cập nhật formData để hiển thị trong input
this.formData[this.manualField] = finalText.trim();
// Cập nhật manualBoxData (box quét chọn có nút xóa)
this.manualBoxData[this.manualField] = {
coords: newBbox,
text: finalText.trim(),
......@@ -911,10 +789,10 @@
// Kiểm tra xem box OCR có giao nhau với vùng manual không
const isOverlapping = !(
inner[2] < outer[0] || // box bên trái vùng chọn
inner[0] > outer[2] || // box bên phải vùng chọn
inner[3] < outer[1] || // box phía trên vùng chọn
inner[1] > outer[3] // box phía dưới vùng chọn
inner[2] < outer[0] || // inner.right < outer.left → hoàn toàn bên trái
inner[0] > outer[2] || // inner.left > outer.right → hoàn toàn bên phải
inner[3] < outer[1] || // inner.bottom < outer.top → hoàn toàn phía trên
inner[1] > outer[3] // inner.top > outer.bottom → hoàn toàn phía dưới
);
// Trả về true nếu box OCR nằm hoàn toàn trong hoặc giao nhau đáng kể
......@@ -926,11 +804,7 @@
getPartialText(text, bbox, selectBbox) {
const [x1, y1, x2, y2] = bbox;
const [sx1, sy1, sx2, sy2] = selectBbox;
// Chiều rộng box OCR
const boxWidth = x2 - x1;
const boxHeight = y2 - y1;
// Vị trí start và end tương đối trong text
let startRatio = Math.max(0, (sx1 - x1) / boxWidth);
let endRatio = Math.min(1, (sx2 - x1) / boxWidth);
......@@ -982,8 +856,6 @@
// Xóa box cũ có cùng fieldName trước khi tạo mới (chỉ xóa manual box)
this.ocrData = this.ocrData.filter(box => !(box.field === fieldName && box.isManual));
// Tạo box manual từ DB (không có nút xóa)
const manualBox = {
text: text || '',
bbox: coords,
......@@ -1045,8 +917,6 @@
};
this.ocrData.push(manualBox);
console.log(`Manual box shown successfully: ${isFromOCR ? 'from OCR (no delete btn)' : 'from manual selection (with delete btn)'}`);
// Force re-render
this.$forceUpdate();
} else {
......