feat: G-2 작업실적 관리 API 구현

- WorkResult 모델 생성 (Production 네임스페이스)
- WorkResultService 서비스 구현 (CRUD + 통계 + 토글)
- WorkResultController 컨트롤러 생성 (8개 엔드포인트)
- FormRequest 검증 클래스 (Store/Update)
- Swagger 문서 작성 (WorkResultApi.php)
- 라우트 추가 (/api/v1/work-results)
- i18n 메시지 추가 (work_result 키)

API Endpoints:
- GET /work-results - 목록 조회 (페이징, 필터링)
- GET /work-results/stats - 통계 조회
- GET /work-results/{id} - 상세 조회
- POST /work-results - 등록
- PUT /work-results/{id} - 수정
- DELETE /work-results/{id} - 삭제
- PATCH /work-results/{id}/inspection - 검사 상태 토글
- PATCH /work-results/{id}/packaging - 포장 상태 토글

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-26 15:18:08 +09:00
parent dd0f79b947
commit 84cce6742e
9 changed files with 1154 additions and 0 deletions

View File

@@ -0,0 +1,97 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\WorkResult\WorkResultStoreRequest;
use App\Http\Requests\WorkResult\WorkResultUpdateRequest;
use App\Services\WorkResultService;
use Illuminate\Http\Request;
class WorkResultController extends Controller
{
public function __construct(private WorkResultService $service) {}
/**
* 목록 조회
*/
public function index(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->index($request->all());
}, __('message.work_result.fetched'));
}
/**
* 통계 조회
*/
public function stats(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->stats($request->all());
}, __('message.work_result.fetched'));
}
/**
* 단건 조회
*/
public function show(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->show($id);
}, __('message.work_result.fetched'));
}
/**
* 생성
*/
public function store(WorkResultStoreRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->store($request->validated());
}, __('message.work_result.created'));
}
/**
* 수정
*/
public function update(WorkResultUpdateRequest $request, int $id)
{
return ApiResponse::handle(function () use ($request, $id) {
return $this->service->update($id, $request->validated());
}, __('message.work_result.updated'));
}
/**
* 삭제
*/
public function destroy(int $id)
{
return ApiResponse::handle(function () use ($id) {
$this->service->destroy($id);
return 'success';
}, __('message.work_result.deleted'));
}
/**
* 검사 상태 토글
*/
public function toggleInspection(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->toggleInspection($id);
}, __('message.work_result.inspection_toggled'));
}
/**
* 포장 상태 토글
*/
public function togglePackaging(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->togglePackaging($id);
}, __('message.work_result.packaging_toggled'));
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Http\Requests\WorkResult;
use Illuminate\Foundation\Http\FormRequest;
class WorkResultStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'work_order_id' => 'required|integer|exists:work_orders,id',
'work_order_item_id' => 'nullable|integer|exists:work_order_items,id',
'lot_no' => 'required|string|max:50',
'work_date' => 'required|date',
'process_type' => 'nullable|in:screen,slat,bending',
'product_name' => 'required|string|max:200',
'specification' => 'nullable|string|max:100',
'production_qty' => 'required|integer|min:0',
'good_qty' => 'nullable|integer|min:0',
'defect_qty' => 'required|integer|min:0',
'is_inspected' => 'nullable|boolean',
'is_packaged' => 'nullable|boolean',
'worker_id' => 'nullable|integer|exists:users,id',
'memo' => 'nullable|string|max:2000',
];
}
public function messages(): array
{
return [
'work_order_id.required' => __('validation.required', ['attribute' => '작업지시']),
'work_order_id.exists' => __('validation.exists', ['attribute' => '작업지시']),
'lot_no.required' => __('validation.required', ['attribute' => '로트번호']),
'work_date.required' => __('validation.required', ['attribute' => '작업일']),
'product_name.required' => __('validation.required', ['attribute' => '품목명']),
'production_qty.required' => __('validation.required', ['attribute' => '생산수량']),
'defect_qty.required' => __('validation.required', ['attribute' => '불량수량']),
];
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Http\Requests\WorkResult;
use Illuminate\Foundation\Http\FormRequest;
class WorkResultUpdateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'work_order_id' => 'sometimes|integer|exists:work_orders,id',
'work_order_item_id' => 'nullable|integer|exists:work_order_items,id',
'lot_no' => 'sometimes|string|max:50',
'work_date' => 'sometimes|date',
'process_type' => 'sometimes|in:screen,slat,bending',
'product_name' => 'sometimes|string|max:200',
'specification' => 'nullable|string|max:100',
'production_qty' => 'sometimes|integer|min:0',
'good_qty' => 'nullable|integer|min:0',
'defect_qty' => 'sometimes|integer|min:0',
'is_inspected' => 'nullable|boolean',
'is_packaged' => 'nullable|boolean',
'worker_id' => 'nullable|integer|exists:users,id',
'memo' => 'nullable|string|max:2000',
];
}
public function messages(): array
{
return [
'work_order_id.exists' => __('validation.exists', ['attribute' => '작업지시']),
'lot_no.max' => __('validation.max.string', ['attribute' => '로트번호', 'max' => 50]),
'work_date.date' => __('validation.date', ['attribute' => '작업일']),
'product_name.max' => __('validation.max.string', ['attribute' => '품목명', 'max' => 200]),
];
}
}

