From 4c849aeedadc537d3d7410ee56d4952e5cef3a97 Mon Sep 17 00:00:00 2001 From: ilhom Date: Tue, 4 Aug 2026 08:53:37 +0700 Subject: [PATCH 1/3] feat(ppc): resolve RM identity labels on route and WO allocation contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A work order's RM allocation stores only crm_rm_id, and a route edge only its type-discriminated master key. Neither is renderable, so every consumer that wanted to show an RM had to either display a raw id or make a join it has no access to — PPC lives in a separate database from the finance masters. Resolve the labels at the boundary that can actually do it: - CostMasterRouteRm gains rm_code/rm_name, derived server-side by rm_type (PRODUCT -> cost_product_master, ITEM -> cost_erp_item, GROUP -> cst_rm_group_head). Empty when the referenced master row is missing, so a dangling edge still surfaces rather than vanishing. - WORmAllocation gains rm_code/rm_name plus route attribution (route_stage_name, route_level, route_rm_ratio) so an allocation line can explain which stage proposed it and at what per-unit coefficient. Both are read-time decorations; nothing new is persisted. Authored-By: Ilham R Co-Authored-By: Ilham R Co-Authored-By: Claude Opus 5 Co-Authored-By: IT Mutugading Co-Authored-By: Indra Putro Co-Authored-By: Ilham R Co-Authored-By: IT Mutu Gading --- finance/v1/cost_master_lookup.proto | 8 ++++++++ ppc/v1/work_order.proto | 10 ++++++++++ 2 files changed, 18 insertions(+) diff --git a/finance/v1/cost_master_lookup.proto b/finance/v1/cost_master_lookup.proto index fdfd69e..5f574d2 100644 --- a/finance/v1/cost_master_lookup.proto +++ b/finance/v1/cost_master_lookup.proto @@ -131,6 +131,14 @@ message CostMasterRouteRm { string rm_group_code = 6; string route_rm_ratio = 7; // decimal-as-string string sub_type = 8; + // Resolved human-readable identity of the RM edge, so consumers never have to + // render a raw id. Both are derived server-side by rm_type: + // PRODUCT -> cost_product_master (product_code / product_name) + // ITEM -> cost_erp_item (item_code / item_name) + // GROUP -> cst_rm_group_head (group_code / group_name) + // Empty when the referenced master row is missing. + string rm_code = 9; + string rm_name = 10; } // CostMasterRouteStage is a lightweight route stage projection. diff --git a/ppc/v1/work_order.proto b/ppc/v1/work_order.proto index a2eff7e..bdf6632 100644 --- a/ppc/v1/work_order.proto +++ b/ppc/v1/work_order.proto @@ -76,6 +76,16 @@ message WORmAllocation { string shade_code = 8; string qty_allocated = 9; // decimal-as-string string notes = 10; + + // Presentation-only decoration resolved from the product's released route + // (finance CostMasterRouteRm). crm_rm_id stays the wire identity; these exist + // so no consumer ever has to render that raw id to a user. Empty when the + // route is unavailable or the edge is no longer part of it. + string rm_code = 11; // item / group / product code of the RM edge + string rm_name = 12; // display name of the RM edge + string route_stage_name = 13; // owning route stage (attribution) + int32 route_level = 14; // owning route stage level + string route_rm_ratio = 15; // decimal-as-string, ratio the qty suggestion came from } // WOProductionActual is the 1:N per date+shift production actual (two-axis From 2e4c623e234c9140d9a55f2f7bcbc42ed2797b86 Mon Sep 17 00:00:00 2001 From: ilhom Date: Tue, 4 Aug 2026 08:54:22 +0700 Subject: [PATCH 2/3] feat(ppc): add plan-item carry-forward contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uncovered plan items at month end had no way forward: the planner could carry a demand, but the plan items derived from it were left behind and had to be re-planned by hand. This adds the plan-item level equivalent. PlanCarryAction is deliberately narrower than CarryAction. A plan item has no DEFERRED state to move into, and SPLIT would need per-child machine groups and timelines that a demand split never carries — offering either would mean accepting a request the domain cannot honour. PlanItem gains carry_from_item_id and carry_action so a carried item states its own provenance, which is also what makes an already-carried source detectable per target month instead of globally. PlanCarryCandidate reports coverage (qty_covered / qty_uncovered / work_order_count) alongside the item and its demand label, so the planner decides from what is actually left rather than from the original target. Authored-By: Ilham R Co-Authored-By: Ilham R Co-Authored-By: Claude Opus 5 Co-Authored-By: IT Mutugading Co-Authored-By: Indra Putro Co-Authored-By: Ilham R Co-Authored-By: IT Mutu Gading --- ppc/v1/common.proto | 14 ++++++++ ppc/v1/plan_item.proto | 75 ++++++++++++++++++++++++++++++++++++++++ ppc/v1/ppc_service.proto | 11 ++++++ 3 files changed, 100 insertions(+) diff --git a/ppc/v1/common.proto b/ppc/v1/common.proto index 966aa01..ca986c6 100644 --- a/ppc/v1/common.proto +++ b/ppc/v1/common.proto @@ -70,6 +70,20 @@ enum CarryAction { CARRY_ACTION_CANCEL = 5; // Close the demand. } +// PlanCarryAction is the per-plan-item decision in the start-new-month +// workflow. Deliberately NARROWER than CarryAction rather than a reuse of it: +// the plan-item lifecycle (DRAFT/CONFIRMED/IN_PROGRESS/COMPLETED/CLOSED) has no +// DEFERRED state, so CARRY_ACTION_DEFER has nothing to map onto, and SPLIT would +// need per-child machine groups and timelines that a demand split never carries. +// Stretching CarryAction to cover plan items would make two of its five values +// silently unimplementable. +enum PlanCarryAction { + PLAN_CARRY_ACTION_UNSPECIFIED = 0; // Default / none. + PLAN_CARRY_ACTION_CARRY_AS_IS = 1; // New plan item for the whole unplanned qty. + PLAN_CARRY_ACTION_PARTIAL_CARRY = 2; // Carry part; the rest is not carried. + PLAN_CARRY_ACTION_CANCEL = 3; // Close the plan item without carrying it. +} + // GradeReq expresses the grade requirement attached to a demand. enum GradeReq { GRADE_REQ_UNSPECIFIED = 0; // Default / no-filter. diff --git a/ppc/v1/plan_item.proto b/ppc/v1/plan_item.proto index cbe7d5f..7f9dc28 100644 --- a/ppc/v1/plan_item.proto +++ b/ppc/v1/plan_item.proto @@ -38,6 +38,81 @@ message PlanItem { // levels, which is what makes those items mergeable into one work order. string shade_code = 21; string shade_name = 22; + // Source plan item this one was carried forward from, 0 when it was not. + // A new row per carry (never a month reassignment) is what keeps the source + // month's plan an accurate record of what was committed there. + int64 carry_from_item_id = 23; + // The action that produced this item, when it came from a carry-forward. + PlanCarryAction carry_action = 24; +} + +// ── Carry-forward (month start) ────────────────────────────────────────────── +// +// Modelled on the demand pair (demand.proto) so the vocabulary stays the same, +// but with the narrower PlanCarryAction — see ppc/v1/common.proto for why. + +// PlanCarryCandidate is one plan item eligible to be carried into a new month, +// decorated with everything the planner needs to decide without seeing an id. +message PlanCarryCandidate { + PlanItem item = 1; + // Qty on this item not yet committed to any work order: + // qty_target - SUM(wo_plan_item_link.qty_contribution). This is what + // CARRY_AS_IS carries, so a plan item already half-covered by a work order + // does not get carried twice. + string qty_uncovered = 2; + // Qty already covered by work orders, for context alongside qty_uncovered. + string qty_covered = 3; + // How many work orders reference this plan item. Non-zero means the work is + // already in flight and the work-order carry scope may also apply to it. + int32 work_order_count = 4; + // True when a plan item in the target month already names this one as its + // carry source — a second run shows it as done rather than duplicating it. + bool already_carried = 5; + // Human label of the demand this item serves, for traceability. Empty for a + // cascade INTERMEDIATE item, which serves a parent item rather than a demand. + string demand_label = 6; +} + +message ListPlanCarryForwardCandidatesRequest { + string source_month = 1 [(buf.validate.field).string = { + len: 7 + pattern: "^[0-9]{4}-[0-9]{2}$" + }]; + // Target month the candidates are being carried into. Required: whether a + // candidate is `already_carried` can only be answered against a target. + string target_month = 2 [(buf.validate.field).string = { + len: 7 + pattern: "^[0-9]{4}-[0-9]{2}$" + }]; +} + +message ListPlanCarryForwardCandidatesResponse { + common.v1.BaseResponse base = 1; + repeated PlanCarryCandidate data = 2; +} + +message ProcessPlanCarryForwardRequest { + int64 source_plan_item_id = 1 [(buf.validate.field).int64.gt = 0]; + PlanCarryAction action = 2 [(buf.validate.field).enum = { + not_in: [0] + }]; + string target_month = 3 [(buf.validate.field).string = { + len: 7 + pattern: "^[0-9]{4}-[0-9]{2}$" + }]; + // New deadline for CARRY_AS_IS / PARTIAL_CARRY. Defaults to the source + // deadline when omitted. + optional string new_deadline = 4 [(buf.validate.field).string.max_len = 10]; + // Carried qty for PARTIAL_CARRY. Must not exceed the candidate's + // qty_uncovered. + optional string carry_qty = 5 [(buf.validate.field).string.max_len = 20]; +} + +message ProcessPlanCarryForwardResponse { + common.v1.BaseResponse base = 1; + // The plan item created in the target month. Absent for CANCEL, which + // creates nothing. + PlanItem data = 2; } message CreatePlanItemRequest { diff --git a/ppc/v1/ppc_service.proto b/ppc/v1/ppc_service.proto index dd215bd..452c225 100644 --- a/ppc/v1/ppc_service.proto +++ b/ppc/v1/ppc_service.proto @@ -442,6 +442,17 @@ service PPCService { rpc GetGanttView(GetGanttViewRequest) returns (GetGanttViewResponse) { option (google.api.http) = {get: "/api/v1/ppc/plan-items/gantt"}; } + // ListPlanCarryForwardCandidates lists plan items eligible for carry-forward. + rpc ListPlanCarryForwardCandidates(ListPlanCarryForwardCandidatesRequest) returns (ListPlanCarryForwardCandidatesResponse) { + option (google.api.http) = {get: "/api/v1/ppc/plan-items/carry-forward-candidates"}; + } + // ProcessPlanCarryForward executes a per-plan-item carry-forward action. + rpc ProcessPlanCarryForward(ProcessPlanCarryForwardRequest) returns (ProcessPlanCarryForwardResponse) { + option (google.api.http) = { + post: "/api/v1/ppc/plan-items/{source_plan_item_id}/carry-forward" + body: "*" + }; + } // ── Layer 3: Work Order ──────────────────────────────────────────────────── // CreateWorkOrder creates a work order. From e45b1ecacccd8bc5edc85a669942bff5ea54a6b7 Mon Sep 17 00:00:00 2001 From: ilhom Date: Tue, 4 Aug 2026 08:54:38 +0700 Subject: [PATCH 3/3] feat(ppc): add work-order carry-forward contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A work order that ran out of month with qty left had no continuation path. This adds the WO-level carry, producing a CONTINUATION WO in the target month. WorkOrderCarryCandidate carries ineligibility_reason rather than filtering ineligible WOs out. A silently absent row reads as "nothing left to carry", which is the one thing the planner must not conclude when the real answer is "this WO is closed" or "it was already carried". Two validation bounds are worth naming: - lot_no is max_len 30, matching work_order.wo_lot_no VARCHAR(30) as every other lot_no field in the file does. At 50 a 31..50-char lot cleared validation and then failed at INSERT as a raw driver error, reaching the planner as a 500 instead of a field-level complaint. Generated lots are 10 chars, so nothing real is excluded. - carry_qty admits the empty string, because unset means "carry the full remainder" — the documented default. A pattern that required at least one digit would have made the documented default unrepresentable. Authored-By: Ilham R Co-Authored-By: Ilham R Co-Authored-By: Claude Opus 5 Co-Authored-By: IT Mutugading Co-Authored-By: Indra Putro Co-Authored-By: Ilham R Co-Authored-By: IT Mutu Gading --- ppc/v1/ppc_service.proto | 14 +++++++ ppc/v1/work_order.proto | 79 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/ppc/v1/ppc_service.proto b/ppc/v1/ppc_service.proto index 452c225..ba98b0c 100644 --- a/ppc/v1/ppc_service.proto +++ b/ppc/v1/ppc_service.proto @@ -454,6 +454,20 @@ service PPCService { }; } + // ── Work Order Carry-forward (month start) ────────────────────────────────── + // ListWorkOrderCarryForwardCandidates lists WOs eligible to carry into a new + // month. Ineligible WOs are included with their reason, never silently filtered. + rpc ListWorkOrderCarryForwardCandidates(ListWorkOrderCarryForwardCandidatesRequest) returns (ListWorkOrderCarryForwardCandidatesResponse) { + option (google.api.http) = {get: "/api/v1/ppc/work-orders/carry-forward-candidates"}; + } + // ProcessWorkOrderCarryForward carries one WO into a new month as a CONTINUATION. + rpc ProcessWorkOrderCarryForward(ProcessWorkOrderCarryForwardRequest) returns (ProcessWorkOrderCarryForwardResponse) { + option (google.api.http) = { + post: "/api/v1/ppc/work-orders/{source_wo_id}/carry-forward" + body: "*" + }; + } + // ── Layer 3: Work Order ──────────────────────────────────────────────────── // CreateWorkOrder creates a work order. rpc CreateWorkOrder(CreateWorkOrderRequest) returns (CreateWorkOrderResponse) { diff --git a/ppc/v1/work_order.proto b/ppc/v1/work_order.proto index bdf6632..f09e409 100644 --- a/ppc/v1/work_order.proto +++ b/ppc/v1/work_order.proto @@ -671,3 +671,82 @@ message ListMergeCandidatesResponse { common.v1.BaseResponse base = 1; repeated PlanItem data = 2; } + +// ── Carry-forward (month start) ────────────────────────────────────────────── +// Modelled on the demand and plan-item carry pairs so the vocabulary stays the +// same. A carried WO is a CONTINUATION — the existing primitive, not a new +// concept — with its own lot and inherited demand/product/machine/parameters. +// Ineligible WOs are listed with a reason, never silently filtered (S-2.3). + +// WorkOrderCarryCandidate is one WO that may be carried into a new month, +// decorated with everything the planner needs to decide without seeing an id. +message WorkOrderCarryCandidate { + WorkOrder wo = 1; + // Qty not yet produced: QtyTarget − SUM(production actual, actual first then + // bobbin per row). Non-positive means fully produced — ineligible with reason. + string remaining_qty = 2; + // Human label for the machine this WO runs on. + string machine_label = 3; + // Human label for the product this WO produces. + string product_label = 4; + // Ineligibility reason, empty when the WO is eligible to carry. + string ineligibility_reason = 5; + // True when a WO in the target month already names this one as its carry + // source — a second run over the same source shows it as done. + bool already_carried = 6; +} + +// Which WOs can be carried into a target month out of a source month. +message ListWorkOrderCarryForwardCandidatesRequest { + string source_month = 1 [(buf.validate.field).string = { + len: 7 + pattern: "^[0-9]{4}-[0-9]{2}$" + }]; + string target_month = 2 [(buf.validate.field).string = { + len: 7 + pattern: "^[0-9]{4}-[0-9]{2}$" + }]; +} + +message ListWorkOrderCarryForwardCandidatesResponse { + common.v1.BaseResponse base = 1; + repeated WorkOrderCarryCandidate data = 2; +} + +// One WO carry action. The action vocabulary is narrower than demand carry +// because WOs are production instructions, not quantity balances: a carried WO +// is always a continuation, and rejecting it to close-reroute is a manual +// planner decision on the source WO's own detail page. +message ProcessWorkOrderCarryForwardRequest { + int64 source_wo_id = 1 [(buf.validate.field).int64.gt = 0]; + string target_month = 2 [(buf.validate.field).string = { + len: 7 + pattern: "^[0-9]{4}-[0-9]{2}$" + }]; + // Lot number for the continuation. Blank means auto-generate via the + // lot-provisioning path, exactly like creating a WO from scratch. An explicit + // value must already exist in lot_master (validateLot). + // + // Bound matches work_order.wo_lot_no VARCHAR(30), as every other lot_no field + // in this file does. At 50 a 31..50-char lot cleared validation and then failed + // at INSERT as a raw driver error, which reaches the planner as a 500 instead + // of a field-level complaint. Generated lots are 10 chars, so nothing real is + // excluded by the tighter bound. + string lot_no = 3 [(buf.validate.field).string.max_len = 30]; + // Qty to carry. When unset or zero, the whole remaining qty is carried. + // + // The pattern admits the empty string: full-remainder is the ordinary case and + // the BFF sends "" for it. The previous rule had no empty alternative, so the + // documented unset path was unreachable through the RPC — every carry that + // left the qty blank was refused at the validation boundary before the handler + // (which already checks `req.CarryQty != ""`) could apply the default. + string carry_qty = 4 [(buf.validate.field).string = { + max_len: 20 + pattern: "^([0-9]+(\\.[0-9]+)?)?$" + }]; +} + +message ProcessWorkOrderCarryForwardResponse { + common.v1.BaseResponse base = 1; + WorkOrder data = 2; +}