chore(v2): restructure monorepo to src/ layout with uv
Aligns the repo with the python-project-spec-design.md template chosen for V2.0.0. Big move, no logic changes. The 3 pre-existing test failures (test_recipes::test_update_recipe, test_recipes:: test_recipe_versioning, test_tasks::test_reorder_tasks, plus the client test_save_measurement_proxy) survive unchanged. Layout changes - server/ -> src/backend/ - server/middleware/ -> src/backend/api/middleware/ - server/routers/ -> src/backend/api/routers/ - server/models/ -> src/backend/models/orm/ - server/schemas/ -> src/backend/models/api/ - server/uploads/ -> uploads/ (project root, mounted volume) - server/tests/ -> src/backend/tests/ - client/ -> src/frontend/flask_app/ (Flask kept; React deroga is documented in CLAUDE.md, justified by tablet UX, USB caliper/barcode workflow and Fabric.js integration) Tooling - pyproject.toml: monorepo with [project] core deps and optional-dependencies server / client / dev. Replaces both server/requirements.txt and client/requirements.txt. - uv.lock + .python-version (3.11) committed for reproducible builds. - Dockerfile (root, backend) and Dockerfile.frontend rewritten to use uv sync --frozen --no-dev --extra server|client; legacy Dockerfiles preserved as Dockerfile.legacy for reference but excluded from build context via .dockerignore. - docker-compose.dev.yml + docker-compose.yml: build context now ".", dockerfile pointing to the root files. Code adjustments forced by the move - Every "from config|database|models|schemas|services|routers|middleware import ..." rewritten to its src.backend.* equivalent (50+ files including indented inline imports inside test bodies). - src/backend/migrations/env.py: insert project root into sys.path so alembic can resolve src.backend.* imports regardless of cwd. - src/backend/config.py: env_file ../../.env (was ../.env), upload_path resolves project root via parents[2]. - src/backend/tests/conftest.py + tests: import ... from src.backend.* instead of bare names; old per-directory pytest.ini files removed in favor of root pyproject.toml [tool.pytest.ini_options]. - .gitignore: uploads/ at root, src/frontend/flask_app/static/css/ tailwind.css path; .dockerignore tightened. - CLAUDE.md: rewrote sections "Layout del repository", "Comandi di Sviluppo", "Database & Migrations", "Test", "i18n", and all path references throughout the architecture sections. Verified - uv lock resolves 77 packages; uv sync --extra server --extra client --extra dev installs cleanly. - uv run pytest: 171 passed, 4 pre-existing failures. - uv run alembic -c src/backend/migrations/alembic.ini check loads config and metadata (errors only on the absent local MySQL). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* CSV Export Utility
|
||||
* Client-side CSV generation and download with Italian locale support
|
||||
* Uses Blob API for efficient file generation
|
||||
*/
|
||||
|
||||
function csvExport() {
|
||||
const _t = (key) => (window.CSV_I18N && window.CSV_I18N[key]) || key;
|
||||
|
||||
return {
|
||||
// Locale-specific settings
|
||||
delimiter: ';', // Italian Excel standard
|
||||
decimalSeparator: ',', // Italian number format
|
||||
dateFormat: 'dd/MM/yyyy HH:mm:ss',
|
||||
|
||||
/**
|
||||
* Export measurements to CSV file
|
||||
* @param {Array} measurements - Array of measurement objects
|
||||
* @param {String} filename - Optional filename (auto-generated if not provided)
|
||||
*/
|
||||
exportMeasurements(measurements, filename = null) {
|
||||
if (!measurements || !Array.isArray(measurements) || measurements.length === 0) {
|
||||
console.warn('No measurements to export');
|
||||
return;
|
||||
}
|
||||
|
||||
// Build CSV content
|
||||
const csv = this.buildMeasurementCSV(measurements);
|
||||
|
||||
// Download file
|
||||
this.downloadCSV(csv, filename || this.generateFilename('misure'));
|
||||
},
|
||||
|
||||
/**
|
||||
* Export task execution summary
|
||||
* @param {Object} task - Task object with measurements
|
||||
* @param {String} filename - Optional filename
|
||||
*/
|
||||
exportTaskSummary(task, filename = null) {
|
||||
if (!task || !task.measurements || task.measurements.length === 0) {
|
||||
console.warn('No task data to export');
|
||||
return;
|
||||
}
|
||||
|
||||
const csv = this.buildTaskSummaryCSV(task);
|
||||
this.downloadCSV(csv, filename || this.generateFilename(`task_${task.id}`));
|
||||
},
|
||||
|
||||
/**
|
||||
* Build CSV content for measurements
|
||||
*/
|
||||
buildMeasurementCSV(measurements) {
|
||||
const headers = [
|
||||
'ID',
|
||||
_t('subtask_id'),
|
||||
_t('subtask_name'),
|
||||
_t('measured_value'),
|
||||
_t('unit'),
|
||||
_t('nominal_value'),
|
||||
_t('tolerance_plus'),
|
||||
_t('tolerance_minus'),
|
||||
_t('deviation'),
|
||||
_t('result'),
|
||||
_t('lot_number'),
|
||||
_t('serial_number'),
|
||||
_t('input_method'),
|
||||
_t('measurement_date'),
|
||||
_t('operator')
|
||||
];
|
||||
|
||||
let csv = headers.join(this.delimiter) + '\n';
|
||||
|
||||
for (const m of measurements) {
|
||||
const row = [
|
||||
m.id || '',
|
||||
m.subtask_id || '',
|
||||
this.escapeCsvValue(m.subtask_name || ''),
|
||||
this.formatNumber(m.value),
|
||||
this.escapeCsvValue(m.unit || 'mm'),
|
||||
this.formatNumber(m.nominal),
|
||||
this.formatNumber(m.tolerance_plus),
|
||||
this.formatNumber(m.tolerance_minus),
|
||||
this.formatNumber(m.deviation),
|
||||
m.pass_fail || '',
|
||||
this.escapeCsvValue(m.lot_number || ''),
|
||||
this.escapeCsvValue(m.serial_number || ''),
|
||||
m.input_method || '',
|
||||
this.formatDateTime(m.measured_at),
|
||||
this.escapeCsvValue(m.operator_name || '')
|
||||
];
|
||||
csv += row.join(this.delimiter) + '\n';
|
||||
}
|
||||
|
||||
return csv;
|
||||
},
|
||||
|
||||
/**
|
||||
* Build CSV content for task summary
|
||||
*/
|
||||
buildTaskSummaryCSV(task) {
|
||||
// Header section
|
||||
let csv = _t('task_summary_title') + '\n\n';
|
||||
|
||||
csv += `${_t('task_id')}${this.delimiter}${task.id}\n`;
|
||||
csv += `${_t('task_name')}${this.delimiter}${this.escapeCsvValue(task.name || '')}\n`;
|
||||
csv += `${_t('recipe')}${this.delimiter}${this.escapeCsvValue(task.recipe_name || '')}\n`;
|
||||
csv += `${_t('operator')}${this.delimiter}${this.escapeCsvValue(task.operator_name || '')}\n`;
|
||||
csv += `${_t('start_date')}${this.delimiter}${this.formatDateTime(task.started_at)}\n`;
|
||||
csv += `${_t('end_date')}${this.delimiter}${this.formatDateTime(task.completed_at)}\n`;
|
||||
csv += `${_t('status')}${this.delimiter}${task.status || ''}\n`;
|
||||
csv += `${_t('lot')}${this.delimiter}${this.escapeCsvValue(task.lot_number || '')}\n`;
|
||||
csv += `${_t('serial')}${this.delimiter}${this.escapeCsvValue(task.serial_number || '')}\n`;
|
||||
|
||||
// Statistics
|
||||
csv += `\n${_t('statistics')}\n`;
|
||||
const stats = this.calculateStats(task.measurements);
|
||||
csv += `${_t('total_measurements')}${this.delimiter}${stats.total}\n`;
|
||||
csv += `${_t('passed')}${this.delimiter}${stats.passed}\n`;
|
||||
csv += `${_t('failed')}${this.delimiter}${stats.failed}\n`;
|
||||
csv += `${_t('pass_rate')}${this.delimiter}${this.formatNumber(stats.passRate)}%\n`;
|
||||
|
||||
// Measurements detail
|
||||
csv += `\n${_t('measurement_details')}\n`;
|
||||
csv += this.buildMeasurementCSV(task.measurements);
|
||||
|
||||
return csv;
|
||||
},
|
||||
|
||||
/**
|
||||
* Calculate statistics from measurements
|
||||
*/
|
||||
calculateStats(measurements) {
|
||||
const total = measurements.length;
|
||||
const passed = measurements.filter(m => (m.pass_fail || '').toLowerCase() === 'pass').length;
|
||||
const failed = total - passed;
|
||||
const passRate = total > 0 ? (passed / total * 100) : 0;
|
||||
|
||||
return { total, passed, failed, passRate };
|
||||
},
|
||||
|
||||
/**
|
||||
* Download CSV content as file
|
||||
*/
|
||||
downloadCSV(csvContent, filename) {
|
||||
// Add UTF-8 BOM for Excel compatibility
|
||||
const BOM = '\uFEFF';
|
||||
const blob = new Blob([BOM + csvContent], {
|
||||
type: 'text/csv;charset=utf-8;'
|
||||
});
|
||||
|
||||
// Create download link
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
|
||||
// Trigger download
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
// Cleanup
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
|
||||
/**
|
||||
* Format number with Italian decimal separator
|
||||
*/
|
||||
formatNumber(value) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const num = typeof value === 'number' ? value : parseFloat(value);
|
||||
if (isNaN(num)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Convert to string and replace decimal separator
|
||||
return num.toString().replace('.', this.decimalSeparator);
|
||||
},
|
||||
|
||||
/**
|
||||
* Format datetime for Italian locale
|
||||
*/
|
||||
formatDateTime(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
if (isNaN(date.getTime())) return '';
|
||||
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const year = date.getFullYear();
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
|
||||
return `${day}/${month}/${year} ${hours}:${minutes}:${seconds}`;
|
||||
} catch (err) {
|
||||
console.error('Date format error:', err);
|
||||
return dateStr;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Escape CSV value (handle quotes, delimiters)
|
||||
*/
|
||||
escapeCsvValue(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const str = String(value);
|
||||
|
||||
// If contains delimiter, newline, or quote, wrap in quotes
|
||||
if (str.includes(this.delimiter) || str.includes('\n') || str.includes('"')) {
|
||||
// Escape existing quotes by doubling them
|
||||
return '"' + str.replace(/"/g, '""') + '"';
|
||||
}
|
||||
|
||||
return str;
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate filename with timestamp
|
||||
*/
|
||||
generateFilename(prefix) {
|
||||
const now = new Date();
|
||||
const dateStr = now.toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
const timeStr = now.toTimeString().slice(0, 5).replace(':', ''); // HHMM
|
||||
|
||||
return `${prefix}_${dateStr}_${timeStr}.csv`;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* CSV Export Button Component
|
||||
* Reusable Alpine component for export functionality
|
||||
*/
|
||||
function csvExportButton() {
|
||||
return {
|
||||
exporting: false,
|
||||
|
||||
async exportData(data, type = 'measurements') {
|
||||
this.exporting = true;
|
||||
|
||||
try {
|
||||
const exporter = csvExport();
|
||||
|
||||
if (type === 'measurements') {
|
||||
exporter.exportMeasurements(data);
|
||||
} else if (type === 'task') {
|
||||
exporter.exportTaskSummary(data);
|
||||
}
|
||||
|
||||
// Show success feedback
|
||||
this.$dispatch('export-success', { type });
|
||||
|
||||
} catch (err) {
|
||||
console.error('Export error:', err);
|
||||
this.$dispatch('export-error', { error: err.message });
|
||||
} finally {
|
||||
this.exporting = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user