View File

@@ -0,0 +1,242 @@
<?php
namespace App\Models\Production;
use App\Models\Members\User;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* 작업실적 모델
*
* 생산 작업의 실적을 기록하고 추적하는 엔티티
*/
class WorkResult extends Model
{
use BelongsToTenant, ModelTrait, SoftDeletes;
protected $table = 'work_results';
protected $fillable = [
'tenant_id',
'work_order_id',
'work_order_item_id',
'lot_no',
'work_date',
'process_type',
'product_name',
'specification',
'production_qty',
'good_qty',
'defect_qty',
'defect_rate',
'is_inspected',
'is_packaged',
'worker_id',
'memo',
'created_by',
'updated_by',
];
protected $casts = [
'work_date' => 'date',
'production_qty' => 'integer',
'good_qty' => 'integer',
'defect_qty' => 'integer',
'defect_rate' => 'decimal:2',
'is_inspected' => 'boolean',
'is_packaged' => 'boolean',
];
protected $hidden = [
'deleted_at',
];
// ──────────────────────────────────────────────────────────────
// 상수
// ──────────────────────────────────────────────────────────────
/**
* 공정 유형 (WorkOrder와 동일)
*/
public const PROCESS_SCREEN = 'screen';
public const PROCESS_SLAT = 'slat';
public const PROCESS_BENDING = 'bending';
public const PROCESS_TYPES = [
self::PROCESS_SCREEN,
self::PROCESS_SLAT,
self::PROCESS_BENDING,
];
// ──────────────────────────────────────────────────────────────
// 관계
// ──────────────────────────────────────────────────────────────
/**
* 작업지시
*/
public function workOrder(): BelongsTo
{
return $this->belongsTo(WorkOrder::class);
}
/**
* 작업지시 품목
*/
public function workOrderItem(): BelongsTo
{
return $this->belongsTo(WorkOrderItem::class);
}
/**
* 작업자
*/
public function worker(): BelongsTo
{
return $this->belongsTo(User::class, 'worker_id');
}
/**
* 생성자
*/
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
/**
* 수정자
*/
public function updater(): BelongsTo
{
return $this->belongsTo(User::class, 'updated_by');
}
// ──────────────────────────────────────────────────────────────
// 스코프
// ──────────────────────────────────────────────────────────────
/**
* 공정유형별 필터
*/
public function scopeProcessType($query, string $type)
{
return $query->where('process_type', $type);
}
/**
* 작업일 범위 필터
*/
public function scopeWorkDateBetween($query, $from, $to)
{
return $query->whereBetween('work_date', [$from, $to]);
}
/**
* 작업지시별 필터
*/
public function scopeWorkOrder($query, int $workOrderId)
{
return $query->where('work_order_id', $workOrderId);
}
/**
* 작업자별 필터
*/
public function scopeWorker($query, int $workerId)
{
return $query->where('worker_id', $workerId);
}
/**
* 검사 완료 필터
*/
public function scopeInspected($query)
{
return $query->where('is_inspected', true);
}
/**
* 포장 완료 필터
*/
public function scopePackaged($query)
{
return $query->where('is_packaged', true);
}
/**
* 불량 있음
*/
public function scopeHasDefects($query)
{
return $query->where('defect_qty', '>', 0);
}
// ──────────────────────────────────────────────────────────────
// 헬퍼 메서드
// ──────────────────────────────────────────────────────────────
/**
* 불량률 계산
*/
public function calculateDefectRate(): float
{
if ($this->production_qty <= 0) {
return 0;
}
return round(($this->defect_qty / $this->production_qty) * 100, 2);
}
/**
* 불량률 업데이트
*/
public function updateDefectRate(): void
{
$this->defect_rate = $this->calculateDefectRate();
}
/**
* 양품수량 자동 계산 및 설정
*/
public function calculateGoodQty(): int
{
return max(0, $this->production_qty - $this->defect_qty);
}
/**
* 불량이 있는지 확인
*/
public function hasDefects(): bool
{
return $this->defect_qty > 0;
}
/**
* 검사 및 포장 모두 완료인지 확인
*/
public function isFullyProcessed(): bool
{
return $this->is_inspected && $this->is_packaged;
}
// ──────────────────────────────────────────────────────────────
// 부팅
// ──────────────────────────────────────────────────────────────
protected static function boot()
{
parent::boot();
// 저장 전 불량률 자동 계산
static::saving(function (WorkResult $model) {
$model->updateDefectRate();
});
}
}

View File

@@ -0,0 +1,279 @@
<?php
namespace App\Services;
use App\Models\Production\WorkOrder;
use App\Models\Production\WorkResult;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class WorkResultService extends Service
{
/**
* 목록 조회 (검색/필터링/페이징)
*/
public function index(array $params)
{
$tenantId = $this->tenantId();
$page = (int) ($params['page'] ?? 1);
$size = (int) ($params['size'] ?? 20);
$q = trim((string) ($params['q'] ?? ''));
$processType = $params['process_type'] ?? null;
$workOrderId = $params['work_order_id'] ?? null;
$workerId = $params['worker_id'] ?? null;
$workDateFrom = $params['work_date_from'] ?? null;
$workDateTo = $params['work_date_to'] ?? null;
$isInspected = isset($params['is_inspected']) ? filter_var($params['is_inspected'], FILTER_VALIDATE_BOOLEAN) : null;
$isPackaged = isset($params['is_packaged']) ? filter_var($params['is_packaged'], FILTER_VALIDATE_BOOLEAN) : null;
$query = WorkResult::query()
->where('tenant_id', $tenantId)
->with([
'workOrder:id,work_order_no',
'worker:id,name',
]);
// 검색어
if ($q !== '') {
$query->where(function ($qq) use ($q) {
$qq->where('lot_no', 'like', "%{$q}%")
->orWhere('product_name', 'like', "%{$q}%")
->orWhereHas('workOrder', fn ($wo) => $wo->where('work_order_no', 'like', "%{$q}%"));
});
}
// 공정유형 필터
if ($processType !== null) {
$query->where('process_type', $processType);
}
// 작업지시 필터
if ($workOrderId !== null) {
$query->where('work_order_id', $workOrderId);
}
// 작업자 필터
if ($workerId !== null) {
$query->where('worker_id', $workerId);
}
// 작업일 범위
if ($workDateFrom !== null) {
$query->where('work_date', '>=', $workDateFrom);
}
if ($workDateTo !== null) {
$query->where('work_date', '<=', $workDateTo);
}
// 검사 완료 필터
if ($isInspected !== null) {
$query->where('is_inspected', $isInspected);
}
// 포장 완료 필터
if ($isPackaged !== null) {
$query->where('is_packaged', $isPackaged);
}
$query->orderByDesc('work_date')->orderByDesc('created_at');
return $query->paginate($size, ['*'], 'page', $page);
}
/**
* 통계 조회
*/
public function stats(array $params = []): array
{
$tenantId = $this->tenantId();
$workDateFrom = $params['work_date_from'] ?? null;
$workDateTo = $params['work_date_to'] ?? null;
$processType = $params['process_type'] ?? null;
$query = WorkResult::where('tenant_id', $tenantId);
// 작업일 범위
if ($workDateFrom !== null) {
$query->where('work_date', '>=', $workDateFrom);
}
if ($workDateTo !== null) {
$query->where('work_date', '<=', $workDateTo);
}
// 공정유형 필터
if ($processType !== null) {
$query->where('process_type', $processType);
}
$totals = $query->select([
DB::raw('SUM(production_qty) as total_production'),
DB::raw('SUM(good_qty) as total_good'),
DB::raw('SUM(defect_qty) as total_defect'),
])->first();
$totalProduction = (int) ($totals->total_production ?? 0);
$totalGood = (int) ($totals->total_good ?? 0);
$totalDefect = (int) ($totals->total_defect ?? 0);
$defectRate = $totalProduction > 0
? round(($totalDefect / $totalProduction) * 100, 1)
: 0;
return [
'total_production' => $totalProduction,
'total_good' => $totalGood,
'total_defect' => $totalDefect,
'defect_rate' => $defectRate,
];
}
/**
* 단건 조회
*/
public function show(int $id)
{
$tenantId = $this->tenantId();
$workResult = WorkResult::where('tenant_id', $tenantId)
->with([
'workOrder:id,work_order_no,project_name,status',
'workOrderItem:id,item_name,specification,quantity',
'worker:id,name',
])
->find($id);
if (! $workResult) {
throw new NotFoundHttpException(__('error.not_found'));
}
return $workResult;
}
/**
* 생성
*/
public function store(array $data)
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
return DB::transaction(function () use ($data, $tenantId, $userId) {
$data['tenant_id'] = $tenantId;
$data['created_by'] = $userId;
$data['updated_by'] = $userId;
// 양품수량 자동 계산 (입력 안 된 경우)
if (! isset($data['good_qty'])) {
$data['good_qty'] = max(0, ($data['production_qty'] ?? 0) - ($data['defect_qty'] ?? 0));
}
// 작업지시 정보로 자동 채움
if (! empty($data['work_order_id'])) {
$workOrder = WorkOrder::find($data['work_order_id']);
if ($workOrder) {
$data['process_type'] = $data['process_type'] ?? $workOrder->process_type;
}
}
return WorkResult::create($data);
});
}
/**
* 수정
*/
public function update(int $id, array $data)
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$workResult = WorkResult::where('tenant_id', $tenantId)->find($id);
if (! $workResult) {
throw new NotFoundHttpException(__('error.not_found'));
}
return DB::transaction(function () use ($workResult, $data, $userId) {
$data['updated_by'] = $userId;
// 양품수량 재계산 (생산수량 또는 불량수량 변경 시)
if (isset($data['production_qty']) || isset($data['defect_qty'])) {
$productionQty = $data['production_qty'] ?? $workResult->production_qty;
$defectQty = $data['defect_qty'] ?? $workResult->defect_qty;
if (! isset($data['good_qty'])) {
$data['good_qty'] = max(0, $productionQty - $defectQty);
}
}
$workResult->update($data);
return $workResult->fresh([
'workOrder:id,work_order_no',
'worker:id,name',
]);
});
}
/**
* 삭제
*/
public function destroy(int $id): void
{
$tenantId = $this->tenantId();
$workResult = WorkResult::where('tenant_id', $tenantId)->find($id);
if (! $workResult) {
throw new NotFoundHttpException(__('error.not_found'));
}
$workResult->delete();
}
/**
* 검사 상태 토글
*/
public function toggleInspection(int $id)
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$workResult = WorkResult::where('tenant_id', $tenantId)->find($id);
if (! $workResult) {
throw new NotFoundHttpException(__('error.not_found'));
}
$workResult->update([
'is_inspected' => ! $workResult->is_inspected,
'updated_by' => $userId,
]);
return $workResult->fresh();
}
/**
* 포장 상태 토글
*/
public function togglePackaging(int $id)
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$workResult = WorkResult::where('tenant_id', $tenantId)->find($id);
if (! $workResult) {
throw new NotFoundHttpException(__('error.not_found'));
}
$workResult->update([
'is_packaged' => ! $workResult->is_packaged,
'updated_by' => $userId,
]);
return $workResult->fresh();
}
}

View File

@@ -0,0 +1,313 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(name="WorkResult", description="작업실적 관리")
*
* @OA\Schema(
* schema="WorkResult",
* type="object",
* required={"id","lot_no","work_date","work_order_id","process_type","product_name"},
*
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="tenant_id", type="integer", example=1),
* @OA\Property(property="work_order_id", type="integer", example=1),
* @OA\Property(property="work_order_item_id", type="integer", nullable=true, example=1),
* @OA\Property(property="lot_no", type="string", example="KD-TS-250212-01-01"),
* @OA\Property(property="work_date", type="string", format="date", example="2025-02-12"),
* @OA\Property(property="process_type", type="string", enum={"screen","slat","bending"}, example="screen"),
* @OA\Property(property="product_name", type="string", example="스크린 셔터 (프리미엄)"),
* @OA\Property(property="specification", type="string", nullable=true, example="8000x2800"),
* @OA\Property(property="production_qty", type="integer", example=10),
* @OA\Property(property="good_qty", type="integer", example=9),
* @OA\Property(property="defect_qty", type="integer", example=1),
* @OA\Property(property="defect_rate", type="number", format="float", example=10.0),
* @OA\Property(property="is_inspected", type="boolean", example=true),
* @OA\Property(property="is_packaged", type="boolean", example=false),
* @OA\Property(property="worker_id", type="integer", nullable=true, example=10),
* @OA\Property(property="memo", type="string", nullable=true),
* @OA\Property(property="created_at", type="string", example="2025-02-12T10:00:00Z"),
* @OA\Property(property="updated_at", type="string", example="2025-02-12T10:00:00Z"),
* @OA\Property(property="work_order", type="object", nullable=true, @OA\Property(property="id", type="integer"), @OA\Property(property="work_order_no", type="string")),
* @OA\Property(property="worker", type="object", nullable=true, @OA\Property(property="id", type="integer"), @OA\Property(property="name", type="string"))
* )
*
* @OA\Schema(
* schema="WorkResultStats",
* type="object",
*
* @OA\Property(property="total_production", type="integer", example=100, description="총 생산수량"),
* @OA\Property(property="total_good", type="integer", example=95, description="총 양품수량"),
* @OA\Property(property="total_defect", type="integer", example=5, description="총 불량수량"),
* @OA\Property(property="defect_rate", type="number", format="float", example=5.0, description="불량률 (%)")
* )
*
* @OA\Schema(
* schema="WorkResultPagination",
* type="object",
*
* @OA\Property(property="current_page", type="integer", example=1),
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/WorkResult")),
* @OA\Property(property="first_page_url", type="string"),
* @OA\Property(property="from", type="integer"),
* @OA\Property(property="last_page", type="integer"),
* @OA\Property(property="per_page", type="integer"),
* @OA\Property(property="total", type="integer")
* )
*
* @OA\Schema(
* schema="WorkResultCreateRequest",
* type="object",
* required={"work_order_id","lot_no","work_date","product_name","production_qty","defect_qty"},
*
* @OA\Property(property="work_order_id", type="integer", description="작업지시 ID"),
* @OA\Property(property="work_order_item_id", type="integer", nullable=true, description="작업지시 품목 ID"),
* @OA\Property(property="lot_no", type="string", maxLength=50, description="로트번호"),
* @OA\Property(property="work_date", type="string", format="date", description="작업일"),
* @OA\Property(property="process_type", type="string", enum={"screen","slat","bending"}, nullable=true, description="공정구분"),
* @OA\Property(property="product_name", type="string", maxLength=200, description="품목명"),
* @OA\Property(property="specification", type="string", maxLength=100, nullable=true, description="규격"),
* @OA\Property(property="production_qty", type="integer", minimum=0, description="생산수량"),
* @OA\Property(property="good_qty", type="integer", minimum=0, nullable=true, description="양품수량 (미입력 시 자동계산)"),
* @OA\Property(property="defect_qty", type="integer", minimum=0, description="불량수량"),
* @OA\Property(property="is_inspected", type="boolean", nullable=true, description="검사완료 여부"),
* @OA\Property(property="is_packaged", type="boolean", nullable=true, description="포장완료 여부"),
* @OA\Property(property="worker_id", type="integer", nullable=true, description="작업자 ID"),
* @OA\Property(property="memo", type="string", maxLength=2000, nullable=true, description="비고")
* )
*
* @OA\Schema(
* schema="WorkResultUpdateRequest",
* type="object",
*
* @OA\Property(property="work_order_id", type="integer", nullable=true),
* @OA\Property(property="work_order_item_id", type="integer", nullable=true),
* @OA\Property(property="lot_no", type="string", maxLength=50, nullable=true),
* @OA\Property(property="work_date", type="string", format="date", nullable=true),
* @OA\Property(property="process_type", type="string", enum={"screen","slat","bending"}, nullable=true),
* @OA\Property(property="product_name", type="string", maxLength=200, nullable=true),
* @OA\Property(property="specification", type="string", maxLength=100, nullable=true),
* @OA\Property(property="production_qty", type="integer", minimum=0, nullable=true),
* @OA\Property(property="good_qty", type="integer", minimum=0, nullable=true),
* @OA\Property(property="defect_qty", type="integer", minimum=0, nullable=true),
* @OA\Property(property="is_inspected", type="boolean", nullable=true),
* @OA\Property(property="is_packaged", type="boolean", nullable=true),
* @OA\Property(property="worker_id", type="integer", nullable=true),
* @OA\Property(property="memo", type="string", maxLength=2000, nullable=true)
* )
*/
class WorkResultApi
{
/**
* @OA\Get(
* path="/api/v1/work-results",
* tags={"WorkResult"},
* summary="작업실적 목록 조회",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\Parameter(name="page", in="query", required=false, @OA\Schema(type="integer", default=1)),
* @OA\Parameter(name="size", in="query", required=false, @OA\Schema(type="integer", default=20)),
* @OA\Parameter(name="q", in="query", required=false, @OA\Schema(type="string"), description="검색어 (로트번호, 품목명, 작업지시번호)"),
* @OA\Parameter(name="process_type", in="query", required=false, @OA\Schema(type="string", enum={"screen","slat","bending"})),
* @OA\Parameter(name="work_order_id", in="query", required=false, @OA\Schema(type="integer")),
* @OA\Parameter(name="worker_id", in="query", required=false, @OA\Schema(type="integer")),
* @OA\Parameter(name="work_date_from", in="query", required=false, @OA\Schema(type="string", format="date")),
* @OA\Parameter(name="work_date_to", in="query", required=false, @OA\Schema(type="string", format="date")),
* @OA\Parameter(name="is_inspected", in="query", required=false, @OA\Schema(type="boolean")),
* @OA\Parameter(name="is_packaged", in="query", required=false, @OA\Schema(type="boolean")),
*
* @OA\Response(response=200, description="조회 성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", ref="#/components/schemas/WorkResultPagination")
* )
* ),
*
* @OA\Response(response=401, description="인증 실패")
* )
*/
public function index() {}
/**
* @OA\Get(
* path="/api/v1/work-results/stats",
* tags={"WorkResult"},
* summary="작업실적 통계 조회",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\Parameter(name="work_date_from", in="query", required=false, @OA\Schema(type="string", format="date")),
* @OA\Parameter(name="work_date_to", in="query", required=false, @OA\Schema(type="string", format="date")),
* @OA\Parameter(name="process_type", in="query", required=false, @OA\Schema(type="string", enum={"screen","slat","bending"})),
*
* @OA\Response(response=200, description="조회 성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", ref="#/components/schemas/WorkResultStats")
* )
* ),
*
* @OA\Response(response=401, description="인증 실패")
* )
*/
public function stats() {}
/**
* @OA\Get(
* path="/api/v1/work-results/{id}",
* tags={"WorkResult"},
* summary="작업실적 상세 조회",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
*
* @OA\Response(response=200, description="조회 성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", ref="#/components/schemas/WorkResult")
* )
* ),
*
* @OA\Response(response=404, description="리소스 없음")
* )
*/
public function show() {}
/**
* @OA\Post(
* path="/api/v1/work-results",
* tags={"WorkResult"},
* summary="작업실적 등록",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\RequestBody(required=true,
*
* @OA\JsonContent(ref="#/components/schemas/WorkResultCreateRequest")
* ),
*
* @OA\Response(response=200, description="등록 성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", ref="#/components/schemas/WorkResult")
* )
* ),
*
* @OA\Response(response=422, description="유효성 검증 실패")
* )
*/
public function store() {}
/**
* @OA\Put(
* path="/api/v1/work-results/{id}",
* tags={"WorkResult"},
* summary="작업실적 수정",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
*
* @OA\RequestBody(required=true,
*
* @OA\JsonContent(ref="#/components/schemas/WorkResultUpdateRequest")
* ),
*
* @OA\Response(response=200, description="수정 성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", ref="#/components/schemas/WorkResult")
* )
* ),
*
* @OA\Response(response=404, description="리소스 없음"),
* @OA\Response(response=422, description="유효성 검증 실패")
* )
*/
public function update() {}
/**
* @OA\Delete(
* path="/api/v1/work-results/{id}",
* tags={"WorkResult"},
* summary="작업실적 삭제",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
*
* @OA\Response(response=200, description="삭제 성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", type="string", example="success")
* )
* ),
*
* @OA\Response(response=404, description="리소스 없음")
* )
*/
public function destroy() {}
/**
* @OA\Patch(
* path="/api/v1/work-results/{id}/inspection",
* tags={"WorkResult"},
* summary="검사 상태 토글",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
*
* @OA\Response(response=200, description="성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", ref="#/components/schemas/WorkResult")
* )
* ),
*
* @OA\Response(response=404, description="리소스 없음")
* )
*/
public function toggleInspection() {}
/**
* @OA\Patch(
* path="/api/v1/work-results/{id}/packaging",
* tags={"WorkResult"},
* summary="포장 상태 토글",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
*
* @OA\Response(response=200, description="성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", ref="#/components/schemas/WorkResult")
* )
* ),
*
* @OA\Response(response=404, description="리소스 없음")
* )
*/
public function togglePackaging() {}
}

View File

@@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('work_results', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->comment('테넌트 ID');
$table->foreignId('work_order_id')->constrained()->comment('작업지시 ID');
$table->foreignId('work_order_item_id')->nullable()->constrained()->comment('작업지시 품목 ID');
$table->string('lot_no', 50)->comment('로트번호');
$table->date('work_date')->comment('작업일');
$table->enum('process_type', ['screen', 'slat', 'bending'])->comment('공정구분');
$table->string('product_name', 200)->comment('품목명');
$table->string('specification', 100)->nullable()->comment('규격');
$table->integer('production_qty')->default(0)->comment('생산수량');
$table->integer('good_qty')->default(0)->comment('양품수량');
$table->integer('defect_qty')->default(0)->comment('불량수량');
$table->decimal('defect_rate', 5, 2)->default(0)->comment('불량률 (%)');
$table->boolean('is_inspected')->default(false)->comment('검사 완료 여부');
$table->boolean('is_packaged')->default(false)->comment('포장 완료 여부');
$table->foreignId('worker_id')->nullable()->constrained('users')->comment('작업자 ID');
$table->text('memo')->nullable()->comment('비고');
$table->foreignId('created_by')->nullable()->constrained('users')->comment('생성자');
$table->foreignId('updated_by')->nullable()->constrained('users')->comment('수정자');
$table->timestamps();
$table->softDeletes();
// 인덱스
$table->index(['tenant_id', 'work_date']);
$table->index(['tenant_id', 'process_type']);
$table->index(['tenant_id', 'work_order_id']);
$table->index('lot_no');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('work_results');
}
};

View File

@@ -122,6 +122,20 @@
'deleted' => '자재가 삭제되었습니다.', 'deleted' => '자재가 삭제되었습니다.',
], ],
// 입고 관리
'receiving' => [
'fetched' => '입고를 조회했습니다.',
'created' => '입고가 등록되었습니다.',
'updated' => '입고가 수정되었습니다.',
'deleted' => '입고가 삭제되었습니다.',
'processed' => '입고처리가 완료되었습니다.',
],
// 재고 관리
'stock' => [
'fetched' => '재고를 조회했습니다.',
],
// 단가 관리 // 단가 관리
'pricing' => [ 'pricing' => [
'created' => '단가가 등록되었습니다.', 'created' => '단가가 등록되었습니다.',
@@ -391,4 +405,27 @@
'cancelled' => '휴가가 취소되었습니다.', 'cancelled' => '휴가가 취소되었습니다.',
'granted' => '휴가가 부여되었습니다.', 'granted' => '휴가가 부여되었습니다.',
], ],
// 작업지시 관리
'work_order' => [
'fetched' => '작업지시를 조회했습니다.',
'created' => '작업지시가 등록되었습니다.',
'updated' => '작업지시가 수정되었습니다.',
'deleted' => '작업지시가 삭제되었습니다.',
'status_updated' => '작업지시 상태가 변경되었습니다.',
'assigned' => '작업지시가 배정되었습니다.',
'bending_toggled' => '벤딩 항목이 변경되었습니다.',
'issue_added' => '이슈가 등록되었습니다.',
'issue_resolved' => '이슈가 해결되었습니다.',
],
// 작업실적 관리
'work_result' => [
'fetched' => '작업실적을 조회했습니다.',
'created' => '작업실적이 등록되었습니다.',
'updated' => '작업실적이 수정되었습니다.',
'deleted' => '작업실적이 삭제되었습니다.',
'inspection_toggled' => '검사 상태가 변경되었습니다.',
'packaging_toggled' => '포장 상태가 변경되었습니다.',
],
]; ];

View File

@@ -38,6 +38,9 @@
use App\Http\Controllers\Api\V1\VendorLedgerController; use App\Http\Controllers\Api\V1\VendorLedgerController;
use App\Http\Controllers\Api\V1\BankTransactionController; use App\Http\Controllers\Api\V1\BankTransactionController;
use App\Http\Controllers\Api\V1\CardTransactionController; use App\Http\Controllers\Api\V1\CardTransactionController;
use App\Http\Controllers\Api\V1\ReceivablesController;
use App\Http\Controllers\Api\V1\DailyReportController;
use App\Http\Controllers\Api\V1\ComprehensiveAnalysisController;
use App\Http\Controllers\Api\V1\EstimateController; use App\Http\Controllers\Api\V1\EstimateController;
use App\Http\Controllers\Api\V1\FileStorageController; use App\Http\Controllers\Api\V1\FileStorageController;
use App\Http\Controllers\Api\V1\FolderController; use App\Http\Controllers\Api\V1\FolderController;
@@ -56,6 +59,7 @@
use App\Http\Controllers\Api\V1\ItemsController; use App\Http\Controllers\Api\V1\ItemsController;
use App\Http\Controllers\Api\V1\ItemsFileController; use App\Http\Controllers\Api\V1\ItemsFileController;
use App\Http\Controllers\Api\V1\LeaveController; use App\Http\Controllers\Api\V1\LeaveController;
use App\Http\Controllers\Api\V1\LeavePolicyController;
use App\Http\Controllers\Api\V1\LoanController; use App\Http\Controllers\Api\V1\LoanController;
use App\Http\Controllers\Api\V1\MenuController; use App\Http\Controllers\Api\V1\MenuController;
use App\Http\Controllers\Api\V1\ModelSetController; use App\Http\Controllers\Api\V1\ModelSetController;
@@ -96,6 +100,7 @@
use App\Http\Controllers\Api\V1\UserRoleController; use App\Http\Controllers\Api\V1\UserRoleController;
use App\Http\Controllers\Api\V1\WithdrawalController; use App\Http\Controllers\Api\V1\WithdrawalController;
use App\Http\Controllers\Api\V1\WorkOrderController; use App\Http\Controllers\Api\V1\WorkOrderController;
use App\Http\Controllers\Api\V1\WorkResultController;
use App\Http\Controllers\Api\V1\WorkSettingController; use App\Http\Controllers\Api\V1\WorkSettingController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@@ -328,6 +333,10 @@
Route::post('/{id}/cancel', [LeaveController::class, 'cancel'])->name('v1.leaves.cancel'); Route::post('/{id}/cancel', [LeaveController::class, 'cancel'])->name('v1.leaves.cancel');
}); });
// Leave Policy API (휴가 정책)
Route::get('/leave-policy', [LeavePolicyController::class, 'show'])->name('v1.leave-policy.show');
Route::put('/leave-policy', [LeavePolicyController::class, 'update'])->name('v1.leave-policy.update');
// Approval Form API (결재 양식) // Approval Form API (결재 양식)
Route::prefix('approval-forms')->group(function () { Route::prefix('approval-forms')->group(function () {
Route::get('', [ApprovalFormController::class, 'index'])->name('v1.approval-forms.index'); Route::get('', [ApprovalFormController::class, 'index'])->name('v1.approval-forms.index');
@@ -499,6 +508,22 @@
Route::get('/accounts', [BankTransactionController::class, 'accounts'])->name('v1.bank-transactions.accounts'); Route::get('/accounts', [BankTransactionController::class, 'accounts'])->name('v1.bank-transactions.accounts');
}); });
// Receivables API (채권 현황)
Route::prefix('receivables')->group(function () {
Route::get('', [ReceivablesController::class, 'index'])->name('v1.receivables.index');
Route::get('/summary', [ReceivablesController::class, 'summary'])->name('v1.receivables.summary');
});
// Daily Report API (일일 보고서)
Route::prefix('daily-report')->group(function () {
Route::get('/note-receivables', [DailyReportController::class, 'noteReceivables'])->name('v1.daily-report.note-receivables');
Route::get('/daily-accounts', [DailyReportController::class, 'dailyAccounts'])->name('v1.daily-report.daily-accounts');
Route::get('/summary', [DailyReportController::class, 'summary'])->name('v1.daily-report.summary');
});
// Comprehensive Analysis API (종합 분석 보고서)
Route::get('/comprehensive-analysis', [ComprehensiveAnalysisController::class, 'index'])->name('v1.comprehensive-analysis.index');
// Plan API (요금제 관리) // Plan API (요금제 관리)
Route::prefix('plans')->group(function () { Route::prefix('plans')->group(function () {
Route::get('', [PlanController::class, 'index'])->name('v1.plans.index'); Route::get('', [PlanController::class, 'index'])->name('v1.plans.index');
@@ -1008,6 +1033,21 @@
Route::patch('/{id}/issues/{issueId}/resolve', [WorkOrderController::class, 'resolveIssue'])->whereNumber('id')->name('v1.work-orders.issues.resolve'); // 이슈 해결 Route::patch('/{id}/issues/{issueId}/resolve', [WorkOrderController::class, 'resolveIssue'])->whereNumber('id')->name('v1.work-orders.issues.resolve'); // 이슈 해결
}); });
// 작업실적 관리 API (Production)
Route::prefix('work-results')->group(function () {
// 기본 CRUD
Route::get('', [WorkResultController::class, 'index'])->name('v1.work-results.index'); // 목록
Route::get('/stats', [WorkResultController::class, 'stats'])->name('v1.work-results.stats'); // 통계
Route::post('', [WorkResultController::class, 'store'])->name('v1.work-results.store'); // 생성
Route::get('/{id}', [WorkResultController::class, 'show'])->whereNumber('id')->name('v1.work-results.show'); // 상세
Route::put('/{id}', [WorkResultController::class, 'update'])->whereNumber('id')->name('v1.work-results.update'); // 수정
Route::delete('/{id}', [WorkResultController::class, 'destroy'])->whereNumber('id')->name('v1.work-results.destroy'); // 삭제
// 상태 토글
Route::patch('/{id}/inspection', [WorkResultController::class, 'toggleInspection'])->whereNumber('id')->name('v1.work-results.inspection'); // 검사 상태 토글
Route::patch('/{id}/packaging', [WorkResultController::class, 'togglePackaging'])->whereNumber('id')->name('v1.work-results.packaging'); // 포장 상태 토글
});
// 파일 저장소 API // 파일 저장소 API
Route::prefix('files')->group(function () { Route::prefix('files')->group(function () {
Route::post('/upload', [FileStorageController::class, 'upload'])->name('v1.files.upload'); // 파일 업로드 (임시) Route::post('/upload', [FileStorageController::class, 'upload'])->name('v1.files.upload'); // 파일 업로드 (임시)